feat: implement relationship system
This commit is contained in:
parent
32f8f29912
commit
d0bb119911
|
|
@ -0,0 +1,22 @@
|
||||||
|
// ── Relationship ─────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* A relationship definition — like a component, but represents a directed
|
||||||
|
* link between two entities.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const ChildOf = defineRelationship();
|
||||||
|
* world.relate(child, ChildOf, parent);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface RelationshipDef {
|
||||||
|
/** Unique symbol used as the storage key. */
|
||||||
|
readonly _key: symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a named relationship between entities.
|
||||||
|
*/
|
||||||
|
export function defineRelationship(): RelationshipDef {
|
||||||
|
return { _key: Symbol() };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
World,
|
||||||
|
defineRelationship,
|
||||||
|
type RelationshipUpdate,
|
||||||
|
type WorldEvent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// ── Relationships ─────────────────────────────────────
|
||||||
|
const ChildOf = defineRelationship();
|
||||||
|
const Targeting = defineRelationship();
|
||||||
|
const Inside = defineRelationship();
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────
|
||||||
|
function collectEvents(world: World): WorldEvent[] {
|
||||||
|
const log: WorldEvent[] = [];
|
||||||
|
world.events$.subscribe((e: WorldEvent) => log.push(e));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelUpdates(
|
||||||
|
obs$: { subscribe: Function },
|
||||||
|
): RelationshipUpdate[] {
|
||||||
|
const log: RelationshipUpdate[] = [];
|
||||||
|
obs$.subscribe((u: RelationshipUpdate) => log.push(u));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic relate / unrelate ───────────────────────────
|
||||||
|
describe("Relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relates two entities", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBe(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelated returns undefined when no relationship", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns reverse lookup", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, parent);
|
||||||
|
world.relate(b, ChildOf, parent);
|
||||||
|
|
||||||
|
const children = world.getRelatedTo(parent, ChildOf);
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children).toContain(a);
|
||||||
|
expect(children).toContain(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns empty when no edges", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unrelate removes the relationship", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
world.unrelate(child, ChildOf);
|
||||||
|
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unrelate is idempotent", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(() => world.unrelate(e, ChildOf)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate replaces existing relationship", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
expect(world.getRelated(a, ChildOf)).toBe(b);
|
||||||
|
expect(world.getRelatedTo(b, ChildOf)).toContain(a);
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
expect(world.getRelated(a, ChildOf)).toBe(c);
|
||||||
|
// a should no longer point to b
|
||||||
|
expect(world.getRelatedTo(b, ChildOf)).toEqual([]);
|
||||||
|
expect(world.getRelatedTo(c, ChildOf)).toContain(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────
|
||||||
|
describe("Relationship events", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipAdded event", () => {
|
||||||
|
const events = collectEvents(world);
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const ev = events.find((e) => e.type === "relationshipAdded")!;
|
||||||
|
expect(ev).toMatchObject({
|
||||||
|
type: "relationshipAdded",
|
||||||
|
source: a,
|
||||||
|
target: b,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved on unrelate", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
const ev = events.find((e) => e.type === "relationshipRemoved")!;
|
||||||
|
expect(ev).toMatchObject({
|
||||||
|
type: "relationshipRemoved",
|
||||||
|
source: a,
|
||||||
|
target: b,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved when replacing an edge", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
const removed = events.filter((e) => e.type === "relationshipRemoved");
|
||||||
|
const added = events.filter((e) => e.type === "relationshipAdded");
|
||||||
|
|
||||||
|
expect(removed).toHaveLength(1);
|
||||||
|
expect(removed[0]).toMatchObject({ source: a, target: b });
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
expect(added[0]).toMatchObject({ source: a, target: c });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Observable relationships ──────────────────────────
|
||||||
|
describe("Observable relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits added on relate", () => {
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].added).toEqual([{ source: a, target: b }]);
|
||||||
|
expect(log[0].removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed on unrelate", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed+added on replacement", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
// Should have two updates: one removed, one added
|
||||||
|
expect(log).toHaveLength(2);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
expect(log[1].added).toEqual([{ source: a, target: c }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds with existing relationships", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
|
||||||
|
// Unrelate should trigger removed — proving seed worked
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observers are scoped to relationship type", () => {
|
||||||
|
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
expect(childLog).toHaveLength(1);
|
||||||
|
expect(targetLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Destroy cleanup ───────────────────────────────────
|
||||||
|
describe("Destroy cleanup", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes edges when source is destroyed", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
world.destroy(child);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes edges when target is destroyed", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
world.destroy(parent);
|
||||||
|
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved events on destroy", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, Targeting, b);
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const tLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
world.destroy(a);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: c }]);
|
||||||
|
|
||||||
|
expect(tLog).toHaveLength(1);
|
||||||
|
expect(tLog[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects cross-relationship observers when destroying target", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.destroy(parent);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: child, target: parent }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles destroy when entity is source for multiple relationships", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
world.relate(a, Targeting, c);
|
||||||
|
|
||||||
|
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
world.destroy(a);
|
||||||
|
|
||||||
|
expect(childLog).toHaveLength(1);
|
||||||
|
expect(childLog[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
|
||||||
|
expect(targetLog).toHaveLength(1);
|
||||||
|
expect(targetLog[0].removed).toEqual([{ source: a, target: c }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multiple relationship types ──────────────────────
|
||||||
|
describe("Multiple relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an entity can have different relationship types simultaneously", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
|
||||||
|
world.relate(e, ChildOf, a);
|
||||||
|
world.relate(e, Targeting, b);
|
||||||
|
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBe(a);
|
||||||
|
expect(world.getRelated(e, Targeting)).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relationships of different types don't interfere", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
world.relate(a, Targeting, c);
|
||||||
|
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
expect(world.getRelated(a, ChildOf)).toBeUndefined();
|
||||||
|
// Targeting should still be intact
|
||||||
|
expect(world.getRelated(a, Targeting)).toBe(c);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dead entities ─────────────────────────────────────
|
||||||
|
describe("Dead entity safety", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate throws on dead source", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.destroy(a);
|
||||||
|
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate throws on dead target", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.destroy(b);
|
||||||
|
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelated returns undefined for dead entity", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns empty for dead entity", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue