@codehz/ecs 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.en.md +26 -3
  2. package/README.md +28 -3
  3. package/dist/builder.d.mts +296 -46
  4. package/dist/index.d.mts +2 -2
  5. package/dist/index.mjs +2 -2
  6. package/dist/testing.d.mts +1 -1
  7. package/dist/testing.mjs +1 -1
  8. package/dist/world.mjs +452 -179
  9. package/dist/world.mjs.map +1 -1
  10. package/examples/debug-observability.ts +92 -0
  11. package/examples/inventory-system-relations.ts +1 -1
  12. package/examples/parent-child-hierarchy.ts +18 -38
  13. package/package.json +1 -1
  14. package/skills/ecs/SKILL.md +4 -4
  15. package/src/__tests__/component/singleton.test.ts +40 -1
  16. package/src/__tests__/core/archetype.test.ts +155 -13
  17. package/src/__tests__/core/bitset.test.ts +12 -0
  18. package/src/__tests__/entity/entity.test.ts +33 -0
  19. package/src/__tests__/entity/id-system.test.ts +40 -0
  20. package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
  21. package/src/__tests__/perf/serialization.perf.test.ts +242 -0
  22. package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
  23. package/src/__tests__/query/caching.test.ts +62 -0
  24. package/src/__tests__/query/filter.test.ts +16 -22
  25. package/src/__tests__/query/perf.test.ts +3 -5
  26. package/src/__tests__/relations/hierarchy.test.ts +208 -0
  27. package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
  28. package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
  29. package/src/__tests__/serialization/bounds.test.ts +134 -1
  30. package/src/__tests__/world/commands.test.ts +337 -0
  31. package/src/__tests__/world/debug-stats.test.ts +206 -0
  32. package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
  33. package/src/__tests__/world/serialize.test.ts +17 -0
  34. package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
  35. package/src/archetype/archetype.ts +96 -46
  36. package/src/archetype/helpers.ts +7 -29
  37. package/src/archetype/store.ts +35 -20
  38. package/src/commands/buffer.ts +5 -2
  39. package/src/commands/changeset.ts +0 -31
  40. package/src/component/registry.ts +64 -63
  41. package/src/entity/index.ts +6 -3
  42. package/src/index.ts +13 -0
  43. package/src/query/filter.ts +4 -10
  44. package/src/query/query.ts +12 -12
  45. package/src/storage/serialization.ts +29 -2
  46. package/src/types/index.ts +71 -0
  47. package/src/world/commands.ts +44 -56
  48. package/src/world/hooks.ts +8 -0
  49. package/src/world/serialization.ts +32 -18
  50. package/src/world/world.ts +387 -20
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { component, type EntityId } from "../../entity";
2
+ import { component, relation, type EntityId } from "../../entity";
3
+ import { decodeSerializedId, encodeEntityId, encodeEntityIdCached } from "../../storage/serialization";
3
4
  import { World } from "../../world/world";
4
5
 
