From d0bb119911124f2150c42a758dc4d083c6450df7 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 31 May 2026 15:54:29 +0800 Subject: [PATCH] feat: implement relationship system --- src/relationship.ts | 22 +++ test/relationships.test.ts | 381 +++++++++++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/relationship.ts create mode 100644 test/relationships.test.ts diff --git a/src/relationship.ts b/src/relationship.ts new file mode 100644 index 0000000..b3b240f --- /dev/null +++ b/src/relationship.ts @@ -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() }; +} diff --git a/test/relationships.test.ts b/test/relationships.test.ts new file mode 100644 index 0000000..c03e0db --- /dev/null +++ b/test/relationships.test.ts @@ -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([]); + }); +});