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:
parent
d0bb119911
commit
1c55485f9f
|
|
@ -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<T extends Record<string, any>> {
|
||||
/** 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<T extends Record<string, any>> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<T extends Record<string, any>>(
|
||||
defaults: T
|
||||
name: string,
|
||||
defaults: T,
|
||||
): ComponentDef<T> {
|
||||
return {
|
||||
_key: Symbol(),
|
||||
name,
|
||||
defaults: { ...defaults },
|
||||
type: undefined as unknown as T, // phantom; never read at runtime
|
||||
type: undefined as unknown as T,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ export type {
|
|||
QueryUpdate,
|
||||
RelationshipUpdate,
|
||||
} from "./observable/events";
|
||||
export type { WorldSnapshot } from "./serialization";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, unknown>>;
|
||||
/** Relationship name → (source ID → target ID). */
|
||||
relationships: Record<string, Record<string, string>>;
|
||||
}
|
||||
114
src/world.ts
114
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<string, Record<string, unknown>> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
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<string, Record<string, string>> = {};
|
||||
for (const [key, fwd] of this._relForward) {
|
||||
const rel = this._relKeyToDef.get(key)!;
|
||||
const edges: Record<string, string> = {};
|
||||
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<any>[],
|
||||
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<string, Entity>();
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue