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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user