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