feat: implement relationship system

This commit is contained in:
hypercross 2026-05-31 15:54:29 +08:00
parent 32f8f29912
commit d0bb119911
2 changed files with 403 additions and 0 deletions

22
src/relationship.ts Normal file
View File

@ -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() };
}

381
test/relationships.test.ts Normal file
View File

@ -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([]);
});
});