diff --git a/src/relationship.ts b/src/relationship.ts index 27d454a..309309e 100644 --- a/src/relationship.ts +++ b/src/relationship.ts @@ -1,24 +1,55 @@ // ── Relationship ───────────────────────────────────── /** * A relationship definition — like a component, but represents a directed - * link between two entities. + * link between two entities. Every relationship carries an optional data + * payload (defaults to `{}` for bare edges). * * @example * ```ts * const ChildOf = defineRelationship('childOf'); - * world.relate(child, ChildOf, parent); + * const Health = defineRelationship('health', { hp: 100 }); * ``` */ -export interface RelationshipDef { +export interface RelationshipDef = {}> { /** Unique symbol used as the storage key. */ readonly _key: symbol; /** Human-readable name, used for serialization. */ readonly name: string; + /** Default values used when no data override is provided. */ + readonly defaults: T; + /** Phantom type for inference. */ + readonly type: T; } /** * Define a named relationship between entities. + * + * When `defaults` is omitted the relationship is a bare edge (no data). + * When `defaults` is provided the relationship carries data accessible + * via `world.getRelData()` / `world.setRelData()`. + * + * @example + * ```ts + * // Bare edge + * const ChildOf = defineRelationship('childOf'); + * + * // With data + * const Health = defineRelationship('health', { hp: 100 }); + * ``` */ -export function defineRelationship(name: string): RelationshipDef { - return { _key: Symbol(), name }; +export function defineRelationship(name: string): RelationshipDef<{}>; +export function defineRelationship>( + name: string, + defaults: T, +): RelationshipDef; +export function defineRelationship>( + name: string, + defaults?: T, +): RelationshipDef<{}> | RelationshipDef { + return { + _key: Symbol(), + name, + defaults: (defaults ?? {}) as any, + type: undefined as unknown as any, + }; } diff --git a/src/serialization.ts b/src/serialization.ts index 6b28567..07e86ec 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -5,6 +5,9 @@ export interface WorldSnapshot { /** Entity stable ID → component map (component name → data). */ entities: Record>; - /** Relationship name → (source ID → target ID). */ - relationships: Record>; + /** Relationship name → (source ID → target ID or edge object). */ + relationships: Record< + string, + Record + >; } diff --git a/src/world.ts b/src/world.ts index e4756b1..82a18c6 100644 --- a/src/world.ts +++ b/src/world.ts @@ -35,6 +35,8 @@ export class World { private _relReverse = new Map>>(); /** Key → RelationshipDef for event emission. */ private _relKeyToDef = new Map(); + /** Relationship data: relationship._key → SparseSet (keyed by source index). */ + private _relData = new Map>(); // ── Change tracking ─────────────────────────────── private _dirty = new Map>(); @@ -93,6 +95,8 @@ export class World { if (fwd.has(idx)) { const target = fwd.get(idx); const rel = this._relKeyToDef.get(key)!; + // Clean up relationship data if applicable + this._relData.get(key)?.remove(idx); this._relRemoveEdge(entity, target, rel); } @@ -322,8 +326,17 @@ export class World { * Create a directed relationship from `source` to `target`. * Each source can only have one target per relationship type. * If a relationship already exists, it is replaced. + * + * An optional `data` payload can be provided to store data along + * with the edge (accessible via `getRelData` / `setRelData`). + * Data is stored lazily — bare edges without data use no storage. */ - relate(source: Entity, rel: RelationshipDef, target: Entity): void { + relate = {}>( + source: Entity, + rel: RelationshipDef, + target: Entity, + data?: Partial, + ): void { const si = entityIndex(source); const ti = entityIndex(target); this._assertAlive(si, source); @@ -351,6 +364,16 @@ export class World { this._relCounts[si]++; this._relCounts[ti]++; + // Lazy data storage — only allocate when data is provided + if (data !== undefined) { + let dataStore = this._relData.get(rel._key); + if (!dataStore) { + dataStore = new SparseSet(); + this._relData.set(rel._key, dataStore); + } + dataStore.set(si, { ...rel.defaults, ...data }); + } + this._emit({ type: "relationshipAdded", source, @@ -370,9 +393,48 @@ export class World { const target = this.getRelated(source, rel); if (target === undefined) return; + this._relData.get(rel._key)?.remove(si); this._relRemoveEdge(source, target, rel); } + /** + * Get the data stored alongside a relationship. + * Returns the relationship's defaults if no data was explicitly set. + */ + getRelData = {}>( + source: Entity, + rel: RelationshipDef, + ): T { + const si = entityIndex(source); + this._assertAlive(si, source); + + const store = this._relData.get(rel._key); + if (!store || !store.has(si)) { + return { ...rel.defaults }; + } + return store.get(si); + } + + /** + * Set the data for a relationship edge. + * Creates storage lazily if this is the first data set on this relationship type. + */ + setRelData = {}>( + source: Entity, + rel: RelationshipDef, + data: T, + ): void { + const si = entityIndex(source); + this._assertAlive(si, source); + + let store = this._relData.get(rel._key); + if (!store) { + store = new SparseSet(); + this._relData.set(rel._key, store); + } + store.set(si, data); + } + /** * Get the target entity for a relationship, or undefined. */ @@ -505,14 +567,26 @@ export class World { } // Relationships - const relationships: Record> = {}; + const relationships: Record< + string, + Record + > = {}; for (const [key, fwd] of this._relForward) { const rel = this._relKeyToDef.get(key)!; - const edges: Record = {}; + const edges: Record = + {}; + const dataStore = this._relData.get(key); for (const [si, target] of fwd.entries()) { const ti = entityIndex(target); if (ids[si] !== undefined && ids[ti] !== undefined) { - edges[ids[si]] = ids[ti]; + if (dataStore?.has(si)) { + edges[ids[si]] = { + target: ids[ti], + data: dataStore.get(si), + }; + } else { + edges[ids[si]] = ids[ti]; + } } } if (Object.keys(edges).length > 0) { @@ -572,11 +646,23 @@ export class World { `Pass it in the relationships array.`, ); } - for (const [srcId, tgtId] of Object.entries(edges)) { + for (const [srcId, value] of Object.entries(edges)) { const source = idToEntity.get(srcId); - const target = idToEntity.get(tgtId); - if (source && target) { - world.relate(source, rel, target); + if (!source) continue; + + if (typeof value === "string") { + // Pure edge (no data) + const target = idToEntity.get(value); + if (target) { + world.relate(source, rel, target); + } + } else if (typeof value === "object" && value !== null) { + // Edge with data + const edge = value as { target: string; data?: unknown }; + const target = idToEntity.get(edge.target); + if (target) { + world.relate(source, rel, target, edge.data as any); + } } } } diff --git a/test/relationships.test.ts b/test/relationships.test.ts index 33340a4..2954727 100644 --- a/test/relationships.test.ts +++ b/test/relationships.test.ts @@ -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 }); + }); +});