archetype-ecs 1.2.0 → 2.0.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.
@@ -1,11 +1,24 @@
1
+ export interface ProfilerEntry {
2
+ avg: number;
3
+ }
4
+
5
+ export interface Profiler {
6
+ readonly enabled: boolean;
7
+ setEnabled(value: boolean): void;
8
+ begin(): number;
9
+ end(name: string, t0: number): void;
10
+ record(name: string, ms: number): void;
11
+ getData(): Map<string, ProfilerEntry>;
12
+ }
13
+
1
14
  const EMA_ALPHA = 0.1;
2
- const data = new Map();
15
+ const data = new Map<string, ProfilerEntry>();
3
16
  let enabled = false;
4
17
 
5
- export const profiler = {
18
+ export const profiler: Profiler = {
6
19
  get enabled() { return enabled; },
7
20
 
8
- setEnabled(value) {
21
+ setEnabled(value: boolean) {
9
22
  enabled = value;
10
23
  if (!value) data.clear();
11
24
  },
@@ -14,7 +27,7 @@ export const profiler = {
14
27
  return enabled ? performance.now() : 0;
15
28
  },
16
29
 
17
- end(name, t0) {
30
+ end(name: string, t0: number) {
18
31
  if (!enabled) return;
19
32
  const ms = performance.now() - t0;
20
33
  const entry = data.get(name);
@@ -25,7 +38,7 @@ export const profiler = {
25
38
  }
26
39
  },
27
40
 
28
- record(name, ms) {
41
+ record(name: string, ms: number) {
29
42
  if (!enabled) return;
30
43
  const entry = data.get(name);
31
44
  if (entry) {
package/src/System.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type { ComponentDef } from './ComponentRegistry.js';
2
+ import type { EntityId, EntityManager, ArchetypeView } from './EntityManager.js';
3
+
4
+ // ── Decorators (TC39 Stage 3) ────────────────────────────
5
+
6
+ export function OnAdded(...types: ComponentDef[]) {
7
+ return function (_method: Function, context: ClassMethodDecoratorContext) {
8
+ context.addInitializer(function () {
9
+ const self = this as unknown as System;
10
+ self._registerHook('add', types, (self as any)[context.name].bind(self));
11
+ });
12
+ };
13
+ }
14
+
15
+ export function OnRemoved(...types: ComponentDef[]) {
16
+ return function (_method: Function, context: ClassMethodDecoratorContext) {
17
+ context.addInitializer(function () {
18
+ const self = this as unknown as System;
19
+ self._registerHook('remove', types, (self as any)[context.name].bind(self));
20
+ });
21
+ };
22
+ }
23
+
24
+ // ── Base class ───────────────────────────────────────────
25
+
26
+ interface Hook {
27
+ buffer: Set<EntityId>;
28
+ handler: (id: EntityId) => void;
29
+ }
30
+
31
+ export class System {
32
+ em: EntityManager;
33
+ _unsubs: (() => void)[] = [];
34
+ _hooks: Hook[] = [];
35
+
36
+ constructor(em: EntityManager) {
37
+ this.em = em;
38
+ }
39
+
40
+ _registerHook(kind: 'add' | 'remove', types: ComponentDef[], handler: (id: EntityId) => void): void {
41
+ const buffer = new Set<EntityId>();
42
+
43
+ if (kind === 'add') {
44
+ for (const comp of types) {
45
+ const unsub = this.em.onAdd(comp, (id: EntityId) => {
46
+ for (let i = 0; i < types.length; i++) {
47
+ if (!this.em.hasComponent(id, types[i])) return;
48
+ }
49
+ buffer.add(id);
50
+ });
51
+ this._unsubs.push(unsub);
52
+ }
53
+ } else {
54
+ for (const comp of types) {
55
+ const unsub = this.em.onRemove(comp, (id: EntityId) => buffer.add(id));
56
+ this._unsubs.push(unsub);
57
+ }
58
+ }
59
+
60
+ this._hooks.push({ buffer, handler });
61
+ }
62
+
63
+ forEach(types: ComponentDef[], callback: (view: ArchetypeView) => void, exclude?: ComponentDef[]): void {
64
+ this.em.forEach(types, callback, exclude);
65
+ }
66
+
67
+ tick?(): void;
68
+
69
+ run(): void {
70
+ for (const hook of this._hooks) {
71
+ for (const id of hook.buffer) hook.handler(id);
72
+ hook.buffer.clear();
73
+ }
74
+ if (this.tick) this.tick();
75
+ }
76
+
77
+ dispose(): void {
78
+ for (const unsub of this._unsubs) unsub();
79
+ this._unsubs.length = 0;
80
+ this._hooks.length = 0;
81
+ }
82
+ }
83
+
84
+ // ── Functional API ───────────────────────────────────────
85
+
86
+ type HookCallback = (entityId: EntityId) => void;
87
+
88
+ export interface SystemContext {
89
+ onAdded(...args: [...ComponentDef[], HookCallback]): void;
90
+ onRemoved(...args: [...ComponentDef[], HookCallback]): void;
91
+ forEach(types: ComponentDef[], callback: (view: ArchetypeView) => void, exclude?: ComponentDef[]): void;
92
+ }
93
+
94
+ export type FunctionalSystemConstructor = (sys: SystemContext) => (() => void) | void;
95
+
96
+ interface FunctionalHook {
97
+ unsubs: (() => void)[];
98
+ buffer: Set<EntityId>;
99
+ callback: HookCallback;
100
+ }
101
+
102
+ export interface FunctionalSystem {
103
+ (): void;
104
+ dispose(): void;
105
+ }
106
+
107
+ export function createSystem(em: EntityManager, constructor: FunctionalSystemConstructor): FunctionalSystem {
108
+ const hooks: FunctionalHook[] = [];
109
+
110
+ const sys: SystemContext = {
111
+ onAdded(...args: any[]) {
112
+ const callback = args[args.length - 1] as HookCallback;
113
+ const types = args.slice(0, -1) as ComponentDef[];
114
+ const buffer = new Set<EntityId>();
115
+ const unsubs: (() => void)[] = [];
116
+
117
+ for (const comp of types) {
118
+ const unsub = em.onAdd(comp, (id: EntityId) => {
119
+ for (let i = 0; i < types.length; i++) {
120
+ if (!em.hasComponent(id, types[i])) return;
121
+ }
122
+ buffer.add(id);
123
+ });
124
+ unsubs.push(unsub);
125
+ }
126
+
127
+ hooks.push({ unsubs, buffer, callback });
128
+ },
129
+
130
+ onRemoved(...args: any[]) {
131
+ const callback = args[args.length - 1] as HookCallback;
132
+ const types = args.slice(0, -1) as ComponentDef[];
133
+ const buffer = new Set<EntityId>();
134
+ const unsubs: (() => void)[] = [];
135
+
136
+ for (const comp of types) {
137
+ const unsub = em.onRemove(comp, (id: EntityId) => {
138
+ buffer.add(id);
139
+ });
140
+ unsubs.push(unsub);
141
+ }
142
+
143
+ hooks.push({ unsubs, buffer, callback });
144
+ },
145
+
146
+ forEach(types: ComponentDef[], callback: (view: ArchetypeView) => void, exclude?: ComponentDef[]) {
147
+ em.forEach(types, callback, exclude);
148
+ },
149
+ };
150
+
151
+ const tick = constructor(sys);
152
+
153
+ function system() {
154
+ for (const hook of hooks) {
155
+ for (const id of hook.buffer) hook.callback(id);
156
+ hook.buffer.clear();
157
+ }
158
+ if (tick) tick();
159
+ }
160
+
161
+ system.dispose = function () {
162
+ for (const hook of hooks) {
163
+ for (const unsub of hook.unsubs) unsub();
164
+ }
165
+ hooks.length = 0;
166
+ };
167
+
168
+ return system;
169
+ }
170
+
171
+ // ── Activator ────────────────────────────────────────────
172
+
173
+ interface Runnable {
174
+ run(): void;
175
+ dispose(): void;
176
+ }
177
+
178
+ export interface Pipeline {
179
+ (): void;
180
+ dispose(): void;
181
+ }
182
+
183
+ export function createSystems(em: EntityManager, entries: (FunctionalSystemConstructor | (new (em: EntityManager) => System))[]): Pipeline {
184
+ const systems: Runnable[] = entries.map(Entry => {
185
+ if ((Entry as any).prototype instanceof System) {
186
+ return new (Entry as new (em: EntityManager) => System)(em);
187
+ }
188
+ const sys = createSystem(em, Entry as FunctionalSystemConstructor);
189
+ return { run: sys, dispose: sys.dispose };
190
+ });
191
+
192
+ function pipeline() {
193
+ for (let i = 0; i < systems.length; i++) systems[i].run();
194
+ }
195
+
196
+ pipeline.dispose = function () {
197
+ for (let i = 0; i < systems.length; i++) systems[i].dispose();
198
+ };
199
+
200
+ return pipeline;
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export { createEntityManager } from './EntityManager.js';
2
+ export type { EntityId, FieldRef, ArchetypeView, EntityManager, SerializedData } from './EntityManager.js';
3
+ export { createSystem, createSystems, System, OnAdded, OnRemoved } from './System.js';
4
+ export type { SystemContext, FunctionalSystemConstructor, FunctionalSystem, Pipeline } from './System.js';
5
+ export { profiler } from './Profiler.js';
6
+ export type { Profiler, ProfilerEntry } from './Profiler.js';
7
+ export { TYPED, componentSchemas, parseTypeSpec } from './ComponentRegistry.js';
8
+ export type { ComponentDef, TypeSpec } from './ComponentRegistry.js';
9
+
10
+ import { parseTypeSpec, componentSchemas, type ComponentDef, type TypeSpec } from './ComponentRegistry.js';
11
+
12
+ export function component(name: string, typeOrSchema?: string | Record<string, string>, fields?: string[]): ComponentDef {
13
+ const sym = Symbol(name);
14
+ const comp: any = { _sym: sym, _name: name };
15
+
16
+ let schema: Record<string, TypeSpec> | undefined;
17
+
18
+ if (typeof typeOrSchema === 'string' && Array.isArray(fields)) {
19
+ const spec = parseTypeSpec(typeOrSchema);
20
+ schema = {};
21
+ for (const f of fields) {
22
+ schema[f] = spec;
23
+ comp[f] = { _sym: sym, _field: f };
24
+ }
25
+ } else if (typeOrSchema && typeof typeOrSchema === 'object') {
26
+ schema = {};
27
+ for (const [field, type] of Object.entries(typeOrSchema)) {
28
+ schema[field] = parseTypeSpec(type);
29
+ comp[field] = { _sym: sym, _field: field };
30
+ }
31
+ }
32
+
33
+ if (schema) {
34
+ componentSchemas.set(sym, schema);
35
+ }
36
+
37
+ return comp;
38
+ }
@@ -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';
3
+ import { createEntityManager, type EntityManager } from '../src/EntityManager.js';
4
4
  import { component } 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, any> = { Position, Velocity, Health };
144
144
 
145
145
  it('round-trips entities and components', () => {
146
146
  const a = em.createEntity();
@@ -189,27 +189,27 @@ 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 any).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, any> = { ...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: any) => any>([
212
+ ['Meta2', (compData: any) => ({ ...compData, restored: true })]
213
213
  ]);
214
214
 
215
215
  em.deserialize(data, metaNameToSymbol, { deserializers });
@@ -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();
@@ -246,7 +246,7 @@ describe('Typed Components (SoA)', () => {
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 });
@@ -272,11 +272,8 @@ describe('Typed Components (SoA)', () => {
272
272
 
273
273
  const resultB = em.getComponent(b, Pos);
274
274
  assert.ok(Math.abs(resultB.x - 3) < 0.001);
275
- assert.ok(Math.abs(resultB.y - 4) < 0.001);
276
-
277
275
  const resultC = em.getComponent(c, Pos);
278
276
  assert.ok(Math.abs(resultC.x - 5) < 0.001);
279
- assert.ok(Math.abs(resultC.y - 6) < 0.001);
280
277
  });
281
278
 
282
279
  it('typed + tag on same entity', () => {
@@ -288,9 +285,6 @@ describe('Typed Components (SoA)', () => {
288
285
 
289
286
  const pos = em.getComponent(id, Pos);
290
287
  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
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 any);
304
+ const py = arch.field(Pos.y as any);
305
+ const vx = arch.field(Vel.vx as any);
306
+ const vy = arch.field(Vel.vy as any);
313
307
  for (let i = 0; i < arch.count; i++) {
314
308
  px[i] += vx[i];
315
309
  py[i] += vy[i];
@@ -327,7 +321,7 @@ describe('Typed Components (SoA)', () => {
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, any> = { PosSer: Pos };
331
325
 
332
326
  const a = em.createEntity();
333
327
  em.addComponent(a, Pos, { x: 1.5, y: 2.5 });
@@ -339,11 +333,8 @@ describe('Typed Components (SoA)', () => {
339
333
 
340
334
  const posA = em.getComponent(a, Pos);
341
335
  assert.ok(Math.abs(posA.x - 1.5) < 0.01);
342
- assert.ok(Math.abs(posA.y - 2.5) < 0.01);
343
-
344
336
  const posB = em.getComponent(b, Pos);
345
337
  assert.ok(Math.abs(posB.x - 3.5) < 0.01);
346
- assert.ok(Math.abs(posB.y - 4.5) < 0.01);
347
338
  });
348
339
 
349
340
  it('archetype migration with typed components', () => {
@@ -356,16 +347,10 @@ describe('Typed Components (SoA)', () => {
356
347
 
357
348
  const pos = em.getComponent(id, Pos);
358
349
  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);
364
350
 
365
351
  em.removeComponent(id, Vel);
366
352
  const pos2 = em.getComponent(id, Pos);
367
353
  assert.ok(Math.abs(pos2.x - 5) < 0.001);
368
- assert.ok(Math.abs(pos2.y - 10) < 0.001);
369
354
  assert.equal(em.hasComponent(id, Vel), false);
370
355
  });
371
356
 
@@ -376,25 +361,22 @@ describe('Typed Components (SoA)', () => {
376
361
  em.addComponent(id, Pos, { x: 99, y: 88 });
377
362
  const result = em.getComponent(id, Pos);
378
363
  assert.ok(Math.abs(result.x - 99) < 0.001);
379
- assert.ok(Math.abs(result.y - 88) < 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);
388
- 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);
370
+ assert.ok(Math.abs(em.get(id, Pos.x as any) - 3.5) < 0.001);
371
+ em.set(id, Pos.x as any, 42);
372
+ assert.ok(Math.abs(em.get(id, Pos.x as any) - 42) < 0.001);
391
373
  });
392
374
 
393
375
  it('get returns undefined for missing entity/component', () => {
394
376
  const Pos = component('PosGFM', 'f32', ['x', 'y']);
395
- assert.equal(em.get(999, Pos.x), undefined);
377
+ assert.equal(em.get(999, Pos.x as any), undefined);
396
378
  const id = em.createEntity();
397
- assert.equal(em.get(id, Pos.x), undefined);
379
+ assert.equal(em.get(id, Pos.x as any), undefined);
398
380
  });
399
381
 
400
382
  it('forEach field returns undefined for tag component', () => {
@@ -405,7 +387,7 @@ describe('Typed Components (SoA)', () => {
405
387
  em.addComponent(id, Tag, {});
406
388
 
407
389
  em.forEach([Pos, Tag], (arch) => {
408
- assert.ok(arch.field(Pos.x) instanceof Float32Array);
390
+ assert.ok(arch.field(Pos.x as any) instanceof Float32Array);
409
391
  });
410
392
  });
411
393
 
@@ -414,11 +396,11 @@ describe('Typed Components (SoA)', () => {
414
396
  const id = em.createEntity();
415
397
  em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
416
398
 
417
- assert.equal(em.get(id, Name.name), 'Hero');
418
- assert.equal(em.get(id, Name.title), 'Sir');
399
+ assert.equal(em.get(id, Name.name as any), 'Hero');
400
+ assert.equal(em.get(id, Name.title as any), 'Sir');
419
401
 
420
- em.set(id, Name.name, 'Villain');
421
- assert.equal(em.get(id, Name.name), 'Villain');
402
+ em.set(id, Name.name as any, 'Villain');
403
+ assert.equal(em.get(id, Name.name as any), 'Villain');
422
404
 
423
405
  const obj = em.getComponent(id, Name);
424
406
  assert.deepEqual(obj, { name: 'Villain', title: 'Sir' });
@@ -429,8 +411,8 @@ describe('Typed Components (SoA)', () => {
429
411
  const id = em.createEntity();
430
412
  em.addComponent(id, Label, { text: 'hello', color: 'red' });
431
413
 
432
- assert.equal(em.get(id, Label.text), 'hello');
433
- assert.equal(em.get(id, Label.color), 'red');
414
+ assert.equal(em.get(id, Label.text as any), 'hello');
415
+ assert.equal(em.get(id, Label.color as any), 'red');
434
416
  });
435
417
 
436
418
  it('string component growth past capacity', () => {
@@ -441,8 +423,8 @@ describe('Typed Components (SoA)', () => {
441
423
  }
442
424
  const ids = em.query([Name]);
443
425
  assert.equal(ids.length, 100);
444
- assert.equal(em.get(ids[0], Name.value), 'entity_0');
445
- assert.equal(em.get(ids[99], Name.value), 'entity_99');
426
+ assert.equal(em.get(ids[0], Name.value as any), 'entity_0');
427
+ assert.equal(em.get(ids[99], Name.value as any), 'entity_99');
446
428
  });
447
429
 
448
430
  it('string component swap-remove preserves data', () => {
@@ -455,8 +437,8 @@ describe('Typed Components (SoA)', () => {
455
437
  em.addComponent(c, Name, { value: 'ccc' });
456
438
 
457
439
  em.destroyEntity(a);
458
- assert.equal(em.get(b, Name.value), 'bbb');
459
- assert.equal(em.get(c, Name.value), 'ccc');
440
+ assert.equal(em.get(b, Name.value as any), 'bbb');
441
+ assert.equal(em.get(c, Name.value as any), 'ccc');
460
442
  });
461
443
 
462
444
  it('mixed string + numeric fields in one component', () => {
@@ -464,12 +446,8 @@ describe('Typed Components (SoA)', () => {
464
446
  const id = em.createEntity();
465
447
  em.addComponent(id, Item, { name: 'Sword', weight: 3.5 });
466
448
 
467
- 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);
449
+ assert.equal(em.get(id, Item.name as any), 'Sword');
450
+ assert.ok(Math.abs(em.get(id, Item.weight as any) - 3.5) < 0.01);
473
451
  });
474
452
 
475
453
  it('string component forEach field access', () => {
@@ -480,10 +458,157 @@ describe('Typed Components (SoA)', () => {
480
458
  }
481
459
 
482
460
  em.forEach([Name], (arch) => {
483
- const values = arch.field(Name.value);
461
+ const values = arch.field(Name.value as any);
484
462
  assert.ok(Array.isArray(values));
485
463
  assert.equal(values[0], 'e0');
486
464
  assert.equal(values[4], 'e4');
487
465
  });
488
466
  });
489
467
  });
468
+
469
+ describe('Deferred Hooks (onAdd / onRemove)', () => {
470
+ let em: EntityManager;
471
+ const Position = component('HPos', 'f32', ['x', 'y']);
472
+ const Velocity = component('HVel', 'f32', ['vx', 'vy']);
473
+ const Health = component('HHealth', 'f32', ['hp']);
474
+
475
+ beforeEach(() => {
476
+ em = createEntityManager();
477
+ });
478
+
479
+ it('onAdd fires after flushHooks with correct entity IDs', () => {
480
+ const added: number[] = [];
481
+ em.onAdd(Position, (id) => added.push(id));
482
+
483
+ const a = em.createEntity();
484
+ em.addComponent(a, Position, { x: 1, y: 2 });
485
+ const b = em.createEntity();
486
+ em.addComponent(b, Position, { x: 3, y: 4 });
487
+
488
+ assert.deepEqual(added, []);
489
+ em.flushHooks();
490
+ assert.deepEqual(added, [a, b]);
491
+ });
492
+
493
+ it('onRemove fires after flushHooks on removeComponent', () => {
494
+ const removed: number[] = [];
495
+ em.onRemove(Position, (id) => removed.push(id));
496
+
497
+ const id = em.createEntity();
498
+ em.addComponent(id, Position, { x: 1, y: 2 });
499
+ em.removeComponent(id, Position);
500
+
501
+ assert.deepEqual(removed, []);
502
+ em.flushHooks();
503
+ assert.deepEqual(removed, [id]);
504
+ });
505
+
506
+ it('onRemove fires for each component on destroyEntity', () => {
507
+ const removedPos: number[] = [];
508
+ const removedVel: number[] = [];
509
+ em.onRemove(Position, (id) => removedPos.push(id));
510
+ em.onRemove(Velocity, (id) => removedVel.push(id));
511
+
512
+ const id = em.createEntityWith(Position, { x: 1, y: 2 }, Velocity, { vx: 3, vy: 4 });
513
+ em.destroyEntity(id);
514
+ em.flushHooks();
515
+
516
+ assert.deepEqual(removedPos, [id]);
517
+ assert.deepEqual(removedVel, [id]);
518
+ });
519
+
520
+ it('createEntityWith triggers onAdd for all component types', () => {
521
+ const addedPos: number[] = [];
522
+ const addedVel: number[] = [];
523
+ em.onAdd(Position, (id) => addedPos.push(id));
524
+ em.onAdd(Velocity, (id) => addedVel.push(id));
525
+
526
+ const id = em.createEntityWith(Position, { x: 1, y: 2 }, Velocity, { vx: 3, vy: 4 });
527
+ em.flushHooks();
528
+
529
+ assert.deepEqual(addedPos, [id]);
530
+ assert.deepEqual(addedVel, [id]);
531
+ });
532
+
533
+ it('callbacks do not fire before flushHooks (deferred)', () => {
534
+ const added: number[] = [];
535
+ em.onAdd(Position, (id) => added.push(id));
536
+
537
+ const id = em.createEntity();
538
+ em.addComponent(id, Position, { x: 1, y: 2 });
539
+
540
+ assert.deepEqual(added, []);
541
+ });
542
+
543
+ it('unsubscribe prevents callback from firing', () => {
544
+ const added: number[] = [];
545
+ const unsub = em.onAdd(Position, (id) => added.push(id));
546
+
547
+ const a = em.createEntity();
548
+ em.addComponent(a, Position, { x: 1, y: 2 });
549
+ unsub();
550
+ em.flushHooks();
551
+
552
+ assert.deepEqual(added, []);
553
+ });
554
+
555
+ it('overwrite (addComponent with existing component) does not trigger onAdd', () => {
556
+ const added: number[] = [];
557
+ em.onAdd(Position, (id) => added.push(id));
558
+
559
+ const id = em.createEntity();
560
+ em.addComponent(id, Position, { x: 1, y: 2 });
561
+ em.flushHooks();
562
+ added.length = 0;
563
+
564
+ em.addComponent(id, Position, { x: 99, y: 88 });
565
+ em.flushHooks();
566
+
567
+ assert.deepEqual(added, []);
568
+ });
569
+
570
+ it('multiple callbacks on the same component', () => {
571
+ const added1: number[] = [];
572
+ const added2: number[] = [];
573
+ em.onAdd(Position, (id) => added1.push(id));
574
+ em.onAdd(Position, (id) => added2.push(id));
575
+
576
+ const id = em.createEntity();
577
+ em.addComponent(id, Position, { x: 1, y: 2 });
578
+ em.flushHooks();
579
+
580
+ assert.deepEqual(added1, [id]);
581
+ assert.deepEqual(added2, [id]);
582
+ });
583
+
584
+ it('flushHooks is a no-op when no hooks registered', () => {
585
+ const id = em.createEntity();
586
+ em.addComponent(id, Position, { x: 1, y: 2 });
587
+ em.flushHooks();
588
+ });
589
+
590
+ it('addComponent migration triggers onAdd', () => {
591
+ const added: number[] = [];
592
+ em.onAdd(Velocity, (id) => added.push(id));
593
+
594
+ const id = em.createEntity();
595
+ em.addComponent(id, Position, { x: 1, y: 2 });
596
+ em.addComponent(id, Velocity, { vx: 1, vy: 1 });
597
+ em.flushHooks();
598
+
599
+ assert.deepEqual(added, [id]);
600
+ });
601
+
602
+ it('pending arrays are cleared after flushHooks', () => {
603
+ const added: number[] = [];
604
+ em.onAdd(Position, (id) => added.push(id));
605
+
606
+ const a = em.createEntity();
607
+ em.addComponent(a, Position, { x: 1, y: 2 });
608
+ em.flushHooks();
609
+ assert.deepEqual(added, [a]);
610
+
611
+ em.flushHooks();
612
+ assert.deepEqual(added, [a]);
613
+ });
614
+ });