@codehz/ecs 0.8.2 → 0.10.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 (57) hide show
  1. package/README.en.md +26 -3
  2. package/README.md +41 -4
  3. package/dist/builder.d.mts +348 -83
  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 +1922 -1400
  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/examples/spatial-grid.ts +1 -1
  14. package/package.json +1 -1
  15. package/skills/ecs/SKILL.md +4 -4
  16. package/src/__tests__/component/singleton.test.ts +116 -35
  17. package/src/__tests__/core/archetype.test.ts +155 -13
  18. package/src/__tests__/core/bitset.test.ts +12 -0
  19. package/src/__tests__/entity/entity.test.ts +33 -0
  20. package/src/__tests__/entity/id-system.test.ts +40 -0
  21. package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
  22. package/src/__tests__/perf/serialization.perf.test.ts +242 -0
  23. package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
  24. package/src/__tests__/query/caching.test.ts +62 -0
  25. package/src/__tests__/query/filter.test.ts +16 -22
  26. package/src/__tests__/query/perf.test.ts +3 -5
  27. package/src/__tests__/relations/hierarchy.test.ts +208 -0
  28. package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
  29. package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
  30. package/src/__tests__/serialization/bounds.test.ts +133 -1
  31. package/src/__tests__/world/commands.test.ts +337 -0
  32. package/src/__tests__/world/component-management.test.ts +6 -5
  33. package/src/__tests__/world/debug-stats.test.ts +206 -0
  34. package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
  35. package/src/__tests__/world/serialize.test.ts +17 -0
  36. package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
  37. package/src/archetype/archetype.ts +96 -46
  38. package/src/archetype/helpers.ts +7 -29
  39. package/src/archetype/store.ts +35 -20
  40. package/src/commands/buffer.ts +5 -2
  41. package/src/commands/changeset.ts +0 -31
  42. package/src/component/registry.ts +64 -63
  43. package/src/entity/index.ts +6 -3
  44. package/src/index.ts +15 -0
  45. package/src/query/filter.ts +4 -10
  46. package/src/query/query.ts +12 -12
  47. package/src/storage/serialization.ts +29 -2
  48. package/src/types/index.ts +71 -0
  49. package/src/world/archetype-manager.ts +283 -0
  50. package/src/world/command-executor.ts +258 -0
  51. package/src/world/commands.ts +44 -56
  52. package/src/world/debug-stats.ts +147 -0
  53. package/src/world/hooks.ts +8 -0
  54. package/src/world/operations.ts +88 -0
  55. package/src/world/serialization.ts +32 -18
  56. package/src/world/singleton.ts +51 -0
  57. package/src/world/world.ts +429 -457
@@ -1,6 +1,17 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { Archetype } from "../../archetype/archetype";
3
- import { DontFragmentStoreImpl } from "../../archetype/store";
3
+ import {
4
+ buildCacheKey,
5
+ buildRegularComponentValue,
6
+ buildSingleComponent,
7
+ buildWildcardRelationValue,
8
+ findWildcardRelations,
9
+ getWildcardRelationDataSource,
10
+ hasWildcardRelation,
11
+ isRelationType,
12
+ matchesRelationComponentId,
13
+ } from "../../archetype/helpers";
14
+ import { SparseStoreImpl } from "../../archetype/store";
4
15
  import { component, createEntityId, relation, type EntityId } from "../../entity";
5
16
 
