feat: add support for data-carrying relationships

Introduce the ability to attach optional data payloads to
relationships. This includes:

- Updating `defineRelationship` to accept default values.
- Adding `getRelData` and `setRelData` to the `World` class.
- Allowing `relate` to accept an optional data override.
- Updating serialization to include relationship data in snapshots.
- Implementing lazy storage for relationship data using `SparseSet`.
This commit is contained in:
2026-06-02 17:56:12 +08:00
parent a97488e8d6
commit cd6350e0b1
4 changed files with 288 additions and 15 deletions
+153
View File
@@ -379,3 +379,156 @@ describe("Dead entity safety", () => {
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 });
});
});