529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
World,
|
|
defineComponent,
|
|
defineRelationship,
|
|
type WorldSnapshot,
|
|
query,
|
|
type QueryUpdate,
|
|
type RelationshipUpdate,
|
|
type WorldEvent,
|
|
type ComponentDef,
|
|
type RelationshipDef,
|
|
} 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 Shield = defineComponent("shield", { armor: 5, broken: false });
|
|
const Name = defineComponent("name", { value: "" });
|
|
const Team = defineComponent("team", { id: 0, color: "#fff" });
|
|
|
|
const ChildOf = defineRelationship("childOf");
|
|
const Targeting = defineRelationship("targeting");
|
|
const OwnedBy = defineRelationship("ownedBy");
|
|
|
|
// ── Serialization helpers ────────────────────────────
|
|
function roundTrip(
|
|
world: World,
|
|
components: ComponentDef<any>[] = [
|
|
Position,
|
|
Velocity,
|
|
Health,
|
|
Shield,
|
|
Name,
|
|
Team,
|
|
],
|
|
rels: RelationshipDef[] = [ChildOf, Targeting, OwnedBy],
|
|
): World {
|
|
const json = JSON.stringify(world.toJSON());
|
|
return World.fromJSON(JSON.parse(json), components, rels);
|
|
}
|
|
|
|
function sortedIds(snap: WorldSnapshot): string[] {
|
|
return Object.keys(snap.entities).sort();
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────
|
|
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 loaded = roundTrip(world);
|
|
|
|
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();
|
|
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 loaded = roundTrip(world);
|
|
|
|
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 loaded = roundTrip(world);
|
|
expect(loaded.entityCount).toBe(1);
|
|
|
|
const withPos = [...loaded.query(query(Position))];
|
|
expect(withPos).toHaveLength(0);
|
|
expect(loaded.entityCount).toBe(1);
|
|
});
|
|
|
|
it("entity IDs are re-sequential (hole collapsed)", () => {
|
|
world.spawn(); // e0
|
|
const b = world.spawn();
|
|
world.spawn(); // e2
|
|
world.destroy(b); // hole at e1
|
|
|
|
const snap = world.toJSON();
|
|
// Holes are collapsed during serialization
|
|
expect(sortedIds(snap)).toEqual(["e0", "e1"]);
|
|
});
|
|
|
|
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 loaded = roundTrip(world);
|
|
const reSnap = loaded.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({});
|
|
});
|
|
});
|
|
|
|
// ── Extended: rich mixed state ───────────────────────
|
|
describe("Serialization — complex state", () => {
|
|
let world: World;
|
|
|
|
function setupRichWorld() {
|
|
const w = new World();
|
|
|
|
// Player with many components
|
|
const player = w.spawn();
|
|
w.add(player, Position, { x: 100, y: 200 });
|
|
w.add(player, Velocity, { vx: 0, vy: 0 });
|
|
w.add(player, Health, { current: 85, max: 100 });
|
|
w.add(player, Shield, { armor: 20, broken: false });
|
|
w.add(player, Name, { value: "Hero" });
|
|
w.add(player, Team, { id: 1, color: "#ff0000" });
|
|
|
|
// Enemy
|
|
const enemy = w.spawn();
|
|
w.add(enemy, Position, { x: 500, y: 300 });
|
|
w.add(enemy, Health, { current: 50, max: 50 });
|
|
w.add(enemy, Team, { id: 2, color: "#0000ff" });
|
|
|
|
// Bullet (few components)
|
|
const bullet = w.spawn();
|
|
w.add(bullet, Position, { x: 100, y: 200 });
|
|
w.add(bullet, Velocity, { vx: 10, vy: 0 });
|
|
|
|
// Bare entity
|
|
w.spawn();
|
|
|
|
// Relationships
|
|
w.relate(bullet, OwnedBy, player);
|
|
w.relate(enemy, Targeting, player);
|
|
|
|
return { w, player, enemy, bullet };
|
|
}
|
|
|
|
it("round-trips a rich world", () => {
|
|
const { w, player, enemy, bullet } = setupRichWorld();
|
|
const loaded = roundTrip(w);
|
|
|
|
expect(loaded.entityCount).toBe(4);
|
|
|
|
// Find player by Name component
|
|
const players = [...loaded.query(query(Name))];
|
|
expect(players).toHaveLength(1);
|
|
const p = players[0];
|
|
expect(loaded.get(p, Position)).toEqual({ x: 100, y: 200 });
|
|
expect(loaded.get(p, Velocity)).toEqual({ vx: 0, vy: 0 });
|
|
expect(loaded.get(p, Health)).toEqual({ current: 85, max: 100 });
|
|
expect(loaded.get(p, Shield)).toEqual({ armor: 20, broken: false });
|
|
expect(loaded.get(p, Name)).toEqual({ value: "Hero" });
|
|
expect(loaded.get(p, Team)).toEqual({ id: 1, color: "#ff0000" });
|
|
|
|
// Find enemy
|
|
const enemies = [...loaded.query(query(Health, Team))].filter(
|
|
(e) => !loaded.has(e, Name),
|
|
);
|
|
expect(enemies).toHaveLength(1);
|
|
const en = enemies[0];
|
|
expect(loaded.get(en, Health)).toEqual({ current: 50, max: 50 });
|
|
|
|
// Bullets
|
|
const bullets = [...loaded.query(query(Position, Velocity))].filter(
|
|
(e) =>
|
|
!loaded.has(e, Health) &&
|
|
!loaded.has(e, Name) &&
|
|
!loaded.has(e, Shield),
|
|
);
|
|
expect(bullets).toHaveLength(1);
|
|
});
|
|
|
|
it("preserves relationships in rich world", () => {
|
|
const { w } = setupRichWorld();
|
|
const loaded = roundTrip(w);
|
|
|
|
const snap = loaded.toJSON();
|
|
|
|
// Verify relationship structure exists
|
|
expect(snap.relationships).toHaveProperty("ownedBy");
|
|
expect(snap.relationships).toHaveProperty("targeting");
|
|
|
|
const ownedByEdges = snap.relationships.ownedBy;
|
|
const targetingEdges = snap.relationships.targeting;
|
|
|
|
// OwnedBy: exactly one edge
|
|
expect(Object.keys(ownedByEdges)).toHaveLength(1);
|
|
// Targeting: exactly one edge
|
|
expect(Object.keys(targetingEdges)).toHaveLength(1);
|
|
});
|
|
|
|
it("relationships reference correct entities after round-trip", () => {
|
|
const { w } = setupRichWorld();
|
|
const snap = w.toJSON();
|
|
|
|
// Let's trace: bullet has Position+Velocity, is OwnedBy
|
|
// Find bullet's string id
|
|
const bulletId = Object.keys(snap.entities).find((id) => {
|
|
const comps = snap.entities[id];
|
|
return comps.position && comps.velocity && !comps.health;
|
|
})!;
|
|
const playerEdge = snap.relationships.ownedBy[bulletId];
|
|
const playerId =
|
|
typeof playerEdge === "string" ? playerEdge : playerEdge.target;
|
|
|
|
// Player should have a name
|
|
const playerComps = snap.entities[playerId];
|
|
expect(playerComps).toHaveProperty("name");
|
|
expect((playerComps.name as { value: string }).value).toBe("Hero");
|
|
});
|
|
});
|
|
|
|
// ── Extended: multiple relationship types ────────────
|
|
describe("Serialization — multiple relationships", () => {
|
|
it("round-trips multiple relationship types on same entity", () => {
|
|
const w = new World();
|
|
const a = w.spawn();
|
|
const b = w.spawn();
|
|
const c = w.spawn();
|
|
|
|
w.relate(a, ChildOf, b);
|
|
w.relate(a, Targeting, c);
|
|
|
|
const loaded = roundTrip(w);
|
|
const snap = loaded.toJSON();
|
|
|
|
expect(Object.keys(snap.relationships.childOf)).toHaveLength(1);
|
|
expect(Object.keys(snap.relationships.targeting)).toHaveLength(1);
|
|
});
|
|
|
|
it("round-trips many-to-one relationships", () => {
|
|
const w = new World();
|
|
const parent = w.spawn();
|
|
const c1 = w.spawn();
|
|
const c2 = w.spawn();
|
|
const c3 = w.spawn();
|
|
|
|
w.relate(c1, ChildOf, parent);
|
|
w.relate(c2, ChildOf, parent);
|
|
w.relate(c3, ChildOf, parent);
|
|
|
|
const loaded = roundTrip(w);
|
|
const snap = loaded.toJSON();
|
|
|
|
const edges = snap.relationships.childOf;
|
|
const children = Object.keys(edges);
|
|
expect(children).toHaveLength(3);
|
|
|
|
// All three should point to the same parent
|
|
const parentId = edges[children[0]];
|
|
expect(edges[children[1]]).toBe(parentId);
|
|
expect(edges[children[2]]).toBe(parentId);
|
|
});
|
|
|
|
it("round-trips replaced relationships correctly", () => {
|
|
const w = new World();
|
|
const a = w.spawn();
|
|
const b = w.spawn();
|
|
const c = w.spawn();
|
|
|
|
w.relate(a, ChildOf, b);
|
|
w.relate(a, ChildOf, c); // replace b → c
|
|
|
|
const loaded = roundTrip(w);
|
|
const snap = loaded.toJSON();
|
|
|
|
const edges = snap.relationships.childOf;
|
|
expect(Object.keys(edges)).toHaveLength(1);
|
|
|
|
// Child pointer resolves forward
|
|
const aId = Object.keys(snap.entities).find(
|
|
(id) =>
|
|
!snap.relationships.childOf[id] &&
|
|
!Object.values(snap.relationships.childOf).includes(id),
|
|
);
|
|
// Actually, find a via exclusion: a has no components, b and c are targets.
|
|
// Let's just check the edge count is right.
|
|
expect(Object.keys(edges)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ── Extended: nested / array data ────────────────────
|
|
describe("Serialization — nested data", () => {
|
|
const Inventory = defineComponent("inventory", {
|
|
items: [] as string[],
|
|
gold: 0,
|
|
});
|
|
|
|
const Transform = defineComponent("transform", {
|
|
position: { x: 0, y: 0 },
|
|
scale: { x: 1, y: 1 },
|
|
});
|
|
|
|
it("round-trips components with array values", () => {
|
|
const w = new World();
|
|
const e = w.spawn();
|
|
w.add(e, Inventory, { items: ["sword", "shield", "potion"], gold: 42 });
|
|
|
|
const loaded = roundTrip(w, [Inventory]);
|
|
const loadedE = [...loaded.query(query(Inventory))][0];
|
|
|
|
const inv = loaded.get(loadedE, Inventory);
|
|
expect(inv.items).toEqual(["sword", "shield", "potion"]);
|
|
expect(inv.gold).toBe(42);
|
|
});
|
|
|
|
it("round-trips components with nested object values", () => {
|
|
const w = new World();
|
|
const e = w.spawn();
|
|
w.add(e, Transform, {
|
|
position: { x: 10, y: 20 },
|
|
scale: { x: 2, y: 2 },
|
|
});
|
|
|
|
const loaded = roundTrip(w, [Transform]);
|
|
const loadedE = [...loaded.query(query(Transform))][0];
|
|
|
|
const t = loaded.get(loadedE, Transform);
|
|
expect(t.position).toEqual({ x: 10, y: 20 });
|
|
expect(t.scale).toEqual({ x: 2, y: 2 });
|
|
});
|
|
|
|
it("empty arrays survive round-trip", () => {
|
|
const w = new World();
|
|
const e = w.spawn();
|
|
w.add(e, Inventory, { items: [], gold: 0 });
|
|
|
|
const loaded = roundTrip(w, [Inventory]);
|
|
const loadedE = [...loaded.query(query(Inventory))][0];
|
|
|
|
expect(loaded.get(loadedE, Inventory).items).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Extended: observables still work after load ──────
|
|
describe("Serialization — observables after load", () => {
|
|
it("loaded world emits events on mutation", () => {
|
|
const w = new World();
|
|
w.spawn();
|
|
|
|
const loaded = roundTrip(w);
|
|
const events: WorldEvent[] = [];
|
|
loaded.events$.subscribe((e) => events.push(e));
|
|
|
|
const e = loaded.spawn();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].type).toBe("spawned");
|
|
});
|
|
|
|
it("loaded world query observables work", () => {
|
|
const w = new World();
|
|
const e = w.spawn();
|
|
w.add(e, Position, { x: 1, y: 2 });
|
|
|
|
const loaded = roundTrip(w);
|
|
const updates: QueryUpdate[] = [];
|
|
loaded.observe(query(Position)).subscribe((u) => {
|
|
if (u.added.length || u.removed.length || u.changed.length) {
|
|
updates.push(u);
|
|
}
|
|
});
|
|
|
|
// Add a new Position entity
|
|
const e2 = loaded.spawn();
|
|
loaded.add(e2, Position, { x: 3, y: 4 });
|
|
|
|
expect(updates).toHaveLength(1);
|
|
expect(updates[0].added).toHaveLength(1);
|
|
});
|
|
|
|
it("loaded world relationship observables work", () => {
|
|
const w = new World();
|
|
const a = w.spawn();
|
|
const b = w.spawn();
|
|
w.relate(a, ChildOf, b);
|
|
|
|
const loaded = roundTrip(w);
|
|
const relUpdates: RelationshipUpdate[] = [];
|
|
loaded.observeRelated(ChildOf).subscribe((u) => relUpdates.push(u));
|
|
|
|
const c = loaded.spawn();
|
|
const d = loaded.spawn();
|
|
loaded.relate(c, ChildOf, d);
|
|
|
|
expect(relUpdates).toHaveLength(1);
|
|
expect(relUpdates[0].added).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ── Extended: stress ─────────────────────────────────
|
|
describe("Serialization — stress", () => {
|
|
it("round-trips 500 entities with mixed components", () => {
|
|
const w = new World();
|
|
|
|
for (let i = 0; i < 500; i++) {
|
|
const e = w.spawn();
|
|
w.add(e, Position, { x: i, y: i * 2 });
|
|
if (i % 2 === 0) {
|
|
w.add(e, Velocity, { vx: 1, vy: 0 });
|
|
}
|
|
if (i % 3 === 0) {
|
|
w.add(e, Health, { current: i, max: 1000 });
|
|
}
|
|
if (i % 5 === 0) {
|
|
w.add(e, Name, { value: `entity_${i}` });
|
|
}
|
|
}
|
|
|
|
// Add some relationships
|
|
const all = [...w.query(query(Position))];
|
|
for (let i = 0; i < 100; i++) {
|
|
const src = all[i * 2];
|
|
const tgt = all[i * 2 + 1];
|
|
if (src && tgt) {
|
|
w.relate(src, ChildOf, tgt);
|
|
}
|
|
}
|
|
|
|
const loaded = roundTrip(w);
|
|
expect(loaded.entityCount).toBe(500);
|
|
|
|
const withPos = [...loaded.query(query(Position))];
|
|
const withVel = [...loaded.query(query(Velocity))];
|
|
const withHealth = [...loaded.query(query(Health))];
|
|
|
|
expect(withPos).toHaveLength(500);
|
|
expect(withVel).toHaveLength(250); // every 2nd
|
|
expect(withHealth).toHaveLength(167); // every 3rd ≈ floor(499/3)+1
|
|
|
|
// Verify a few random entities
|
|
const e0 = withPos[0];
|
|
expect(loaded.get(e0, Position).x).toBe(0);
|
|
});
|
|
|
|
it("repeated round-trips are idempotent", () => {
|
|
const w = new World();
|
|
const e = w.spawn();
|
|
w.add(e, Position, { x: 10, y: 20 });
|
|
w.add(e, Health, { current: 75, max: 100 });
|
|
|
|
const loaded1 = roundTrip(w);
|
|
const loaded2 = roundTrip(loaded1);
|
|
|
|
expect(loaded2.entityCount).toBe(loaded1.entityCount);
|
|
|
|
const e2 = [...loaded2.query(query(Position))][0];
|
|
expect(loaded2.get(e2, Position)).toEqual({ x: 10, y: 20 });
|
|
expect(loaded2.get(e2, Health)).toEqual({ current: 75, max: 100 });
|
|
});
|
|
});
|