5
6
  describe("Serialization edge cases", () => {
@@ -234,4 +235,136 @@ describe("Serialization edge cases", () => {
234
235
  expect(newWorld.has(e3, Position)).toBe(false);
235
236
  expect(newWorld.get(e3, Velocity)).toEqual({ vx: 15 });
236
237
  });
238
+
239
+ it("should serialize and deserialize singleton components (covers componentEntities paths)", () => {
240
+ const world = new World();
241
+ const Config = component<{ debug: boolean }>();
242
+ // Singleton shorthand populates internal component entity
243
+ world.set(Config, { debug: true });
244
+ world.sync();
245
+
246
+ const snapshot = world.serialize();
247
+ const restored = new World(snapshot);
248
+
249
+ expect(restored.has(Config)).toBe(true);
250
+ expect(restored.get(Config)).toEqual({ debug: true });
251
+ });
252
+
253
+ it("should round-trip anonymous (no-name) components in entity-relations and component-relations", () => {
254
+ const world = new World();
255
+ const A = component<{ v: number }>(); // anonymous -> triggers numeric fallback + warn paths
256
+ const B = component<string>(); // also anonymous for target in comp-rel
257
+ const e1 = world.new();
258
+ const e2 = world.new();
259
+ const relE = relation(A, e1);
260
+ const relC = relation(A, B);
261
+ world.set(e2, relE, { v: 42 });
262
+ world.set(e2, relC, { v: 99 });
263
+ world.sync();
264
+
265
+ const snap = world.serialize();
266
+ const r = new World(snap);
267
+
268
+ expect(r.has(e2, relE)).toBe(true);
269
+ expect(r.get(e2, relE)).toEqual({ v: 42 });
270
+ expect(r.has(e2, relC)).toBe(true);
271
+ expect(r.get(e2, relC)).toEqual({ v: 99 });
272
+ });
273
+
274
+ describe("Serialization ID codec (low-level, covers all decode/encode branches)", () => {
275
+ it("should exercise cache hit/miss and no-cache path in encodeEntityIdCached", () => {
276
+ const C = component<number>();
277
+ const cache = new Map();
278
+ const c1 = encodeEntityIdCached(C, cache);
279
+ const c2 = encodeEntityIdCached(C, cache);
280
+ expect(c2).toBe(c1); // hit
281
+ const noCache = encodeEntityIdCached(C); // else branch (no cache provided)
282
+ expect(noCache).toBeDefined();
283
+ // also wrapper
284
+ expect(encodeEntityId(C)).toBeDefined();
285
+ });
286
+
287
+ it("should round-trip all relation kinds including wildcard via encode/decode", () => {
288
+ const C = component<boolean>();
289
+ const E = 9999 as EntityId<any>;
290
+ const relE = relation(C, E);
291
+ const C2 = component<string>();
292
+ const relC = relation(C, C2);
293
+ const wild = relation(C, "*");
294
+
295
+ expect(decodeSerializedId(encodeEntityId(relE))).toBe(relE);
296
+ expect(decodeSerializedId(encodeEntityId(relC))).toBe(relC);
297
+ expect(decodeSerializedId(encodeEntityId(wild))).toBe(wild);
298
+ });
299
+
300
+ it("should hit numeric string fallbacks and all error throws in decode", () => {
301
+ const C1 = component<number>();
302
+ const C2 = component<string>();
303
+ const id1 = C1 as unknown as number;
304
+ const id2 = C2 as unknown as number;
305
+
306
+ // numeric fallback paths (when name lookup fails; use real allocated IDs as strings)
307
+ expect(decodeSerializedId(String(id1) as any)).toBe(id1 as any);
308
+ expect(decodeSerializedId({ component: String(id2), target: 99999 } as any)).toBeDefined(); // component-relation numeric fallback; creates valid relation(C2, fakeTarget)
309
+
310
+ // error paths (unknown names hit throws before relation() ctor)
311
+ expect(() => decodeSerializedId("TotallyUnknownName!!" as any)).toThrow(/Unknown component name in snapshot/);
312
+ expect(() => decodeSerializedId({ component: "BadName", target: 1 } as any)).toThrow(
313
+ /Unknown component name in snapshot/,
314
+ );
315
+ expect(() => decodeSerializedId({ component: "123", target: "BadTargetName" } as any)).toThrow(
316
+ /Unknown target component name in snapshot/,
317
+ );
318
+ expect(() => decodeSerializedId({ foo: "bar" } as any)).toThrow(/Invalid ID in snapshot/);
319
+ });
320
+ });
321
+
322
+ describe("Entity-ID-as-componentType references (ad-hoc entity refs)", () => {
323
+ it("should round-trip worlds using raw EntityIds as component types (covers 'entity' branch in deserialize reference tracking)", () => {
324
+ const world = new World();
325
+
326
+ const eTarget = world.new();
327
+ const eHolder = world.new();
328
+
329
+ // Use a raw entity ID (>= ENTITY_ID_START) directly as a component "type".
330
+ // This models an untyped/ad-hoc reference to another entity.
331
+ // The value is omitted (void presence-only component).
332
+ world.set(eHolder, eTarget as unknown as EntityId<any>);
333
+ world.sync();
334
+
335
+ const snapshot = world.serialize();
336
+ const restored = new World(snapshot);
337
+
338
+ expect(restored.exists(eTarget)).toBe(true);
339
+ expect(restored.exists(eHolder)).toBe(true);
340
+
341
+ // The ad-hoc component must survive the roundtrip.
342
+ expect(restored.has(eHolder, eTarget as unknown as EntityId<any>)).toBe(true);
343
+ });
344
+ });
345
+
346
+ describe("ComponentEntities deserialization guards", () => {
347
+ it("should ignore componentEntities snapshot entries whose id is not a real component entity (covers continue guard)", () => {
348
+ const world = new World();
349
+ const Config = component<{ debug: boolean }>();
350
+ world.set(Config, { debug: true });
351
+ world.sync();
352
+
353
+ const snap: any = world.serialize();
354
+
355
+ // Inject a bogus entry: a high ordinary entity id (never a component entity)
356
+ const bogus = 123456 as EntityId;
357
+ snap.componentEntities = snap.componentEntities || [];
358
+ snap.componentEntities.push({
359
+ id: bogus,
360
+ components: [{ type: 99, value: "should-be-ignored" }],
361
+ });
362
+
363
+ const restored = new World(snap);
364
+
365
+ // Real singleton still works; bogus entry was skipped without error
366
+ expect(restored.has(Config)).toBe(true);
367
+ expect(restored.get(Config)).toEqual({ debug: true });
368
+ });
369
+ });
237
370
  });
