archetype-ecs 1.3.1 → 1.3.2

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 (47) hide show
  1. package/README.md +104 -34
  2. package/bench/allocations-1m.js +1 -1
  3. package/bench/multi-ecs-bench.js +1 -1
  4. package/bench/typed-vs-bitecs-1m.js +1 -1
  5. package/bench/typed-vs-untyped.js +81 -81
  6. package/bench/vs-bitecs.js +31 -56
  7. package/dist/ComponentRegistry.d.ts +18 -0
  8. package/dist/ComponentRegistry.js +29 -0
  9. package/dist/EntityManager.d.ts +52 -0
  10. package/dist/EntityManager.js +891 -0
  11. package/dist/Profiler.d.ts +12 -0
  12. package/dist/Profiler.js +38 -0
  13. package/dist/System.d.ts +41 -0
  14. package/dist/System.js +159 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +29 -0
  17. package/dist/src/ComponentRegistry.d.ts +18 -0
  18. package/dist/src/ComponentRegistry.js +29 -0
  19. package/dist/src/EntityManager.d.ts +49 -0
  20. package/dist/src/EntityManager.js +853 -0
  21. package/dist/src/Profiler.d.ts +12 -0
  22. package/dist/src/Profiler.js +38 -0
  23. package/dist/src/System.d.ts +37 -0
  24. package/dist/src/System.js +139 -0
  25. package/dist/src/index.d.ts +14 -0
  26. package/dist/src/index.js +29 -0
  27. package/dist/tests/EntityManager.test.d.ts +1 -0
  28. package/dist/tests/EntityManager.test.js +651 -0
  29. package/dist/tests/System.test.d.ts +1 -0
  30. package/dist/tests/System.test.js +630 -0
  31. package/dist/tests/types.d.ts +1 -0
  32. package/dist/tests/types.js +129 -0
  33. package/package.json +9 -7
  34. package/src/ComponentRegistry.ts +49 -0
  35. package/src/EntityManager.ts +1018 -0
  36. package/src/{Profiler.js → Profiler.ts} +18 -5
  37. package/src/System.ts +226 -0
  38. package/src/index.ts +44 -0
  39. package/tests/{EntityManager.test.js → EntityManager.test.ts} +338 -70
  40. package/tests/System.test.ts +730 -0
  41. package/tests/types.ts +67 -66
  42. package/tsconfig.json +8 -5
  43. package/tsconfig.test.json +13 -0
  44. package/src/ComponentRegistry.js +0 -21
  45. package/src/EntityManager.js +0 -578
  46. package/src/index.d.ts +0 -118
  47. package/src/index.js +0 -37
