535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
World,
|
|
defineRelationship,
|
|
type RelationshipUpdate,
|
|
type WorldEvent,
|
|
} from "../src/index";
|
|
|
|
// ── Relationships ─────────────────────────────────────
|
|
const ChildOf = defineRelationship("childOf");
|
|
const Targeting = defineRelationship("targeting");
|
|
const Inside = defineRelationship("inside");
|
|
|
|
// ── 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([]);
|
|
});
|
|
});
|
|
|
|
// ── Data-carrying relationships ───────────────────────
|
|
describe("Data-carrying relationships", () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = new World();
|
|
});
|
|
|
|
it("defines a data-carrying relationship", () => {
|
|
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
|
|
expect(Health.name).toBe("health");
|
|
expect(Health.defaults).toEqual({ hp: 100, maxHp: 100 });
|
|
});
|
|
|
|
it("relate stores defaults as data", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game);
|
|
const data = world.getRelData(player, Health);
|
|
expect(data).toEqual({ hp: 100 });
|
|
});
|
|
|
|
it("relate accepts data override", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game, { hp: 50 });
|
|
const data = world.getRelData(player, Health);
|
|
expect(data).toEqual({ hp: 50 });
|
|
});
|
|
|
|
it("setRelData updates relationship data", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game);
|
|
world.setRelData(player, Health, { hp: 75 });
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 75 });
|
|
});
|
|
|
|
it("getRelData returns defaults when no data was set", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
|
|
// Even without an edge, getRelData returns a copy of defaults
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
|
|
});
|
|
|
|
it("setRelData works even without prior relate", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
|
|
world.setRelData(player, Health, { hp: 50 });
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
|
|
});
|
|
|
|
it("data survives unrelate and re-relate", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game, { hp: 50 });
|
|
world.unrelate(player, Health);
|
|
|
|
// After unrelate, stored data is gone, returns defaults
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
|
|
|
|
world.relate(player, Health, game, { hp: 80 });
|
|
// Note: this is a *new* relate with data, so stored data is { hp: 80 }
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 80 });
|
|
});
|
|
|
|
it("data is cleaned up on destroy", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game, { hp: 50 });
|
|
world.destroy(player);
|
|
|
|
// After destroy the entity is dead — assertAlive throws, not the data lookup
|
|
expect(() => world.getRelData(player, Health)).toThrow("not alive");
|
|
});
|
|
|
|
it("setRelData with no prior edge stores data that getsRelated does not see", () => {
|
|
const Health = defineRelationship("health", { hp: 100 });
|
|
const player = world.spawn();
|
|
|
|
world.setRelData(player, Health, { hp: 50 });
|
|
// No edge exists yet
|
|
expect(world.getRelated(player, Health)).toBeUndefined();
|
|
// But data is stored (decoupled storage)
|
|
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
|
|
});
|
|
|
|
it("data-carrying relationships serialize and deserialize", () => {
|
|
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
|
|
const player = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(player, Health, game, { hp: 50, maxHp: 100 });
|
|
|
|
const snapshot = world.toJSON();
|
|
|
|
// Verify the snapshot has data in the relationship structure
|
|
const relSection = snapshot.relationships["health"];
|
|
expect(relSection).toBeDefined();
|
|
|
|
// Find the player source ID — it's the entity without components
|
|
const playerId = Object.keys(snapshot.entities).find(
|
|
(id) => Object.keys(snapshot.entities[id]).length === 0,
|
|
)!;
|
|
const edgeValue = relSection[playerId];
|
|
expect(typeof edgeValue).toBe("object");
|
|
expect((edgeValue as any).target).toBeDefined();
|
|
expect((edgeValue as any).data).toEqual({ hp: 50, maxHp: 100 });
|
|
|
|
// Check JSON round-trip preserves data
|
|
const parsed = JSON.parse(JSON.stringify(snapshot));
|
|
const world2 = World.fromJSON(parsed, [], [Health]);
|
|
|
|
const snap2 = world2.toJSON();
|
|
const playerId2 = Object.keys(snap2.entities).find(
|
|
(id) => Object.keys(snap2.entities[id]).length === 0,
|
|
)!;
|
|
expect(snap2.relationships["health"]).toBeDefined();
|
|
const edgeValue2 = snap2.relationships["health"][playerId2];
|
|
expect((edgeValue2 as any).data).toEqual({ hp: 50, maxHp: 100 });
|
|
});
|
|
|
|
it("pure edge-defined relationships still work alongside data relationships", () => {
|
|
const ChildOf2 = defineRelationship("childOf2");
|
|
const Score = defineRelationship("score", { points: 0 });
|
|
|
|
const parent = world.spawn();
|
|
const child = world.spawn();
|
|
const game = world.spawn();
|
|
|
|
world.relate(child, ChildOf2, parent);
|
|
world.relate(child, Score, game, { points: 42 });
|
|
|
|
// Pure edge still works
|
|
expect(world.getRelated(child, ChildOf2)).toBe(parent);
|
|
|
|
// Data edge still works
|
|
expect(world.getRelData(child, Score)).toEqual({ points: 42 });
|
|
});
|
|
});
|