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.
This commit is contained in:
2026-05-31 16:10:19 +08:00
parent d0bb119911
commit 1c55485f9f
8 changed files with 311 additions and 19 deletions
+6 -6
View File
@@ -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;
+161
View File
@@ -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({});
});
});
+5 -5
View File
@@ -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;