@@ -1,10 +1,10 @@
1
1
  import { describe, it, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { createEntityManager } from '../src/EntityManager.js';
4
- import { component } from '../src/index.js';
3
+ import { createEntityManager, type EntityManager } from '../src/EntityManager.js';
4
+ import { component, type ComponentDef } from '../src/index.js';
5
5
 
6
6
  describe('EntityManager', () => {
7
- let em;
7
+ let em: EntityManager;
8
8
  const Position = component('Position', 'f32', ['x', 'y']);
9
9
  const Velocity = component('Velocity', 'f32', ['vx', 'vy']);
10
10
  const Health = component('Health', 'f32', ['hp']);
@@ -140,7 +140,7 @@ describe('EntityManager', () => {
140
140
  [Velocity._sym, 'Velocity'],
141
141
  [Health._sym, 'Health']
142
142
  ]);
143
- const nameToSymbol = { Position, Velocity, Health };
143
+ const nameToSymbol: Record<string, ComponentDef> = { Position, Velocity, Health };
144
144
 
145
145
  it('round-trips entities and components', () => {
146
146
  const a = em.createEntity();
@@ -189,33 +189,33 @@ describe('EntityManager', () => {
189
189
  const a = em.createEntity();
190
190
  em.addComponent(a, Meta, { x: 1, y: 2, secret: 42 });
191
191
 
192
- const serializers = new Map([
193
- ['Meta', (data) => ({ x: data.x, y: data.y })]
192
+ const serializers = new Map<string, (data: any) => any>([
193
+ ['Meta', (data: any) => ({ x: data.x, y: data.y })]
194
194
  ]);
195
195
 
196
196
  const result = em.serialize(metaSymbolToName, [], [], { serializers });
197
197
  assert.deepEqual(result.components['Meta'][a], { x: 1, y: 2 });
198
- assert.equal(result.components['Meta'][a].secret, undefined);
198
+ assert.equal((result.components['Meta'][a] as Record<string, unknown>).secret, undefined);
199
199
  });
200
200
 
201
201
  it('custom deserializers are used when provided', () => {
202
202
  const Meta = component('Meta2', { x: 'f32', y: 'f32' });
203
203
  const metaSymbolToName = new Map([...symbolToName, [Meta._sym, 'Meta2']]);
204
- const metaNameToSymbol = { ...nameToSymbol, Meta2: Meta };
204
+ const metaNameToSymbol: Record<string, ComponentDef> = { ...nameToSymbol, Meta2: Meta };
205
205
 
206
206
  const a = em.createEntity();
207
207
  em.addComponent(a, Meta, { x: 1, y: 2 });
208
208
 
209
209
  const data = em.serialize(metaSymbolToName);
210
210
 
211
- const deserializers = new Map([
212
- ['Meta2', (compData) => ({ ...compData, restored: true })]
211
+ const deserializers = new Map<string, (data: unknown) => unknown>([
212
+ ['Meta2', (compData) => ({ ...(compData as Record<string, unknown>), restored: true })]
213
213
  ]);
214
214
 
215
215
  em.deserialize(data, metaNameToSymbol, { deserializers });
216
- const result = em.getComponent(a, Meta);
217
- assert.ok(Math.abs(result.x - 1) < 0.01);
218
- assert.ok(Math.abs(result.y - 2) < 0.01);
216
+ const result = em.getComponent(a, Meta)!;
217
+ assert.ok(Math.abs(result.x as number - 1) < 0.01);
218
+ assert.ok(Math.abs(result.y as number - 2) < 0.01);
219
219
  });
220
220
 
221
221
  it('deserialize clears previous state', () => {
@@ -229,7 +229,7 @@ describe('EntityManager', () => {
229
229
  });
230
230
 
231
231
  describe('Typed Components (SoA)', () => {
232
- let em;
232
+ let em: EntityManager;
233
233
 
234
234
  beforeEach(() => {
235
235
  em = createEntityManager();
@@ -239,23 +239,23 @@ describe('Typed Components (SoA)', () => {
239
239
  const Pos = component('Pos', 'f32', ['x', 'y']);
240
240
  const id = em.createEntity();
241
241
  em.addComponent(id, Pos, { x: 1.5, y: 2.5 });
242
- const result = em.getComponent(id, Pos);
243
- assert.ok(Math.abs(result.x - 1.5) < 0.001);
244
- assert.ok(Math.abs(result.y - 2.5) < 0.001);
242
+ const result = em.getComponent(id, Pos)!;
243
+ assert.ok(Math.abs(result.x as number - 1.5) < 0.001);
244
+ assert.ok(Math.abs(result.y as number - 2.5) < 0.001);
245
245
  });
246
246
 
247
247
  it('growth past initial capacity (>64 entities)', () => {
248
248
  const Pos = component('PosGrow', 'f32', ['x', 'y']);
249
- const ids = [];
249
+ const ids: number[] = [];
250
250
  for (let i = 0; i < 100; i++) {
251
251
  const id = em.createEntity();
252
252
  em.addComponent(id, Pos, { x: i, y: i * 2 });
253
253
  ids.push(id);
254
254
  }
255
255
  for (let i = 0; i < 100; i++) {
256
- const result = em.getComponent(ids[i], Pos);
257
- assert.ok(Math.abs(result.x - i) < 0.001);
258
- assert.ok(Math.abs(result.y - i * 2) < 0.001);
256
+ const result = em.getComponent(ids[i], Pos)!;
257
+ assert.ok(Math.abs(result.x as number - i) < 0.001);
258
+ assert.ok(Math.abs(result.y as number - i * 2) < 0.001);
259
259
  }
260
260
  });
261
261
 
@@ -270,13 +270,10 @@ describe('Typed Components (SoA)', () => {
270
270
 
271
271
  em.destroyEntity(a);
272
272
 
273
- const resultB = em.getComponent(b, Pos);
274
- assert.ok(Math.abs(resultB.x - 3) < 0.001);
275
- assert.ok(Math.abs(resultB.y - 4) < 0.001);
276
-
277
- const resultC = em.getComponent(c, Pos);
278
- assert.ok(Math.abs(resultC.x - 5) < 0.001);
279
- assert.ok(Math.abs(resultC.y - 6) < 0.001);
273
+ const resultB = em.getComponent(b, Pos)!;
274
+ assert.ok(Math.abs(resultB.x as number - 3) < 0.001);
275
+ const resultC = em.getComponent(c, Pos)!;
276
+ assert.ok(Math.abs(resultC.x as number - 5) < 0.001);
280
277
  });
281
278
 
282
279
  it('typed + tag on same entity', () => {
@@ -286,11 +283,8 @@ describe('Typed Components (SoA)', () => {
286
283
  em.addComponent(id, Pos, { x: 10, y: 20 });
287
284
  em.addComponent(id, Tag, {});
288
285
 
289
- const pos = em.getComponent(id, Pos);
290
- assert.ok(Math.abs(pos.x - 10) < 0.001);
291
- assert.ok(Math.abs(pos.y - 20) < 0.001);
292
-
293
- // Tag has no schema, getComponent returns undefined
286
+ const pos = em.getComponent(id, Pos)!;
287
+ assert.ok(Math.abs(pos.x as number - 10) < 0.001);
294
288
  assert.equal(em.getComponent(id, Tag), undefined);
295
289
  assert.equal(em.hasComponent(id, Tag), true);
296
290
  });
@@ -306,10 +300,10 @@ describe('Typed Components (SoA)', () => {
306
300
  }
307
301
 
308
302
  em.forEach([Pos, Vel], (arch) => {
309
- const px = arch.field(Pos.x);
310
- const py = arch.field(Pos.y);
311
- const vx = arch.field(Vel.vx);
312
- const vy = arch.field(Vel.vy);
303
+ const px = arch.field(Pos.x) as Float32Array;
304
+ const py = arch.field(Pos.y) as Float32Array;
305
+ const vx = arch.field(Vel.vx) as Float32Array;
306
+ const vy = arch.field(Vel.vy) as Float32Array;
313
307
  for (let i = 0; i < arch.count; i++) {
314
308
  px[i] += vx[i];
315
309
  py[i] += vy[i];
@@ -318,16 +312,16 @@ describe('Typed Components (SoA)', () => {
318
312
 
319
313
  const ids = em.query([Pos, Vel]);
320
314
  for (const id of ids) {
321
- const pos = em.getComponent(id, Pos);
322
- assert.ok(pos.x >= 1);
323
- assert.ok(Math.abs(pos.y - 2) < 0.001);
315
+ const pos = em.getComponent(id, Pos)!;
316
+ assert.ok((pos.x as number) >= 1);
317
+ assert.ok(Math.abs(pos.y as number - 2) < 0.001);
324
318
  }
325
319
  });
326
320
 
327
321
  it('serialize/deserialize round-trip with typed components', () => {
328
322
  const Pos = component('PosSer', 'f32', ['x', 'y']);
329
323
  const symbolToName = new Map([[Pos._sym, 'PosSer']]);
330
- const nameToSymbol = { PosSer: Pos };
324
+ const nameToSymbol: Record<string, ComponentDef> = { PosSer: Pos };
331
325
 
332
326
  const a = em.createEntity();
333
327
  em.addComponent(a, Pos, { x: 1.5, y: 2.5 });
@@ -337,13 +331,10 @@ describe('Typed Components (SoA)', () => {
337
331
  const data = em.serialize(symbolToName);
338
332
  em.deserialize(data, nameToSymbol);
339
333
 
340
- const posA = em.getComponent(a, Pos);
341
- assert.ok(Math.abs(posA.x - 1.5) < 0.01);
342
- assert.ok(Math.abs(posA.y - 2.5) < 0.01);
343
-
344
- const posB = em.getComponent(b, Pos);
345
- assert.ok(Math.abs(posB.x - 3.5) < 0.01);
346
- assert.ok(Math.abs(posB.y - 4.5) < 0.01);
334
+ const posA = em.getComponent(a, Pos)!;
335
+ assert.ok(Math.abs(posA.x as number - 1.5) < 0.01);
336
+ const posB = em.getComponent(b, Pos)!;
337
+ assert.ok(Math.abs(posB.x as number - 3.5) < 0.01);
347
338
  });
348
339
 
349
340
  it('archetype migration with typed components', () => {
@@ -354,18 +345,12 @@ describe('Typed Components (SoA)', () => {
354
345
  em.addComponent(id, Pos, { x: 5, y: 10 });
355
346
  em.addComponent(id, Vel, { vx: 1, vy: 2 });
356
347
 
357
- const pos = em.getComponent(id, Pos);
358
- assert.ok(Math.abs(pos.x - 5) < 0.001);
359
- assert.ok(Math.abs(pos.y - 10) < 0.001);
360
-
361
- const vel = em.getComponent(id, Vel);
362
- assert.ok(Math.abs(vel.vx - 1) < 0.001);
363
- assert.ok(Math.abs(vel.vy - 2) < 0.001);
348
+ const pos = em.getComponent(id, Pos)!;
349
+ assert.ok(Math.abs(pos.x as number - 5) < 0.001);
364
350
 
365
351
  em.removeComponent(id, Vel);
366
- const pos2 = em.getComponent(id, Pos);
367
- assert.ok(Math.abs(pos2.x - 5) < 0.001);
368
- assert.ok(Math.abs(pos2.y - 10) < 0.001);
352
+ const pos2 = em.getComponent(id, Pos)!;
353
+ assert.ok(Math.abs(pos2.x as number - 5) < 0.001);
369
354
  assert.equal(em.hasComponent(id, Vel), false);
370
355
  });
371
356
 
@@ -374,20 +359,17 @@ describe('Typed Components (SoA)', () => {
374
359
  const id = em.createEntity();
375
360
  em.addComponent(id, Pos, { x: 1, y: 2 });
376
361
  em.addComponent(id, Pos, { x: 99, y: 88 });
377
- const result = em.getComponent(id, Pos);
378
- assert.ok(Math.abs(result.x - 99) < 0.001);
379
- assert.ok(Math.abs(result.y - 88) < 0.001);
362
+ const result = em.getComponent(id, Pos)!;
363
+ assert.ok(Math.abs(result.x as number - 99) < 0.001);
380
364
  });
381
365
 
382
366
  it('get/set for zero-allocation field access', () => {
383
367
  const Pos = component('PosGS', 'f32', ['x', 'y']);
384
368
  const id = em.createEntity();
385
369
  em.addComponent(id, Pos, { x: 3.5, y: 7.5 });
386
- assert.ok(Math.abs(em.get(id, Pos.x) - 3.5) < 0.001);
387
- assert.ok(Math.abs(em.get(id, Pos.y) - 7.5) < 0.001);
370
+ assert.ok(Math.abs(em.get(id, Pos.x)! as number - 3.5) < 0.001);
388
371
  em.set(id, Pos.x, 42);
389
- assert.ok(Math.abs(em.get(id, Pos.x) - 42) < 0.001);
390
- assert.ok(Math.abs(em.get(id, Pos.y) - 7.5) < 0.001);
372
+ assert.ok(Math.abs(em.get(id, Pos.x)! as number - 42) < 0.001);
391
373
  });
392
374
 
393
375
  it('get returns undefined for missing entity/component', () => {
@@ -465,11 +447,7 @@ describe('Typed Components (SoA)', () => {
465
447
  em.addComponent(id, Item, { name: 'Sword', weight: 3.5 });
466
448
 
467
449
  assert.equal(em.get(id, Item.name), 'Sword');
468
- assert.ok(Math.abs(em.get(id, Item.weight) - 3.5) < 0.01);
469
-
470
- const obj = em.getComponent(id, Item);
471
- assert.equal(obj.name, 'Sword');
472
- assert.ok(Math.abs(obj.weight - 3.5) < 0.01);
450
+ assert.ok(Math.abs(em.get(id, Item.weight)! as number - 3.5) < 0.01);
473
451
  });
474
452
 
475
453
  it('string component forEach field access', () => {
@@ -487,3 +465,293 @@ describe('Typed Components (SoA)', () => {
487
465
  });
488
466
  });
489
467
  });
468
+
469
+ describe('Deferred Structural Changes during forEach', () => {
470
+ let em: EntityManager;
471
+ const Pos = component('DPos', 'f32', ['x', 'y']);
472
+ const Vel = component('DVel', 'f32', ['vx', 'vy']);
473
+
474
+ beforeEach(() => {
475
+ em = createEntityManager();
476
+ });
477
+
478
+ it('removeComponent during forEach is deferred and applied after', () => {
479
+ const a = em.createEntity();
480
+ const b = em.createEntity();
481
+ const c = em.createEntity();
482
+ em.addComponent(a, Pos, { x: 1, y: 0 });
483
+ em.addComponent(b, Pos, { x: 2, y: 0 });
484
+ em.addComponent(c, Pos, { x: 3, y: 0 });
485
+
486
+ const visited: number[] = [];
487
+ em.forEach([Pos], (arch) => {
488
+ const ids = arch.entityIds;
489
+ for (let i = 0; i < arch.count; i++) {
490
+ visited.push(ids[i]);
491
+ if (ids[i] === a) em.removeComponent(a, Pos);
492
+ }
493
+ });
494
+
495
+ assert.equal(visited.length, 3);
496
+ assert.equal(em.hasComponent(a, Pos), false);
497
+ assert.equal(em.hasComponent(b, Pos), true);
498
+ });
499
+
500
+ it('addComponent (migration) during forEach is deferred', () => {
501
+ const a = em.createEntity();
502
+ const b = em.createEntity();
503
+ em.addComponent(a, Pos, { x: 1, y: 0 });
504
+ em.addComponent(b, Pos, { x: 2, y: 0 });
505
+
506
+ em.forEach([Pos], (arch) => {
507
+ const ids = arch.entityIds;
508
+ for (let i = 0; i < arch.count; i++) {
509
+ if (ids[i] === a) em.addComponent(a, Vel, { vx: 10, vy: 20 });
510
+ }
511
+ });
512
+
513
+ assert.equal(em.hasComponent(a, Vel), true);
514
+ const vel = em.getComponent(a, Vel)!;
515
+ assert.ok(Math.abs((vel.vx as number) - 10) < 0.001);
516
+ });
517
+
518
+ it('addComponent overwrite during forEach is immediate (no migration)', () => {
519
+ const a = em.createEntity();
520
+ em.addComponent(a, Pos, { x: 1, y: 2 });
521
+
522
+ em.forEach([Pos], (arch) => {
523
+ const px = arch.field(Pos.x) as Float32Array;
524
+ em.addComponent(a, Pos, { x: 99, y: 88 });
525
+ assert.ok(Math.abs(px[0] - 99) < 0.001);
526
+ });
527
+ });
528
+
529
+ it('destroyEntity during forEach is deferred', () => {
530
+ const a = em.createEntity();
531
+ const b = em.createEntity();
532
+ const c = em.createEntity();
533
+ em.addComponent(a, Pos, { x: 1, y: 0 });
534
+ em.addComponent(b, Pos, { x: 2, y: 0 });
535
+ em.addComponent(c, Pos, { x: 3, y: 0 });
536
+
537
+ const visited: number[] = [];
538
+ em.forEach([Pos], (arch) => {
539
+ const ids = arch.entityIds;
540
+ for (let i = 0; i < arch.count; i++) {
541
+ visited.push(ids[i]);
542
+ if (ids[i] === b) em.destroyEntity(b);
543
+ }
544
+ });
545
+
546
+ assert.equal(visited.length, 3);
547
+ assert.equal(em.hasComponent(b, Pos), false);
548
+ assert.equal(em.getAllEntities().includes(b), false);
549
+ });
550
+
551
+ it('multiple deferred operations are applied in order', () => {
552
+ const a = em.createEntity();
553
+ em.addComponent(a, Pos, { x: 1, y: 0 });
554
+
555
+ em.forEach([Pos], () => {
556
+ em.removeComponent(a, Pos);
557
+ em.addComponent(a, Vel, { vx: 5, vy: 6 });
558
+ });
559
+
560
+ assert.equal(em.hasComponent(a, Pos), false);
561
+ assert.equal(em.hasComponent(a, Vel), true);
562
+ });
563
+
564
+ it('nested forEach properly defers until outermost forEach completes', () => {
565
+ const a = em.createEntity();
566
+ const b = em.createEntity();
567
+ em.addComponent(a, Pos, { x: 1, y: 0 });
568
+ em.addComponent(b, Vel, { vx: 2, vy: 0 });
569
+
570
+ em.forEach([Pos], () => {
571
+ em.forEach([Vel], () => {
572
+ em.removeComponent(b, Vel);
573
+ });
574
+ assert.equal(em.hasComponent(b, Vel), true); // still deferred
575
+ });
576
+
577
+ assert.equal(em.hasComponent(b, Vel), false);
578
+ });
579
+
580
+ it('em.set() remains immediate during forEach', () => {
581
+ const a = em.createEntity();
582
+ em.addComponent(a, Pos, { x: 1, y: 2 });
583
+
584
+ em.forEach([Pos], (arch) => {
585
+ em.set(a, Pos.x, 42);
586
+ const px = arch.field(Pos.x) as Float32Array;
587
+ assert.ok(Math.abs(px[0] - 42) < 0.001);
588
+ });
589
+ });
590
+
591
+ it('hooks still fire correctly with deferred structural changes', () => {
592
+ const added: number[] = [];
593
+ const removed: number[] = [];
594
+ em.onAdd(Vel, (id) => added.push(id));
595
+ em.onRemove(Pos, (id) => removed.push(id));
596
+
597
+ const a = em.createEntity();
598
+ em.addComponent(a, Pos, { x: 1, y: 0 });
599
+ em.flushHooks();
600
+
601
+ em.forEach([Pos], () => {
602
+ em.removeComponent(a, Pos);
603
+ em.addComponent(a, Vel, { vx: 1, vy: 2 });
604
+ });
605
+
606
+ em.flushHooks();
607
+ assert.deepEqual(removed, [a]);
608
+ assert.deepEqual(added, [a]);
609
+ });
610
+ });
611
+
612
+ describe('Deferred Hooks (onAdd / onRemove)', () => {
613
+ let em: EntityManager;
614
+ const Position = component('HPos', 'f32', ['x', 'y']);
615
+ const Velocity = component('HVel', 'f32', ['vx', 'vy']);
616
+ const Health = component('HHealth', 'f32', ['hp']);
617
+
618
+ beforeEach(() => {
619
+ em = createEntityManager();
620
+ });
621
+
622
+ it('onAdd fires after flushHooks with correct entity IDs', () => {
623
+ const added: number[] = [];
624
+ em.onAdd(Position, (id) => added.push(id));
625
+
626
+ const a = em.createEntity();
627
+ em.addComponent(a, Position, { x: 1, y: 2 });
628
+ const b = em.createEntity();
629
+ em.addComponent(b, Position, { x: 3, y: 4 });
630
+
631
+ assert.deepEqual(added, []);
632
+ em.flushHooks();
633
+ assert.deepEqual(added, [a, b]);
634
+ });
635
+
636
+ it('onRemove fires after flushHooks on removeComponent', () => {
637
+ const removed: number[] = [];
638
+ em.onRemove(Position, (id) => removed.push(id));
639
+
640
+ const id = em.createEntity();
641
+ em.addComponent(id, Position, { x: 1, y: 2 });
642
+ em.removeComponent(id, Position);
643
+
644
+ assert.deepEqual(removed, []);
645
+ em.flushHooks();
646
+ assert.deepEqual(removed, [id]);
647
+ });
648
+
649
+ it('onRemove fires for each component on destroyEntity', () => {
650
+ const removedPos: number[] = [];
651
+ const removedVel: number[] = [];
652
+ em.onRemove(Position, (id) => removedPos.push(id));
653
+ em.onRemove(Velocity, (id) => removedVel.push(id));
654
+
655
+ const id = em.createEntityWith(Position, { x: 1, y: 2 }, Velocity, { vx: 3, vy: 4 });
656
+ em.destroyEntity(id);
657
+ em.flushHooks();
658
+
659
+ assert.deepEqual(removedPos, [id]);
660
+ assert.deepEqual(removedVel, [id]);
661
+ });
662
+
663
+ it('createEntityWith triggers onAdd for all component types', () => {
664
+ const addedPos: number[] = [];
665
+ const addedVel: number[] = [];
666
+ em.onAdd(Position, (id) => addedPos.push(id));
667
+ em.onAdd(Velocity, (id) => addedVel.push(id));
668
+
669
+ const id = em.createEntityWith(Position, { x: 1, y: 2 }, Velocity, { vx: 3, vy: 4 });
670
+ em.flushHooks();
671
+
672
+ assert.deepEqual(addedPos, [id]);
673
+ assert.deepEqual(addedVel, [id]);
674
+ });
675
+
676
+ it('callbacks do not fire before flushHooks (deferred)', () => {
677
+ const added: number[] = [];
678
+ em.onAdd(Position, (id) => added.push(id));
679
+
680
+ const id = em.createEntity();
681
+ em.addComponent(id, Position, { x: 1, y: 2 });
682
+
683
+ assert.deepEqual(added, []);
684
+ });
685
+
686
+ it('unsubscribe prevents callback from firing', () => {
687
+ const added: number[] = [];
688
+ const unsub = em.onAdd(Position, (id) => added.push(id));
689
+
690
+ const a = em.createEntity();
691
+ em.addComponent(a, Position, { x: 1, y: 2 });
692
+ unsub();
693
+ em.flushHooks();
694
+
695
+ assert.deepEqual(added, []);
696
+ });
697
+
698
+ it('overwrite (addComponent with existing component) does not trigger onAdd', () => {
699
+ const added: number[] = [];
700
+ em.onAdd(Position, (id) => added.push(id));
701
+
702
+ const id = em.createEntity();
703
+ em.addComponent(id, Position, { x: 1, y: 2 });
704
+ em.flushHooks();
705
+ added.length = 0;
706
+
707
+ em.addComponent(id, Position, { x: 99, y: 88 });
708
+ em.flushHooks();
709
+
710
+ assert.deepEqual(added, []);
711
+ });
712
+
713
+ it('multiple callbacks on the same component', () => {
714
+ const added1: number[] = [];
715
+ const added2: number[] = [];
716
+ em.onAdd(Position, (id) => added1.push(id));
717
+ em.onAdd(Position, (id) => added2.push(id));
718
+
719
+ const id = em.createEntity();
720
+ em.addComponent(id, Position, { x: 1, y: 2 });
721
+ em.flushHooks();
722
+
723
+ assert.deepEqual(added1, [id]);
724
+ assert.deepEqual(added2, [id]);
725
+ });
726
+
727
+ it('flushHooks is a no-op when no hooks registered', () => {
728
+ const id = em.createEntity();
729
+ em.addComponent(id, Position, { x: 1, y: 2 });
730
+ em.flushHooks();
731
+ });
732
+
733
+ it('addComponent migration triggers onAdd', () => {
734
+ const added: number[] = [];
735
+ em.onAdd(Velocity, (id) => added.push(id));
736
+
737
+ const id = em.createEntity();
738
+ em.addComponent(id, Position, { x: 1, y: 2 });
739
+ em.addComponent(id, Velocity, { vx: 1, vy: 1 });
740
+ em.flushHooks();
741
+
742
+ assert.deepEqual(added, [id]);
743
+ });
744
+
745
+ it('pending arrays are cleared after flushHooks', () => {
746
+ const added: number[] = [];
747
+ em.onAdd(Position, (id) => added.push(id));
748
+
749
+ const a = em.createEntity();
750
+ em.addComponent(a, Position, { x: 1, y: 2 });
751
+ em.flushHooks();
752
+ assert.deepEqual(added, [a]);
753
+
754
+ em.flushHooks();
755
+ assert.deepEqual(added, [a]);
756
+ });
757
+ });