6
17
  describe("Archetype", () => {
@@ -10,25 +21,25 @@ describe("Archetype", () => {
10
21
  const positionComponent = component<Position>();
11
22
  const velocityComponent = component<Velocity>();
12
23
 
13
- // Helper function to create a real DontFragmentStore for testing.
24
+ // Helper function to create a real SparseStore for testing.
14
25
  // We use the production implementation because the interface is now fully semantic.
15
- const createDontFragmentRelations = () => new DontFragmentStoreImpl();
26
+ const createSparseStore = () => new SparseStoreImpl();
16
27
 
17
28
  it("should create an archetype with component types", () => {
18
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
29
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
19
30
  expect(archetype.componentTypes).toEqual([positionComponent, velocityComponent]);
20
31
  expect(archetype.size).toBe(0);
21
32
  });
22
33
 
23
34
  it("should match component types", () => {
24
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
35
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
25
36
  expect(archetype.matches([positionComponent, velocityComponent])).toBe(true);
26
37
  expect(archetype.matches([velocityComponent, positionComponent])).toBe(true); // Order doesn't matter
27
38
  expect(archetype.matches([positionComponent])).toBe(false);
28
39
  });
29
40
 
30
41
  it("should add and remove entities", () => {
31
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
42
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
32
43
  const entity1 = createEntityId(1024);
33
44
  const entity2 = createEntityId(1025);
34
45
 
@@ -57,7 +68,7 @@ describe("Archetype", () => {
57
68
  });
58
69
 
59
70
  it("should get and set component data", () => {
60
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
71
+ const archetype = new Archetype([positionComponent], createSparseStore());
61
72
  const entity = createEntityId(1024);
62
73
  const initialPosition: Position = { x: 5, y: 5 };
63
74
 
@@ -83,7 +94,7 @@ describe("Archetype", () => {
83
94
  const entity = createEntityId(1024);
84
95
 
85
96
  // Archetype with multiple relations
86
- const archetype = new Archetype([relation1, relation2], createDontFragmentRelations());
97
+ const archetype = new Archetype([relation1, relation2], createSparseStore());
87
98
 
88
99
  // Add entity with relations to target1 and target2
89
100
  archetype.addEntity(
@@ -109,7 +120,7 @@ describe("Archetype", () => {
109
120
  });
110
121
 
111
122
  it("should iterate over entities", () => {
112
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
123
+ const archetype = new Archetype([positionComponent], createSparseStore());
113
124
  const entity1 = createEntityId(1024);
114
125
  const entity2 = createEntityId(1025);
115
126
 
@@ -125,7 +136,7 @@ describe("Archetype", () => {
125
136
  });
126
137
 
127
138
  it("should get component data arrays", () => {
128
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
139
+ const archetype = new Archetype([positionComponent], createSparseStore());
129
140
  const entity1 = createEntityId(1024);
130
141
  const entity2 = createEntityId(1025);
131
142
  const pos1: Position = { x: 1, y: 1 };
@@ -153,8 +164,8 @@ describe("Archetype", () => {
153
164
  const relation3 = relation(positionComponent, createEntityId(1029)); // For entity2
154
165
 
155
166
  // Archetype with multiple relations
156
- const archetype1 = new Archetype([relation1, relation2], createDontFragmentRelations());
157
- const archetype2 = new Archetype([relation3], createDontFragmentRelations());
167
+ const archetype1 = new Archetype([relation1, relation2], createSparseStore());
168
+ const archetype2 = new Archetype([relation3], createSparseStore());
158
169
 
159
170
  // Add entity1 with relations to target1 and target2
160
171
  archetype1.addEntity(
@@ -200,7 +211,7 @@ describe("Archetype", () => {
200
211
  const relation2 = relation(positionComponent, target2);
201
212
  const wildcardPositionRelation = relation(positionComponent, "*");
202
213
 
203
- const archetype = new Archetype([relation1, relation2], createDontFragmentRelations());
214
+ const archetype = new Archetype([relation1, relation2], createSparseStore());
204
215
 
205
216
  const entity1 = createEntityId(1024);
206
217
 
@@ -246,4 +257,135 @@ describe("Archetype", () => {
246
257
  [target1, { distance: 100 }], // Updated
247
258
  ]);
248
259
  });
260
+
261
+ it("should cover getEntity, dump, getEntitiesWithComponents, getEntityToIndexMap", () => {
262
+ const archetype = new Archetype([positionComponent], createSparseStore());
263
+ const entity = createEntityId(1024);
264
+ archetype.addEntity(entity, new Map([[positionComponent, { x: 1, y: 2 }]]));
265
+
266
+ // Cover getEntity
267
+ const data = archetype.getEntity(entity);
268
+ expect(data).toBeDefined();
269
+ expect(data!.get(positionComponent)).toEqual({ x: 1, y: 2 });
270
+
271
+ // Cover dump
272
+ const dumped = archetype.dump();
273
+ expect(dumped).toHaveLength(1);
274
+ expect(dumped[0]!.entity).toBe(entity);
275
+
276
+ // Cover getEntitiesWithComponents
277
+ const withComps = archetype.getEntitiesWithComponents([positionComponent]);
278
+ expect(withComps).toHaveLength(1);
279
+ expect(withComps[0]!.entity).toBe(entity);
280
+
281
+ // Cover getEntityToIndexMap
282
+ const map = archetype.getEntityToIndexMap();
283
+ expect(map.get(entity)).toBe(0);
284
+ });
285
+
286
+ it("should cover SparseStoreImpl extra methods (hasAnyForComponent, getAllForEntities, multi)", () => {
287
+ const store = new SparseStoreImpl();
288
+ const e1 = createEntityId(2000);
289
+ const e2 = createEntityId(2001);
290
+
291
+ // Actually create proper relations for a component
292
+ const targetC1 = createEntityId(4001);
293
+ const targetC2 = createEntityId(4002);
294
+ const r1 = relation(velocityComponent, targetC1);
295
+ const r2 = relation(velocityComponent, targetC2);
296
+ const baseComp = velocityComponent;
297
+
298
+ store.setValue(e1, r1, { v: 1 });
299
+ store.setValue(e1, r2, { v: 2 }); // promote to multi
300
+
301
+ // hasAnyForComponent
302
+ expect(store.hasAnyForComponent(baseComp)).toBe(true);
303
+ expect(store.hasAnyForComponent(positionComponent)).toBe(false);
304
+
305
+ // getAllForEntities bulk
306
+ const bulk = store.getAllForEntities([e1, e2, createEntityId(5000)]);
307
+ expect(bulk.has(e1)).toBe(true);
308
+ expect(bulk.get(e1)).toHaveLength(2);
309
+
310
+ // getAllForEntity on one without
311
+ expect(store.getAllForEntity(e2)).toEqual([]);
312
+
313
+ // deleteValue on multi
314
+ store.deleteValue(e1, r1);
315
+ expect(store.getValue(e1, r1)).toBeUndefined();
316
+ // still has the other
317
+ expect(store.getValue(e1, r2)).toEqual({ v: 2 });
318
+
319
+ // delete last demotes? after delete one left in multi, delete last
320
+ store.deleteValue(e1, r2);
321
+ expect(store.hasAnyForComponent(baseComp)).toBe(false);
322
+ });
323
+
324
+ it("should cover archetype helpers (find*, has*, build*, matchers, error paths)", () => {
325
+ const comps = new Map<EntityId, any>();
326
+ const relC = relation(positionComponent, createEntityId(5001));
327
+ const relD = relation(positionComponent, createEntityId(5002));
328
+ comps.set(relC, { d: 10 });
329
+ comps.set(relD, { d: 20 });
330
+ comps.set(positionComponent, { x: 1 }); // non-relation
331
+
332
+ // findWildcardRelations
333
+ const found = findWildcardRelations(comps, positionComponent);
334
+ expect(found).toHaveLength(2);
335
+
336
+ // hasWildcardRelation
337
+ expect(hasWildcardRelation(comps, positionComponent)).toBe(true);
338
+ expect(hasWildcardRelation(comps, velocityComponent)).toBe(false);
339
+
340
+ // matchesRelationComponentId
341
+ expect(matchesRelationComponentId(relC, positionComponent)).toBe(true);
342
+ expect(matchesRelationComponentId(positionComponent, positionComponent)).toBe(false);
343
+
344
+ // isRelationType
345
+ expect(
346
+ isRelationType({
347
+ type: "entity-relation",
348
+ componentId: positionComponent,
349
+ targetId: createEntityId(1024),
350
+ } as any),
351
+ ).toBe(true);
352
+ expect(isRelationType({ type: "component" } as any)).toBe(false);
353
+
354
+ // buildCacheKey
355
+ const key = buildCacheKey([positionComponent, velocityComponent]);
356
+ expect(typeof key).toBe("string");
357
+
358
+ // getWildcardRelationDataSource
359
+ const ds = getWildcardRelationDataSource([relC, relD], positionComponent, false);
360
+ expect(ds).toHaveLength(2);
361
+ const dsOpt = getWildcardRelationDataSource([], positionComponent, true);
362
+ expect(dsOpt).toBeUndefined();
363
+
364
+ // buildRegularComponentValue
365
+ expect(buildRegularComponentValue([{ x: 9 }], 0, false)).toEqual({ x: 9 });
366
+ expect(buildRegularComponentValue(undefined, 0, true)).toBeUndefined();
367
+ expect(() => buildRegularComponentValue(undefined, 0, false)).toThrow();
368
+
369
+ // buildWildcardRelationValue - optional empty -> undefined
370
+ const dfStore = createSparseStore();
371
+ const wildcard = relation(positionComponent, "*");
372
+ const valOpt = buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6000), true);
373
+ expect(valOpt).toBeUndefined();
374
+
375
+ // buildWildcardRelationValue - mandatory empty -> throws
376
+ expect(() => buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6001), false)).toThrow(
377
+ /No matching relations found/,
378
+ );
379
+
380
+ // buildSingleComponent regular path
381
+ const val = buildSingleComponent(
382
+ positionComponent,
383
+ [{ x: 42 }],
384
+ 0,
385
+ createEntityId(7000),
386
+ (t) => (t === positionComponent ? [{ x: 42 }] : []),
387
+ dfStore,
388
+ );
389
+ expect(val).toEqual({ x: 42 });
390
+ });
249
391
  });
@@ -168,4 +168,16 @@ describe("BitSet word boundary tests", () => {
168
168
  expect(bitset.has(24)).toBe(false);
169
169
  expect(bitset.has(26)).toBe(false);
170
170
  });
171
+
172
+ it("should expose length and handle edge has/set/clear on bounds", () => {
173
+ const bitset = new BitSet(10);
174
+ expect(bitset.length).toBe(10);
175
+ expect(bitset.has(-1)).toBe(false);
176
+ expect(bitset.has(10)).toBe(false);
177
+ bitset.set(-1); // no-op
178
+ bitset.set(10); // no-op
179
+ bitset.clear(-1);
180
+ bitset.clear(10);
181
+ expect(bitset.has(0)).toBe(false);
182
+ });
171
183
  });
@@ -22,6 +22,7 @@ import {
22
22
  isEntityId,
23
23
  isExclusiveComponent,
24
24
  isRelationId,
25
+ isSparseComponent,
25
26
  isWildcardRelationId,
26
27
  relation,
27
28
  } from "../../entity";
@@ -503,6 +504,38 @@ describe("Component Options", () => {
503
504
  expect(isDontFragmentComponent(combinedComp)).toBe(true);
504
505
  });
505
506
 
507
+ it("should support the new `sparse` option (preferred name) and treat it identically to the legacy `dontFragment`", () => {
508
+ const sparseComp = component({ sparse: true });
509
+ const normalComp = component();
510
+
511
+ // New primary predicates
512
+ expect(isSparseComponent(sparseComp)).toBe(true);
513
+ expect(isSparseComponent(normalComp)).toBe(false);
514
+
515
+ // Old aliases must still work (BC)
516
+ expect(isDontFragmentComponent(sparseComp)).toBe(true);
517
+ expect(isDontFragmentComponent(normalComp)).toBe(false);
518
+
519
+ const options = getComponentOptions(sparseComp);
520
+ expect(options.sparse).toBe(true);
521
+ // Full BC: old key is also populated
522
+ expect(options.dontFragment).toBe(true);
523
+ });
524
+
525
+ it("should treat `sparse` and `dontFragment` as equivalent (both spellings set the same flag)", () => {
526
+ const viaSparse = component({ sparse: true });
527
+ const viaLegacy = component({ dontFragment: true });
528
+ const viaBoth = component({ sparse: true, dontFragment: true });
529
+
530
+ for (const c of [viaSparse, viaLegacy, viaBoth]) {
531
+ expect(isSparseComponent(c)).toBe(true);
532
+ expect(isDontFragmentComponent(c)).toBe(true);
533
+ const opts = getComponentOptions(c);
534
+ expect(opts.sparse).toBe(true);
535
+ expect(opts.dontFragment).toBe(true);
536
+ }
537
+ });
538
+
506
539
  it("should store and retrieve component merge callback", () => {
507
540
  const merge = (prev: number[], next: number[]) => [...prev, ...next];
508
541
  const mailboxComp = component<number[]>({ merge });
@@ -6,12 +6,16 @@ import {
6
6
  createEntityId,
7
7
  decodeRelationId,
8
8
  ENTITY_ID_START,
9
+ getComponentIdFromRelationId,
9
10
  getDetailedIdType,
10
11
  getIdType,
12
+ getTargetIdFromRelationId,
11
13
  inspectEntityId,
12
14
  INVALID_COMPONENT_ID,
13
15
  isComponentId,
16
+ isComponentRelation,
14
17
  isEntityId,
18
+ isEntityRelation,
15
19
  isRelationId,
16
20
  isWildcardRelationId,
17
21
  relation,
@@ -257,4 +261,40 @@ describe("Entity ID System", () => {
257
261
  expect(decoded.type).toBe("entity");
258
262
  });
259
263
  });
264
+
265
+ describe("Error paths and additional relation utils", () => {
266
+ it("should throw on decodeRelationId for non-relation", () => {
267
+ expect(() => decodeRelationId(123 as any)).toThrow("ID is not a relation ID");
268
+ });
269
+
270
+ it("should throw on decodeRelationId for invalid component or target in relation", () => {
271
+ expect(() => decodeRelationId(-123456 as any)).toThrow();
272
+ });
273
+
274
+ it("should cover isEntityRelation and isComponentRelation", () => {
275
+ const entRel = relation(createComponentId(1), createEntityId(ENTITY_ID_START));
276
+ const compRel = relation(createComponentId(2), createComponentId(3));
277
+ const wild = relation(createComponentId(4), "*");
278
+ const plain = createEntityId(ENTITY_ID_START);
279
+
280
+ expect(isEntityRelation(entRel)).toBe(true);
281
+ expect(isEntityRelation(compRel)).toBe(false);
282
+ expect(isEntityRelation(wild)).toBe(false);
283
+ expect(isEntityRelation(plain)).toBe(false);
284
+
285
+ expect(isComponentRelation(compRel)).toBe(true);
286
+ expect(isComponentRelation(entRel)).toBe(false);
287
+ expect(isComponentRelation(wild)).toBe(false);
288
+ });
289
+
290
+ it("should cover getComponentIdFromRelationId and getTargetIdFromRelationId on valids and invalids", () => {
291
+ const r = relation(createComponentId(99), createEntityId(1024));
292
+ expect(getComponentIdFromRelationId(r)).toBe(createComponentId(99));
293
+ expect(getTargetIdFromRelationId(r)).toBe(createEntityId(1024));
294
+
295
+ expect(getComponentIdFromRelationId(123 as any)).toBeUndefined();
296
+ expect(getTargetIdFromRelationId(123 as any)).toBeUndefined();
297
+ expect(getComponentIdFromRelationId(-999999 as any)).toBeUndefined();
298
+ });
299
+ });
260
300
  });
@@ -117,7 +117,7 @@ describe("Comprehensive ECS performance benchmarks", () => {
117
117
  }
118
118
  world.sync();
119
119
 
120
- const movementQuery = world.createQuery([Position, Velocity]);
120
+ using movementQuery = world.createQuery([Position, Velocity]);
121
121
 
122
122
  // Pure iteration (no writes)
123
123
  const readAvg = benchmark("10k entities: forEach read-only query", 2, 6, () => {
@@ -137,8 +137,6 @@ describe("Comprehensive ECS performance benchmarks", () => {
137
137
  });
138
138
  });
139
139
 
140
- movementQuery.dispose();
141
-
142
140
  console.log(`Sum X (to prevent optimization): ${sumX}`);
143
141
  expect(readAvg).toBeLessThan(20);
144
142
  expect(updateAvg).toBeLessThan(20);
@@ -195,7 +193,7 @@ describe("Comprehensive ECS performance benchmarks", () => {
195
193
  }
196
194
  world.sync();
197
195
 
198
- const movementQuery = world.createQuery([Position, Health]);
196
+ using movementQuery = world.createQuery([Position, Health]);
199
197
 
200
198
  const mixedAvg = benchmark("2k entities: mixed ops (update 90%, spawn 5%, delete 5%) + sync", 2, 6, (round) => {
201
199
  const deleteCount = Math.floor(entities.length * 0.05);
@@ -226,7 +224,6 @@ describe("Comprehensive ECS performance benchmarks", () => {
226
224
  world.sync();
227
225
  });
228
226
 
229
- movementQuery.dispose();
230
227
  expect(mixedAvg).toBeLessThan(300);
231
228
  });
232
229
 
@@ -265,12 +262,12 @@ describe("Comprehensive ECS performance benchmarks", () => {
265
262
  });
266
263
 
267
264
  /**
268
- * Benchmark 7: dontFragment relation updates (the existing benchmark scenario)
265
+ * Benchmark 7: sparse relation updates
269
266
  */
270
- it("should handle dontFragment exclusive relation flips efficiently", () => {
267
+ it("should handle exclusive sparse relation flips efficiently", () => {
271
268
  const world = new World();
272
269
  const Position = component<{ x: number; y: number }>();
273
- const ChildOf = component({ dontFragment: true, exclusive: true });
270
+ const ChildOf = component({ sparse: true, exclusive: true });
274
271
 
275
272
  const parentA = world.new();
276
273
  const parentB = world.new();
@@ -285,7 +282,7 @@ describe("Comprehensive ECS performance benchmarks", () => {
285
282
  }
286
283
  world.sync();
287
284
 
288
- const relationFlipAvg = benchmark("4k entities: exclusive dontFragment relation flip + sync", 2, 8, (round) => {
285
+ const relationFlipAvg = benchmark("4k entities: exclusive sparse relation flip + sync", 2, 8, (round) => {
289
286
  const target = round % 2 === 0 ? parentB : parentA;
290
287
  for (let i = 0; i < entities.length; i++) {
291
288
  world.set(entities[i]!, relation(ChildOf, target));
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { component, relation, type EntityId } from "../../entity";
3
+ import { World } from "../../world/world";
4
+
5
+ function benchmark(label: string, warmupRounds: number, measuredRounds: number, fn: (round: number) => void): number {
6
+ const durations: number[] = [];
7
+
8
+ const totalRounds = warmupRounds + measuredRounds;
9
+ for (let round = 0; round < totalRounds; round++) {
10
+ const start = performance.now();
11
+ fn(round);
12
+ const duration = performance.now() - start;
13
+ if (round >= warmupRounds) {
14
+ durations.push(duration);
15
+ }
16
+ }
17
+
18
+ const average = durations.reduce((sum, duration) => sum + duration, 0) / durations.length;
19
+ console.log(
20
+ `${label}: avg ${average.toFixed(2)}ms after ${warmupRounds} warmup rounds (${durations
21
+ .map((d) => d.toFixed(2))
22
+ .join("ms, ")}ms per measured round)`,
23
+ );
24
+ return average;
25
+ }
26
+
27
+ /**
28
+ * Serialization performance benchmarks.
29
+ *
30
+ * These benchmarks validate the post-optimization serialization path
31
+ * (see optimization plan for src/world/serialization.ts):
32
+ *
33
+ * Key optimizations exercised:
34
+ * - Column-oriented direct export from Archetype (bypasses per-entity Map + dump())
35
+ * - Per-archetype pre-encoding of component type IDs
36
+ * - ID encoding cache (encodeEntityIdCached) for repeated component/relation IDs
37
+ * - Removal of redundant per-entity work in deserialization
38
+ *
39
+ * Target scale: 8k–12k entities across multiple archetypes + relations.
40
+ * This is large enough to show meaningful differences while keeping test runtime reasonable.
41
+ */
42
+ describe("Serialization performance (post-optimization baseline)", () => {
43
+ it("should serialize and deserialize large mixed worlds efficiently", () => {
44
+ const world = new World();
45
+
46
+ // Named components (realistic serialization path with name lookup)
47
+ const Position = component<{ x: number; y: number }>("Position");
48
+ const Velocity = component<{ vx: number; vy: number }>("Velocity");
49
+ const Health = component<{ hp: number; maxHp: number }>("Health");
50
+ const Name = component<{ value: string }>("Name");
51
+ const Inventory = component<{ items: string[] }>("Inventory");
52
+
53
+ // Entity-valued component (creates entity references)
54
+ const Target = component<{ entity: EntityId }>("Target");
55
+
56
+ // Relations
57
+ const ChildOf = component<void>("ChildOf");
58
+
59
+ const entityCount = 12_000;
60
+ const entities: EntityId[] = [];
61
+
62
+ // Distribute entities across several archetypes for realistic archetype diversity
63
+ // Archetype A: Position + Velocity + Health
64
+ // Archetype B: Position + Name + Inventory
65
+ // Archetype C: Position + Velocity + Target (entity-valued component)
66
+ // Archetype D: Position only (minimal)
67
+
68
+ const parents: EntityId[] = [];
69
+
70
+ for (let i = 0; i < entityCount; i++) {
71
+ const entity = world.new();
72
+ entities.push(entity);
73
+
74
+ const archetypeKind = i % 4;
75
+
76
+ world.set(entity, Position, { x: i, y: i * 2 });
77
+
78
+ if (archetypeKind === 0) {
79
+ // Archetype A
80
+ world.set(entity, Velocity, { vx: 1, vy: 0.5 });
81
+ world.set(entity, Health, { hp: 100, maxHp: 100 });
82
+ } else if (archetypeKind === 1) {
83
+ // Archetype B
84
+ world.set(entity, Name, { value: `Entity-${i}` });
85
+ world.set(entity, Inventory, { items: ["sword", "potion"] });
86
+ } else if (archetypeKind === 2) {
87
+ // Archetype C — has entity reference
88
+ world.set(entity, Velocity, { vx: 0.2, vy: -1 });
89
+ // Point to a previous entity (creates realistic entity-valued component)
90
+ const targetIdx = Math.max(0, i - 7);
91
+ world.set(entity, Target, { entity: entities[targetIdx]! });
92
+ } else {
93
+ // Archetype D — minimal
94
+ // Only Position
95
+ }
96
+
97
+ // Every 17th entity becomes a parent and gets some children via relations
98
+ if (i % 17 === 0) {
99
+ parents.push(entity);
100
+ }
101
+ }
102
+
103
+ // Add relations (ChildOf) — creates entity-relation IDs that must be encoded
104
+ for (let i = 0; i < entityCount; i++) {
105
+ const parentIdx = Math.floor(i / 8) % Math.max(1, parents.length);
106
+ const parent = parents[parentIdx] ?? entities[0]!;
107
+ if (parent !== entities[i]) {
108
+ world.set(entities[i]!, relation(ChildOf, parent));
109
+ }
110
+ }
111
+
112
+ world.sync();
113
+
114
+ expect(entities.length).toBe(entityCount);
115
+
116
+ const warmup = 2;
117
+ const measured = 5;
118
+
119
+ // === Serialize ===
120
+ let lastSnapshot: ReturnType<World["serialize"]> | null = null;
121
+
122
+ const serializeAvg = benchmark(
123
+ `serialize ${entityCount} entities (mixed archetypes + relations)`,
124
+ warmup,
125
+ measured,
126
+ () => {
127
+ lastSnapshot = world.serialize();
128
+ },
129
+ );
130
+
131
+ expect(lastSnapshot).toBeDefined();
132
+ expect(lastSnapshot!.entities.length).toBeGreaterThanOrEqual(entityCount * 0.9); // rough sanity
133
+
134
+ // Measure rough heap impact of a serialize call.
135
+ // Note: Bun may require --smol or explicit GC for more stable allocation numbers.
136
+ if (typeof Bun !== "undefined" && Bun.gc) {
137
+ Bun.gc(true);
138
+ }
139
+ const memBefore = process.memoryUsage();
140
+ void world.serialize();
141
+ const memAfter = process.memoryUsage();
142
+ const heapDeltaMB = ((memAfter.heapUsed - memBefore.heapUsed) / 1024 / 1024).toFixed(2);
143
+ console.log(
144
+ `serialize heap delta (one call): ~${heapDeltaMB} MB (rss delta: ${((memAfter.rss - memBefore.rss) / 1024 / 1024).toFixed(2)} MB)`,
145
+ );
146
+
147
+ // === Deserialize (new World from snapshot) ===
148
+ const deserializeAvg = benchmark(
149
+ `deserialize ${entityCount} entities (new World(snapshot))`,
150
+ warmup,
151
+ measured,
152
+ () => {
153
+ // We create and immediately let go of the world to measure allocation + construction cost
154
+ const restored = new World(lastSnapshot!);
155
+ // Touch one value to prevent dead-code elimination in theory
156
+ if (restored.exists(entities[0]!)) {
157
+ void restored.get(entities[0]!, Position);
158
+ }
159
+ },
160
+ );
161
+
162
+ // === Full JSON roundtrip (very common user pattern) ===
163
+ const jsonRoundtripAvg = benchmark(
164
+ `full JSON roundtrip (stringify + parse + new World) — ${entityCount} entities`,
165
+ warmup,
166
+ measured,
167
+ () => {
168
+ const json = JSON.stringify(world.serialize());
169
+ const parsed = JSON.parse(json);
170
+ const restored = new World(parsed);
171
+ if (restored.exists(entities[42]!)) {
172
+ void restored.get(entities[42]!, Position);
173
+ }
174
+ },
175
+ );
176
+
177
+ // Loose upper bounds — these act as regression guards.
178
+ // The numbers are intentionally generous to account for CI machine variance.
179
+ // The main value is the detailed console output for manual before/after comparison.
180
+ expect(serializeAvg).toBeLessThan(80); // ~12k entities serialize
181
+ expect(deserializeAvg).toBeLessThan(120); // new World(snapshot) tends to be heavier
182
+ expect(jsonRoundtripAvg).toBeLessThan(200); // includes JSON + full deserialize
183
+
184
+ // Final sanity: the last deserialized world should still have most entities
185
+ const finalRestored = new World(lastSnapshot!);
186
+ expect(finalRestored.exists(entities[0]!)).toBe(true);
187
+ expect(finalRestored.exists(entities[entityCount - 1]!)).toBe(true);
188
+ });
189
+
190
+ it("should handle worlds with heavy entity-relation usage", () => {
191
+ const world = new World();
192
+
193
+ const Position = component<{ x: number; y: number }>("Pos");
194
+ const Owes = component<{ amount: number }>("Owes"); // used for relations
195
+
196
+ const entityCount = 8_000;
197
+ const entities: EntityId[] = [];
198
+
199
+ for (let i = 0; i < entityCount; i++) {
200
+ const e = world.new();
201
+ entities.push(e);
202
+ world.set(e, Position, { x: i, y: i });
203
+ }
204
+
205
+ // Create a dense web of entity-relations (every entity owes 3 others)
206
+ for (let i = 0; i < entityCount; i++) {
207
+ for (let j = 1; j <= 3; j++) {
208
+ const target = entities[(i + j * 17) % entityCount]!;
209
+ if (target !== entities[i]) {
210
+ world.set(entities[i]!, relation(Owes, target), { amount: (i + j) % 100 });
211
+ }
212
+ }
213
+ }
214
+
215
+ world.sync();
216
+
217
+ const warmup = 1;
218
+ const measured = 4;
219
+
220
+ const serializeAvg = benchmark(
221
+ `serialize ${entityCount} entities (dense entity-relations)`,
222
+ warmup,
223
+ measured,
224
+ () => {
225
+ void world.serialize();
226
+ },
227
+ );
228
+
229
+ // Deserialize with many relations exercises decode + reference tracking
230
+ let snapshot: ReturnType<World["serialize"]> | undefined;
231
+
232
+ const deserializeAvg = benchmark(`deserialize ${entityCount} entities (dense relations)`, warmup, measured, () => {
233
+ if (!snapshot) snapshot = world.serialize();
234
+ const w = new World(snapshot);
235
+ void w;
236
+ });
237
+
238
+ // Very loose bounds — this scenario is intentionally expensive
239
+ expect(serializeAvg).toBeLessThan(150);
240
+ expect(deserializeAvg).toBeLessThan(220);
241
+ });
242
+ });