From 1c55485f9fe33fb88ab7f4bf9ada4520ea2c0a7d Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 31 May 2026 16:10:19 +0800 Subject: [PATCH] feat: add world serialization support Introduce `toJSON` and `fromJSON` methods to the `World` class to allow saving and restoring world states. This requires components and relationships to have human-readable names for stable serialization. --- src/component.ts | 14 ++-- src/index.ts | 1 + src/relationship.ts | 8 +- src/serialization.ts | 10 +++ src/world.ts | 114 ++++++++++++++++++++++++++ test/relationships.test.ts | 12 +-- test/serialization.test.ts | 161 +++++++++++++++++++++++++++++++++++++ test/world.test.ts | 10 +-- 8 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 src/serialization.ts create mode 100644 test/serialization.test.ts diff --git a/src/component.ts b/src/component.ts index ca14a7f..359dcf3 100644 --- a/src/component.ts +++ b/src/component.ts @@ -5,13 +5,15 @@ * * @example * ```ts - * const Position = defineComponent({ x: 0, y: 0 }); + * const Position = defineComponent('position', { x: 0, y: 0 }); * type Position = typeof Position.type; * ``` */ export interface ComponentDef> { /** Unique symbol used as the storage key. */ readonly _key: symbol; + /** Human-readable name, used for serialization. */ + readonly name: string; /** Default values applied when a component is first added. */ readonly defaults: T; /** Phantom type for inference. */ @@ -19,15 +21,17 @@ export interface ComponentDef> { } /** - * Define a component type. The argument provides both default values and the - * TypeScript shape. + * Define a component type. The name is used for serialization. + * The defaults object provides both the TypeScript shape and initial values. */ export function defineComponent>( - defaults: T + name: string, + defaults: T, ): ComponentDef { return { _key: Symbol(), + name, defaults: { ...defaults }, - type: undefined as unknown as T, // phantom; never read at runtime + type: undefined as unknown as T, }; } diff --git a/src/index.ts b/src/index.ts index 070395b..0f1ff04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,3 +14,4 @@ export type { QueryUpdate, RelationshipUpdate, } from "./observable/events"; +export type { WorldSnapshot } from "./serialization"; diff --git a/src/relationship.ts b/src/relationship.ts index b3b240f..27d454a 100644 --- a/src/relationship.ts +++ b/src/relationship.ts @@ -5,18 +5,20 @@ * * @example * ```ts - * const ChildOf = defineRelationship(); + * const ChildOf = defineRelationship('childOf'); * world.relate(child, ChildOf, parent); * ``` */ export interface RelationshipDef { /** Unique symbol used as the storage key. */ readonly _key: symbol; + /** Human-readable name, used for serialization. */ + readonly name: string; } /** * Define a named relationship between entities. */ -export function defineRelationship(): RelationshipDef { - return { _key: Symbol() }; +export function defineRelationship(name: string): RelationshipDef { + return { _key: Symbol(), name }; } diff --git a/src/serialization.ts b/src/serialization.ts new file mode 100644 index 0000000..6b28567 --- /dev/null +++ b/src/serialization.ts @@ -0,0 +1,10 @@ +/** + * Plain JSON-compatible representation of a World. + * Returned by `world.toJSON()`, consumed by `World.fromJSON()`. + */ +export interface WorldSnapshot { + /** Entity stable ID → component map (component name → data). */ + entities: Record>; + /** Relationship name → (source ID → target ID). */ + relationships: Record>; +} diff --git a/src/world.ts b/src/world.ts index ad75882..03463d5 100644 --- a/src/world.ts +++ b/src/world.ts @@ -7,6 +7,7 @@ import { ObservableLayer } from "./observable/observe"; import type { QueryUpdate, RelationshipUpdate } from "./observable/events"; import type { RelationshipDef } from "./relationship"; import { Observable } from "rxjs"; +import type { WorldSnapshot } from "./serialization"; // ── World ───────────────────────────────────────────── /** @@ -364,6 +365,119 @@ export class World { return count; } + // ── Serialization ──────────────────────────────── + + /** + * Serialize the entire world to a plain JSON-compatible object. + * + * Each entity gets a stable string ID ("e0", "e1", …). + * Components are keyed by their `name`. Relationships are keyed + * by their `name` with entity references using the same stable IDs. + */ + toJSON(): WorldSnapshot { + // Build entity index → string id mapping + const ids: string[] = []; + let nextId = 0; + + const entities: Record> = {}; + + for (let i = 0; i < this._generations.length; i++) { + if (this._generations[i] === 0 || this._free.includes(i)) continue; + + const strId = `e${nextId++}`; + ids[i] = strId; + + const comps: Record = {}; + for (const [key, store] of this._components) { + if (store.has(i)) { + const def = this._keyToDef.get(key)!; + comps[def.name] = store.get(i); + } + } + if (Object.keys(comps).length > 0) { + entities[strId] = comps; + } else { + // Still record bare entities + entities[strId] = {}; + } + } + + // Relationships + const relationships: Record> = {}; + for (const [key, fwd] of this._relForward) { + const rel = this._relKeyToDef.get(key)!; + const edges: Record = {}; + for (const [si, target] of fwd.entries()) { + const ti = entityIndex(target); + if (ids[si] !== undefined && ids[ti] !== undefined) { + edges[ids[si]] = ids[ti]; + } + } + if (Object.keys(edges).length > 0) { + relationships[rel.name] = edges; + } + } + + return { entities, relationships }; + } + + /** + * Deserialize a world from a snapshot. + * + * @param data The output of `world.toJSON()`. + * @param components All ComponentDefs that may appear in the snapshot. + * @param relationships All RelationshipDefs that may appear in the snapshot. + */ + static fromJSON( + data: WorldSnapshot, + components: ComponentDef[], + relationships?: RelationshipDef[], + ): World { + const world = new World(); + + const compByName = new Map(components.map((c) => [c.name, c])); + const relByName = new Map((relationships ?? []).map((r) => [r.name, r])); + + // Map string ids → real Entity handles + const idToEntity = new Map(); + + for (const [strId, comps] of Object.entries(data.entities)) { + const entity = world.spawn(); + idToEntity.set(strId, entity); + + for (const [compName, value] of Object.entries(comps)) { + const def = compByName.get(compName); + if (!def) { + throw new Error( + `Unknown component "${compName}" in snapshot. ` + + `Pass it in the components array.`, + ); + } + world.add(entity, def, value as any); + } + } + + // Restore relationships + for (const [relName, edges] of Object.entries(data.relationships)) { + const rel = relByName.get(relName); + if (!rel) { + throw new Error( + `Unknown relationship "${relName}" in snapshot. ` + + `Pass it in the relationships array.`, + ); + } + for (const [srcId, tgtId] of Object.entries(edges)) { + const source = idToEntity.get(srcId); + const target = idToEntity.get(tgtId); + if (source && target) { + world.relate(source, rel, target); + } + } + } + + return world; + } + // ── Internals ───────────────────────────────────── private _emit(event: import("./observable/events").WorldEvent): void { diff --git a/test/relationships.test.ts b/test/relationships.test.ts index c03e0db..d2f93bb 100644 --- a/test/relationships.test.ts +++ b/test/relationships.test.ts @@ -7,9 +7,9 @@ import { } from "../src/index"; // ── Relationships ───────────────────────────────────── -const ChildOf = defineRelationship(); -const Targeting = defineRelationship(); -const Inside = defineRelationship(); +const ChildOf = defineRelationship("childOf"); +const Targeting = defineRelationship("targeting"); +const Inside = defineRelationship("inside"); // ── Helpers ─────────────────────────────────────────── function collectEvents(world: World): WorldEvent[] { @@ -18,9 +18,9 @@ function collectEvents(world: World): WorldEvent[] { return log; } -function collectRelUpdates( - obs$: { subscribe: Function }, -): RelationshipUpdate[] { +function collectRelUpdates(obs$: { + subscribe: Function; +}): RelationshipUpdate[] { const log: RelationshipUpdate[] = []; obs$.subscribe((u: RelationshipUpdate) => log.push(u)); return log; diff --git a/test/serialization.test.ts b/test/serialization.test.ts new file mode 100644 index 0000000..8a578de --- /dev/null +++ b/test/serialization.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + World, + defineComponent, + defineRelationship, + type WorldSnapshot, + query, +} from "../src/index"; + +// ── Definitions ───────────────────────────────────── +const Position = defineComponent("position", { x: 0, y: 0 }); +const Velocity = defineComponent("velocity", { vx: 0, vy: 0 }); +const Health = defineComponent("health", { current: 100, max: 100 }); +const ChildOf = defineRelationship("childOf"); +const ParentOf = defineRelationship("parentOf"); + +describe("Serialization", () => { + let world: World; + + beforeEach(() => { + world = new World(); + }); + + it("serializes components by name", () => { + const e = world.spawn(); + world.add(e, Position, { x: 10, y: 20 }); + world.add(e, Velocity, { vx: 1, vy: 0 }); + + const snap = world.toJSON(); + expect(snap.entities).toHaveProperty("e0"); + expect(snap.entities.e0.position).toEqual({ x: 10, y: 20 }); + expect(snap.entities.e0.velocity).toEqual({ vx: 1, vy: 0 }); + }); + + it("round-trips components through JSON", () => { + const e = world.spawn(); + world.add(e, Position, { x: 42, y: 99 }); + world.add(e, Velocity, { vx: 2, vy: -1 }); + + const json = JSON.stringify(world.toJSON()); + const data = JSON.parse(json) as WorldSnapshot; + const loaded = World.fromJSON(data, [Position, Velocity]); + + // Find entity via query + const loadedEnts = [...loaded.query(query(Position, Velocity))]; + expect(loadedEnts).toHaveLength(1); + + const loadedE = loadedEnts[0]; + expect(loaded.get(loadedE, Position)).toEqual({ x: 42, y: 99 }); + expect(loaded.get(loadedE, Velocity)).toEqual({ vx: 2, vy: -1 }); + }); + + it("handles multiple entities", () => { + world.spawn(); + world.spawn(); + + const snap = world.toJSON(); + // Bare entities still appear + const entries = Object.entries(snap.entities); + expect(entries).toHaveLength(2); + }); + + it("serializes relationships", () => { + const parent = world.spawn(); + const child = world.spawn(); + world.relate(child, ChildOf, parent); + + const snap = world.toJSON(); + expect(snap.relationships.childOf).toHaveProperty("e1"); + expect(snap.relationships.childOf.e1).toBe("e0"); + }); + + it("round-trips relationships", () => { + const parent = world.spawn(); + const child = world.spawn(); + world.relate(child, ChildOf, parent); + + const json = JSON.stringify(world.toJSON()); + const data = JSON.parse(json) as WorldSnapshot; + const loaded = World.fromJSON(data, [Position], [ChildOf, ParentOf]); + + // Verify via the re-serialized snapshot + const reSnap = loaded.toJSON(); + expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1); + }); + + it("throws on unknown component in snapshot", () => { + const snap: WorldSnapshot = { + entities: { e0: { unknownComp: { x: 1 } } }, + relationships: {}, + }; + expect(() => World.fromJSON(snap, [Position])).toThrow( + 'Unknown component "unknownComp"', + ); + }); + + it("throws on unknown relationship in snapshot", () => { + const snap: WorldSnapshot = { + entities: { e0: { position: { x: 0, y: 0 } } }, + relationships: { unknownRel: { e0: "e1" } }, + }; + expect(() => World.fromJSON(snap, [Position], [ChildOf])).toThrow( + 'Unknown relationship "unknownRel"', + ); + }); + + it("preserves entities with no components", () => { + world.spawn(); + const snap = world.toJSON(); + expect(snap.entities).toHaveProperty("e0"); + expect(snap.entities.e0).toEqual({}); + }); + + it("preserves bare entities on round-trip", () => { + world.spawn(); + + const json = JSON.stringify(world.toJSON()); + const loaded = World.fromJSON(JSON.parse(json), [Position]); + + expect(loaded.entityCount).toBe(1); + + // Entity has no Position, so query returns empty + const withPos = [...loaded.query(query(Position))]; + expect(withPos).toHaveLength(0); + // But it exists + expect(loaded.entityCount).toBe(1); + }); + + it("entity IDs are re-sequential (no hole preservation)", () => { + const a = world.spawn(); + const b = world.spawn(); + const c = world.spawn(); + world.destroy(b); // creates a hole in the original world + + const snap = world.toJSON(); + const ids = Object.keys(snap.entities).sort(); + // Serialized as e0 and e1 (hole is collapsed) + expect(ids).toHaveLength(2); + }); + + it("round-trips JSON stringify and parse", () => { + const a = world.spawn(); + world.add(a, Position, { x: 10, y: 20 }); + world.add(a, Health, { current: 75, max: 100 }); + + const raw = JSON.stringify(world.toJSON()); + const parsed = JSON.parse(raw); + + const fresh = World.fromJSON(parsed, [Position, Health]); + const reSnap = fresh.toJSON(); + + expect(reSnap.entities.e0.position).toEqual({ x: 10, y: 20 }); + expect(reSnap.entities.e0.health).toEqual({ current: 75, max: 100 }); + }); + + it("empty world serializes to empty snapshot", () => { + const snap = world.toJSON(); + expect(snap.entities).toEqual({}); + expect(snap.relationships).toEqual({}); + }); +}); diff --git a/test/world.test.ts b/test/world.test.ts index 731f715..f3b3485 100644 --- a/test/world.test.ts +++ b/test/world.test.ts @@ -8,10 +8,10 @@ import { } from "../src/index"; // ── Components ────────────────────────────────────── -const Position = defineComponent({ x: 0, y: 0 }); -const Velocity = defineComponent({ vx: 0, vy: 0 }); -const Health = defineComponent({ current: 100, max: 100 }); -const Dead = defineComponent({ timestamp: 0 }); +const Position = defineComponent("position", { x: 0, y: 0 }); +const Velocity = defineComponent("velocity", { vx: 0, vy: 0 }); +const Health = defineComponent("health", { current: 100, max: 100 }); +const Dead = defineComponent("dead", { timestamp: 0 }); // ── Helpers ──────────────────────────────────────── function collectUpdates(obs$: { subscribe: Function }): QueryUpdate[] { @@ -349,7 +349,7 @@ describe("Change tracking", () => { // ── TypeScript inference ──────────────────────────── describe("Type safety", () => { it("infers component type from defaults", () => { - const Shield = defineComponent({ armor: 5, broken: false }); + const Shield = defineComponent("shield", { armor: 5, broken: false }); const s = Shield.defaults; // compile-time check: these should be the inferred types const _armor: number = s.armor;