@@ -0,0 +1,337 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Archetype } from "../../archetype/archetype";
3
+ import { SparseStoreImpl } from "../../archetype/store";
4
+ import { ComponentChangeset } from "../../commands/changeset";
5
+ import { component, createEntityId, relation, type ComponentId, type EntityId } from "../../entity";
6
+ import {
7
+ applyChangeset,
8
+ areComponentTypesEqual,
9
+ filterRegularComponentTypes,
10
+ maybeRemoveWildcardMarker,
11
+ type CommandProcessorContext,
12
+ } from "../../world/commands";
13
+ import { World } from "../../world/world";
14
+
15
+ function createSparseStore() {
16
+ return new SparseStoreImpl();
17
+ }
18
+
19
+ function makeArchetype(componentTypes: EntityId<any>[]): Archetype {
20
+ return new Archetype(componentTypes, createSparseStore());
21
+ }
22
+
23
+ function makeCtx(): CommandProcessorContext {
24
+ const store = createSparseStore();
25
+ return {
26
+ sparseStore: store,
27
+ ensureArchetype: (types) => new Archetype(Array.from(types), store),
28
+ };
29
+ }
30
+
31
+ describe("world/commands internal coverage", () => {
32
+ const A = component<number>();
33
+ const B = component<string>();
34
+ const Tag = component({ dontFragment: true });
35
+ const Data = component<{ v: number }>({ dontFragment: true });
36
+ const ExclusiveTag = component({ dontFragment: true, exclusive: true });
37
+
38
+ describe("areComponentTypesEqual", () => {
39
+ it("returns true for equal arrays (same order)", () => {
40
+ expect(areComponentTypesEqual([A, B], [A, B])).toBe(true);
41
+ });
42
+
43
+ it("returns true for equal arrays after normalization (different order)", () => {
44
+ expect(areComponentTypesEqual([B, A], [A, B])).toBe(true);
45
+ });
46
+
47
+ it("returns false for different lengths", () => {
48
+ expect(areComponentTypesEqual([A], [A, B])).toBe(false);
49
+ });
50
+
51
+ it("returns false for different content after normalization", () => {
52
+ expect(areComponentTypesEqual([A, B], [A, Tag])).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("filterRegularComponentTypes", () => {
57
+ it("keeps wildcard markers for dontFragment and drops specific dontFragment relations", () => {
58
+ const wild = relation(Tag, "*");
59
+ const specific = relation(Tag, createEntityId(9999) as EntityId);
60
+ const result = filterRegularComponentTypes([A, specific, wild, B]);
61
+ // specific dontFragment relation should be filtered out; wildcard marker kept
62
+ expect(result).toContain(wild);
63
+ expect(result).toContain(A);
64
+ expect(result).toContain(B);
65
+ expect(result).not.toContain(specific);
66
+ });
67
+ });
68
+
69
+ describe("removeMatchingRelations + maybeRemoveWildcardMarker (marker retention)", () => {
70
+ it("keeps wildcard marker when removing one of multiple non-exclusive dontFragment relations (archetype path)", () => {
71
+ const store = createSparseStore();
72
+ const p1 = createEntityId(2001) as EntityId;
73
+ const p2 = createEntityId(2002) as EntityId;
74
+ const r1 = relation(Tag, p1);
75
+ const r2 = relation(Tag, p2);
76
+ const wild = relation(Tag, "*");
77
+
78
+ // Archetype declares the wildcard marker (as required for dontFragment)
79
+ const arch = new Archetype([A, wild], store);
80
+ const entity = createEntityId(5001) as EntityId;
81
+
82
+ // Put the entity in the archetype with two concrete relations stored in dontFragment store
83
+ arch.addEntity(entity, new Map([[A, 1]]));
84
+ store.setValue(entity, r1, undefined);
85
+ store.setValue(entity, r2, undefined);
86
+
87
+ const changeset = new ComponentChangeset();
88
+ // Remove only one target
89
+ changeset.delete(r1);
90
+
91
+ // Call the function under test (simulates what processDeleteCommand does for non-wildcard delete)
92
+ maybeRemoveWildcardMarker(entity, arch, r1, Tag as unknown as ComponentId<any>, changeset);
93
+
94
+ // Marker must NOT have been scheduled for removal
95
+ expect(changeset.removes.has(wild)).toBe(false);
96
+ // We only mutated changeset; the store still contains the other relation entry
97
+ // (presence is observable via getAllForEntity because value may legitimately be undefined for tags)
98
+ const remaining = store.getAllForEntity(entity).some(([t]) => t === r2);
99
+ expect(remaining).toBe(true);
100
+ });
101
+
102
+ it("keeps wildcard marker when other relation exists only in dontFragmentData for the entity", () => {
103
+ const store = createSparseStore();
104
+ const p1 = createEntityId(2001) as EntityId;
105
+ const p2 = createEntityId(2002) as EntityId;
106
+ const r1 = relation(Tag, p1);
107
+ const r2 = relation(Tag, p2);
108
+ const wild = relation(Tag, "*");
109
+
110
+ const arch = new Archetype([A, wild], store);
111
+ const entity = createEntityId(5002) as EntityId;
112
+
113
+ arch.addEntity(entity, new Map([[A, 1]]));
114
+ // Only store relations in dontFragment (no regular archetype components for them)
115
+ store.setValue(entity, r1, undefined);
116
+ store.setValue(entity, r2, undefined);
117
+
118
+ const changeset = new ComponentChangeset();
119
+ changeset.delete(r1);
120
+
121
+ maybeRemoveWildcardMarker(entity, arch, r1, Tag as unknown as ComponentId<any>, changeset);
122
+
123
+ expect(changeset.removes.has(wild)).toBe(false);
124
+ });
125
+
126
+ it("keeps wildcard marker on exclusive dontFragment flip when remove+add are in same changeset (batch)", () => {
127
+ const store = createSparseStore();
128
+ const p1 = createEntityId(3001) as EntityId;
129
+ const p2 = createEntityId(3002) as EntityId;
130
+ const r1 = relation(ExclusiveTag, p1);
131
+ const r2 = relation(ExclusiveTag, p2);
132
+ const wild = relation(ExclusiveTag, "*");
133
+
134
+ const arch = new Archetype([A, wild], store);
135
+ const entity = createEntityId(5003) as EntityId;
136
+
137
+ arch.addEntity(entity, new Map([[A, 1]]));
138
+ store.setValue(entity, r1, { x: 1 }); // some value
139
+
140
+ const changeset = new ComponentChangeset();
141
+ // Simulate exclusive flip: remove old target, add new target in one batch
142
+ changeset.delete(r1);
143
+ changeset.set(r2, undefined); // ExclusiveTag is a void/tag dontFragment component
144
+
145
+ maybeRemoveWildcardMarker(entity, arch, r1, ExclusiveTag as unknown as ComponentId<any>, changeset);
146
+
147
+ // Because a replacement add is present, marker must be kept
148
+ expect(changeset.removes.has(wild)).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("pruneMissingRemovals (via applyChangeset)", () => {
153
+ it("prunes remove commands for components the entity does not actually have", () => {
154
+ const ctx = makeCtx();
155
+ const entity = createEntityId(6001) as EntityId;
156
+
157
+ // Archetype with only A
158
+ const arch = makeArchetype([A]);
159
+ arch.addEntity(entity, new Map([[A, 42]]));
160
+
161
+ const changeset = new ComponentChangeset();
162
+ // Spurious removes that do not exist on the entity
163
+ changeset.delete(B);
164
+ changeset.delete(relation(Tag, createEntityId(7777) as EntityId));
165
+
166
+ const entityToArch = new Map([[entity, arch]]);
167
+ const removed: Map<EntityId<any>, any> | null = new Map();
168
+
169
+ const resultArch = applyChangeset(ctx, entity, arch, changeset, entityToArch, removed);
170
+
171
+ // No structural change should have occurred (pruned), entity stays in same archetype
172
+ expect(resultArch).toBe(arch);
173
+ expect(entityToArch.get(entity)).toBe(arch);
174
+ // The spurious removes should have been pruned (no error, no move)
175
+ expect(changeset.removes.size).toBe(0);
176
+ });
177
+ });
178
+
179
+ describe("applyDontFragmentChanges recording paths (with hooks)", () => {
180
+ it("records removal of dontFragment relation even when stored value is undefined (tag/void case)", () => {
181
+ const store = createSparseStore();
182
+ const p = createEntityId(4001) as EntityId;
183
+ const r = relation(Tag, p); // Tag is dontFragment void-style
184
+ const wild = relation(Tag, "*");
185
+
186
+ const arch = new Archetype([A, wild], store);
187
+ const entity = createEntityId(7001) as EntityId;
188
+ arch.addEntity(entity, new Map([[A, 1]]));
189
+ store.setValue(entity, r, undefined); // explicitly undefined payload
190
+
191
+ const changeset = new ComponentChangeset();
192
+ changeset.delete(r);
193
+
194
+ // removedComponents non-null triggers the hook-recording path
195
+ const removedComponents = new Map<EntityId<any>, any>();
196
+
197
+ const ctx: CommandProcessorContext = {
198
+ sparseStore: store,
199
+ ensureArchetype: (t) => new Archetype(Array.from(t), store),
200
+ };
201
+
202
+ // No regular structural change (only dontFragment relation change)
203
+ applyChangeset(ctx, entity, arch, changeset, new Map(), removedComponents);
204
+
205
+ // The key: we should have recorded something for the removed dontFragment relation
206
+ // even though its value was undefined. This exercises the getAllForEntity fallback.
207
+ expect(removedComponents.has(r)).toBe(true);
208
+ });
209
+
210
+ it("records and applies normal dontFragment add/remove with data payloads", () => {
211
+ const store = createSparseStore();
212
+ const p = createEntityId(4002) as EntityId;
213
+ const r = relation(Data, p);
214
+ const wild = relation(Data, "*");
215
+
216
+ const arch = new Archetype([A, wild], store);
217
+ const entity = createEntityId(7002) as EntityId;
218
+ arch.addEntity(entity, new Map([[A, 1]]));
219
+
220
+ const changeset = new ComponentChangeset();
221
+ changeset.set(r, { v: 99 });
222
+
223
+ const ctx: CommandProcessorContext = {
224
+ sparseStore: store,
225
+ ensureArchetype: (t) => new Archetype(Array.from(t), store),
226
+ };
227
+
228
+ applyChangeset(ctx, entity, arch, changeset, new Map(), null);
229
+
230
+ expect(store.getValue(entity, r)).toEqual({ v: 99 });
231
+ });
232
+ });
233
+
234
+ describe("applyChangeset archetype move vs in-place dontFragment update", () => {
235
+ it("moves archetype when regular component is added/removed alongside dontFragment changes", () => {
236
+ const store = createSparseStore();
237
+ const p = createEntityId(5005) as EntityId;
238
+ const r = relation(Data, p);
239
+ const wild = relation(Data, "*");
240
+
241
+ const arch = new Archetype([A, wild], store);
242
+ const entity = createEntityId(8001) as EntityId;
243
+ arch.addEntity(entity, new Map([[A, 1]]));
244
+ store.setValue(entity, r, { v: 1 });
245
+
246
+ const changeset = new ComponentChangeset();
247
+ changeset.delete(A); // regular component removal → structural change
248
+ changeset.delete(r); // dontFragment
249
+
250
+ const ctx: CommandProcessorContext = {
251
+ sparseStore: store,
252
+ ensureArchetype: (t) => new Archetype(Array.from(t), store),
253
+ };
254
+ const entityToArch = new Map([[entity, arch]]);
255
+ const removed = new Map();
256
+
257
+ const newArch = applyChangeset(ctx, entity, arch, changeset, entityToArch, removed);
258
+
259
+ expect(newArch).not.toBe(arch);
260
+ expect(entityToArch.get(entity)).toBe(newArch);
261
+ expect(removed.has(r)).toBe(true); // recorded because removedComponents was provided
262
+ });
263
+ });
264
+
265
+ // These tests go through the public World API + full command processing pipeline
266
+ // (processCommands → processDeleteCommand → maybeRemoveWildcardMarker, etc.)
267
+ // to ensure branch coverage is attributed correctly for the early-return paths.
268
+ describe("public API paths for remaining branch coverage (processDeleteCommand etc.)", () => {
269
+ it("non-exclusive dontFragment: removing one target keeps wildcard marker (via World commands)", () => {
270
+ const world = new World();
271
+ const ParentTag = component({ dontFragment: true }); // non-exclusive
272
+ const p1 = world.new();
273
+ const p2 = world.new();
274
+ const child = world.new();
275
+
276
+ const r1 = relation(ParentTag, p1);
277
+ const r2 = relation(ParentTag, p2);
278
+ const wild = relation(ParentTag, "*");
279
+
280
+ world.set(child, r1, undefined);
281
+ world.set(child, r2, undefined);
282
+ world.sync();
283
+
284
+ // At this point child should have the wildcard marker in its (regular) component types
285
+ // Remove only one concrete relation
286
+ world.remove(child, r1);
287
+ world.sync();
288
+
289
+ // Marker must still exist (we can observe via wildcard query or has on the marker)
290
+ expect(world.has(child, wild)).toBe(true);
291
+ // The other relation survives
292
+ expect(world.has(child, r2)).toBe(true);
293
+ // The removed one is gone
294
+ expect(world.has(child, r1)).toBe(false);
295
+ });
296
+
297
+ it("exclusive dontFragment replacement via set (normal path) retains marker and cleans old target", () => {
298
+ const world = new World();
299
+ const ChildOf = component({ dontFragment: true, exclusive: true });
300
+ const parentA = world.new();
301
+ const parentB = world.new();
302
+ const child = world.new();
303
+
304
+ const rA = relation(ChildOf, parentA);
305
+ const rB = relation(ChildOf, parentB);
306
+ const wild = relation(ChildOf, "*");
307
+
308
+ world.set(child, rA, undefined);
309
+ world.sync();
310
+
311
+ // Normal exclusive flip via set — handleExclusiveRelation removes the old target
312
+ // using removeMatchingRelations (direct changeset delete, marker kept by other logic)
313
+ world.set(child, rB, undefined);
314
+ world.sync();
315
+
316
+ expect(world.has(child, wild)).toBe(true);
317
+ expect(world.has(child, rB)).toBe(true);
318
+ expect(world.has(child, rA)).toBe(false);
319
+ });
320
+
321
+ it("spurious remove of non-present component is a no-op (pruneMissingRemovals via World)", () => {
322
+ const world = new World();
323
+ const Pos = component<{ x: number }>();
324
+ const e = world.new();
325
+ world.set(e, Pos, { x: 10 });
326
+ world.sync();
327
+
328
+ // Remove something that was never present
329
+ world.remove(e, B);
330
+ // Should not throw and not affect existing data
331
+ world.sync();
332
+
333
+ expect(world.has(e, Pos)).toBe(true);
334
+ expect(world.get(e, Pos)).toEqual({ x: 10 });
335
+ });
336
+ });
337
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { component, relation, type EntityId } from "../../entity";
3
+ import type { SyncDebugStats } from "../../types";
4
+ import { World } from "../../world/world";
5
+
6
+ describe("World - Debug Stats Collector", () => {
7
+ type Position = { x: number; y: number };
8
+ type Velocity = { x: number; y: number };
9
+
10
+ const Position = component<Position>();
11
+ const Velocity = component<Velocity>();
12
+
13
+ it("should deliver stats after sync when collector is active", () => {
14
+ const world = new World();
15
+ const entity = world.new();
16
+
17
+ const received: SyncDebugStats[] = [];
18
+
19
+ using _collector = world.createDebugStatsCollector((stats) => {
20
+ received.push(stats);
21
+ });
22
+
23
+ world.set(entity, Position, { x: 1, y: 2 });
24
+ world.sync();
25
+
26
+ expect(received.length).toBe(1);
27
+ const stats = received[0]!;
28
+ expect(stats.commandIterations).toBeGreaterThanOrEqual(0);
29
+ expect(stats.entities.total).toBeGreaterThanOrEqual(1);
30
+ expect(stats.archetypes.total).toBeGreaterThanOrEqual(1);
31
+ expect(typeof stats.timestamps.syncStart).toBe("number");
32
+ expect(stats.timestamps.syncEnd).toBeGreaterThanOrEqual(stats.timestamps.syncStart);
33
+ });
34
+
35
+ it("should report archetype creation and removal activity", () => {
36
+ const world = new World();
37
+ const e1 = world.new();
38
+ const e2 = world.new();
39
+
40
+ const received: SyncDebugStats[] = [];
41
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
42
+
43
+ // First sync creates the empty archetype + entities
44
+ world.sync();
45
+ const afterFirst = received[received.length - 1]!;
46
+
47
+ // Cause archetype migrations by adding different components
48
+ world.set(e1, Position, { x: 0, y: 0 });
49
+ world.set(e2, Velocity, { x: 1, y: 1 });
50
+ world.sync();
51
+
52
+ const afterMigration = received[received.length - 1]!;
53
+
54
+ expect(afterMigration.activity.archetypesCreated).toBeGreaterThanOrEqual(0);
55
+ // We created at least one new archetype in the second sync
56
+ expect(afterMigration.activity.archetypesCreated + afterFirst.activity.archetypesCreated).toBeGreaterThan(0);
57
+
58
+ // Now remove components to potentially clean up archetypes
59
+ world.remove(e1, Position);
60
+ world.remove(e2, Velocity);
61
+ world.sync();
62
+
63
+ const afterRemove = received[received.length - 1]!;
64
+ expect(afterRemove.activity.archetypesRemoved).toBeGreaterThanOrEqual(0);
65
+ });
66
+
67
+ it("should count actual entity migrations", () => {
68
+ const world = new World();
69
+ const entity = world.new();
70
+
71
+ const received: SyncDebugStats[] = [];
72
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
73
+
74
+ world.set(entity, Position, { x: 1, y: 1 });
75
+ world.sync();
76
+
77
+ // Adding a second component should cause a migration
78
+ world.set(entity, Velocity, { x: 0, y: 0 });
79
+ world.sync();
80
+
81
+ const last = received[received.length - 1]!;
82
+ expect(last.activity.migrations).toBeGreaterThanOrEqual(1);
83
+ });
84
+
85
+ it("should count hook executions", () => {
86
+ const world = new World();
87
+ const entity = world.new();
88
+
89
+ const received: SyncDebugStats[] = [];
90
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
91
+
92
+ // Register a hook
93
+ world.hook([Position], {
94
+ on_set: () => {},
95
+ });
96
+
97
+ world.set(entity, Position, { x: 10, y: 20 });
98
+ world.sync();
99
+
100
+ const stats = received[received.length - 1]!;
101
+ expect(stats.activity.hooksExecuted).toBeGreaterThanOrEqual(1);
102
+ expect(stats.hooks.total).toBeGreaterThanOrEqual(1);
103
+ });
104
+
105
+ it("should deliver identical object reference to multiple collectors", () => {
106
+ const world = new World();
107
+ const e = world.new();
108
+
109
+ const receivedA: SyncDebugStats[] = [];
110
+ const receivedB: SyncDebugStats[] = [];
111
+
112
+ using _c1 = world.createDebugStatsCollector((s) => receivedA.push(s));
113
+ using _c2 = world.createDebugStatsCollector((s) => receivedB.push(s));
114
+
115
+ world.set(e, Position, { x: 5, y: 5 });
116
+ world.sync();
117
+
118
+ expect(receivedA.length).toBe(1);
119
+ expect(receivedB.length).toBe(1);
120
+ expect(receivedA[0]).toBe(receivedB[0]); // exact same reference
121
+ });
122
+
123
+ it("should stop delivering after collector is disposed", () => {
124
+ const world = new World();
125
+ const e = world.new();
126
+
127
+ const received: SyncDebugStats[] = [];
128
+ const collector = world.createDebugStatsCollector((s) => received.push(s));
129
+
130
+ world.set(e, Position, { x: 1, y: 1 });
131
+ world.sync();
132
+ expect(received.length).toBe(1);
133
+
134
+ collector[Symbol.dispose]();
135
+
136
+ world.set(e, Position, { x: 2, y: 2 });
137
+ world.sync();
138
+ // Should not have received a second stats payload
139
+ expect(received.length).toBe(1);
140
+ });
141
+
142
+ it("should report command iterations for complex command batches", () => {
143
+ const world = new World();
144
+ const entities: EntityId[] = [];
145
+
146
+ for (let i = 0; i < 10; i++) {
147
+ entities.push(world.new());
148
+ }
149
+
150
+ const received: SyncDebugStats[] = [];
151
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
152
+
153
+ // Many sets on different entities should require at least one iteration
154
+ for (const e of entities) {
155
+ world.set(e, Position, { x: 1, y: 1 });
156
+ }
157
+ world.sync();
158
+
159
+ const stats = received[received.length - 1]!;
160
+ expect(stats.commandIterations).toBeGreaterThanOrEqual(1);
161
+ });
162
+
163
+ it("should only collect for syncs after the collector was created", () => {
164
+ const world = new World();
165
+ const e = world.new();
166
+
167
+ world.set(e, Position, { x: 1, y: 1 });
168
+ world.sync(); // This sync happens before any collector exists
169
+
170
+ const received: SyncDebugStats[] = [];
171
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
172
+
173
+ world.set(e, Position, { x: 2, y: 2 });
174
+ world.sync(); // This one should be observed
175
+
176
+ expect(received.length).toBe(1);
177
+ });
178
+
179
+ it("should observe activity and index changes from relations", () => {
180
+ const world = new World();
181
+
182
+ const ChildOf = component<void>({ exclusive: true });
183
+
184
+ const parent1 = world.new();
185
+ const parent2 = world.new();
186
+ const child = world.new();
187
+
188
+ const received: SyncDebugStats[] = [];
189
+ using _collector = world.createDebugStatsCollector((s) => received.push(s));
190
+
191
+ // Establish a relation (should populate entity reference indices)
192
+ world.set(child, relation(ChildOf, parent1));
193
+ world.sync();
194
+
195
+ const afterRelation = received[received.length - 1]!;
196
+ expect(afterRelation.indices.entityReferences).toBeGreaterThanOrEqual(1);
197
+
198
+ // Switching an exclusive relation should cause a migration/remove + add
199
+ world.set(child, relation(ChildOf, parent2));
200
+ world.sync();
201
+
202
+ const afterSwitch = received[received.length - 1]!;
203
+ // Exclusive relation flip typically causes at least one structural change
204
+ expect(afterSwitch.activity.migrations).toBeGreaterThanOrEqual(0);
205
+ });
206
+ });