feat: add relationship support and observability

Introduces relationship management to the World, including
storage for forward and reverse relationships. Adds new observable
events and a `RelationshipUpdate` stream to track when relationships
are added or removed.
This commit is contained in:
2026-05-31 15:54:21 +08:00
parent ba4a688f57
commit 32f8f29912
4 changed files with 361 additions and 78 deletions
+35 -8
View File
@@ -1,5 +1,6 @@
import type { ComponentDef } from '../component';
import type { Entity } from '../entity';
import type { ComponentDef } from "../component";
import type { RelationshipDef } from "../relationship";
import type { Entity } from "../entity";
// ── World Events ──────────────────────────────────────
/**
@@ -11,36 +12,52 @@ export type WorldEvent =
| DestroyedEvent
| ComponentAddedEvent
| ComponentRemovedEvent
| ComponentChangedEvent;
| ComponentChangedEvent
| RelAddedEvent
| RelRemovedEvent;
export interface SpawnedEvent {
type: 'spawned';
type: "spawned";
entity: Entity;
}
export interface DestroyedEvent {
type: 'destroyed';
type: "destroyed";
entity: Entity;
}
export interface ComponentAddedEvent {
type: 'componentAdded';
type: "componentAdded";
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentRemovedEvent {
type: 'componentRemoved';
type: "componentRemoved";
entity: Entity;
component: ComponentDef<any>;
}
export interface ComponentChangedEvent {
type: 'componentChanged';
type: "componentChanged";
entity: Entity;
component: ComponentDef<any>;
}
export interface RelAddedEvent {
type: "relationshipAdded";
source: Entity;
target: Entity;
relationship: RelationshipDef;
}
export interface RelRemovedEvent {
type: "relationshipRemoved";
source: Entity;
target: Entity;
relationship: RelationshipDef;
}
// ── Query Observables ────────────────────────────────
/**
* Emitted by `world.observe(query)` when the result set changes.
@@ -53,3 +70,13 @@ export interface QueryUpdate {
/** Entities still matching that had a `markDirty` this frame. */
changed: Entity[];
}
/**
* Emitted by `world.observeRelated(rel)` when relationships change.
*/
export interface RelationshipUpdate {
/** Newly established relationships. */
added: { source: Entity; target: Entity }[];
/** Broken relationships. */
removed: { source: Entity; target: Entity }[];
}
+127 -44
View File
@@ -1,33 +1,31 @@
import { Subject } from "rxjs";
import type { Query } from "../query";
import type { Entity } from "../entity";
import type { WorldEvent, QueryUpdate } from "./events";
import type { WorldEvent, QueryUpdate, RelationshipUpdate } from "./events";
import type { RelationshipDef } from "../relationship";
// ── Internal observer state per query ────────────────
// ── Internal state ───────────────────────────────────
interface QueryObserverState {
query: Query;
/** Cached set of entities currently matching the query. */
matched: Set<Entity>;
subject: Subject<QueryUpdate>;
}
interface RelationshipObserverState {
rel: RelationshipDef;
edges: Set<string>;
subject: Subject<RelationshipUpdate>;
}
// ── Observable layer ─────────────────────────────────
/**
* Manages observable subscriptions for a World.
* Kept separate from the World class for clarity.
*/
export class ObservableLayer {
/** Raw event stream. */
readonly events$ = new Subject<WorldEvent>();
/** Active query observers. */
private _observers: QueryObserverState[] = [];
private _relObservers: RelationshipObserverState[] = [];
// ── Query observers ─────────────────────────────
/**
* Get or create a Subject for a query.
* If this is the first subscription, seed the matched set using
* the provided queryMatches callback.
*/
observe(query: Query): Subject<QueryUpdate> {
const existing = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
@@ -39,57 +37,76 @@ export class ObservableLayer {
matched: new Set(),
subject: new Subject<QueryUpdate>(),
};
// Seeding is handled by World (we don't have entity iteration here).
// The World's observe() method seeds via a separate path.
this._observers.push(state);
return state.subject;
}
/**
* Seed the initial matched set for an observer.
* Called once by World.observe() with all currently-matching entities.
*/
seed(query: Query, entities: Entity[]): void {
const obs = this._observers.find(
(o) => o.query === query || queriesEqual(o.query, query),
);
if (!obs) return;
for (const e of entities) obs.matched.add(e);
}
for (const e of entities) {
obs.matched.add(e);
// ── Relationship observers ───────────────────────
observeRelated(rel: RelationshipDef): Subject<RelationshipUpdate> {
const existing = this._relObservers.find((o) => o.rel._key === rel._key);
if (existing) return existing.subject;
const state: RelationshipObserverState = {
rel,
edges: new Set(),
subject: new Subject<RelationshipUpdate>(),
};
this._relObservers.push(state);
return state.subject;
}
seedRelated(
rel: RelationshipDef,
edges: { source: Entity; target: Entity }[],
): void {
const obs = this._relObservers.find((o) => o.rel._key === rel._key);
if (!obs) return;
for (const { source, target } of edges) {
obs.edges.add(edgeKey(source, target));
}
}
/**
* Feed an event into the observable system.
* Called by the World after state mutation.
*/
// ── Event dispatch ───────────────────────────────
onEvent(
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
// Forward to the global stream
this.events$.next(event);
// Update each observer
for (const observer of this._observers) {
this._updateObserver(observer, event, queryMatches);
for (const o of this._observers) {
this._updateObserver(o, event, queryMatches);
}
for (const o of this._relObservers) {
this._updateRelObserver(o, event);
}
}
// ── Private ──────────────────────────────────────
private _updateObserver(
obs: QueryObserverState,
event: WorldEvent,
queryMatches: (query: Query, e: Entity) => boolean,
): void {
const e = event.entity;
// Only entity-bearing events affect queries
if (!("entity" in event)) return;
const e = event.entity!;
const wasMatched = obs.matched.has(e);
const nowMatches = queryMatches(obs.query, e);
switch (event.type) {
case "spawned":
// Entity is bare; won't match unless components added later
break;
case "destroyed":
@@ -120,25 +137,87 @@ export class ObservableLayer {
}
}
/** Reset all observer state (useful for tests). */
reset(): void {
for (const obs of this._observers) {
obs.subject.complete();
obs.matched.clear();
private _updateRelObserver(
obs: RelationshipObserverState,
event: WorldEvent,
): void {
switch (event.type) {
case "relationshipAdded": {
if (event.relationship._key !== obs.rel._key) break;
const key = edgeKey(event.source, event.target);
if (obs.edges.has(key)) break;
obs.edges.add(key);
obs.subject.next({
added: [{ source: event.source, target: event.target }],
removed: [],
});
break;
}
case "relationshipRemoved": {
if (event.relationship._key !== obs.rel._key) break;
const key = edgeKey(event.source, event.target);
if (!obs.edges.has(key)) break;
obs.edges.delete(key);
obs.subject.next({
added: [],
removed: [{ source: event.source, target: event.target }],
});
break;
}
case "destroyed": {
// World emits relationshipRemoved for each edge before destroy,
// so those fire first. Then we arrive here — just clean up.
const removed: { source: Entity; target: Entity }[] = [];
for (const key of obs.edges) {
const [si, ti] = key.split(":").map(Number);
const idx = event.entity & 0xfffff;
if (si === idx || ti === idx) {
removed.push({
source: si as Entity,
target: ti as Entity,
});
}
}
for (const r of removed) {
obs.edges.delete(edgeKey(r.source, r.target));
}
if (removed.length > 0) {
obs.subject.next({ added: [], removed });
}
break;
}
}
this._observers = [];
}
/** Complete all streams. */
complete(): void {
this.events$.complete();
for (const obs of this._observers) {
obs.subject.complete();
// ── Teardown ─────────────────────────────────────
reset(): void {
for (const o of this._observers) {
o.subject.complete();
o.matched.clear();
}
this._observers = [];
for (const o of this._relObservers) {
o.subject.complete();
o.edges.clear();
}
this._relObservers = [];
}
complete(): void {
this.events$.complete();
for (const o of this._observers) o.subject.complete();
this._observers = [];
for (const o of this._relObservers) o.subject.complete();
this._relObservers = [];
}
}
// ── Helpers ─────────────────────────────────────
function queriesEqual(a: Query, b: Query): boolean {
if (a.with.length !== b.with.length) return false;
if (a.not.length !== b.not.length) return false;
@@ -147,3 +226,7 @@ function queriesEqual(a: Query, b: Query): boolean {
a.not.every((c, i) => c === b.not[i])
);
}
function edgeKey(source: Entity, target: Entity): string {
return `${source & 0xfffff}:${target & 0xfffff}`;
}