archetype-ecs 1.0.0 → 1.2.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,89 +1,91 @@
1
- import { describe, test, expect, beforeEach } from '@jest/globals';
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
2
3
  import { createEntityManager } from '../src/EntityManager.js';
4
+ import { component } from '../src/index.js';
3
5
 
4
6
  describe('EntityManager', () => {
5
7
  let em;
6
- const Position = Symbol('Position');
7
- const Velocity = Symbol('Velocity');
8
- const Health = Symbol('Health');
8
+ const Position = component('Position', 'f32', ['x', 'y']);
9
+ const Velocity = component('Velocity', 'f32', ['vx', 'vy']);
10
+ const Health = component('Health', 'f32', ['hp']);
9
11
 
10
12
  beforeEach(() => {
11
13
  em = createEntityManager();
12
14
  });
13
15
 
14
16
  describe('createEntity / destroyEntity', () => {
15
- test('creates entities with incrementing ids', () => {
17
+ it('creates entities with incrementing ids', () => {
16
18
  const a = em.createEntity();
17
19
  const b = em.createEntity();
18
- expect(b).toBe(a + 1);
20
+ assert.equal(b, a + 1);
19
21
  });
20
22
 
21
- test('destroyEntity removes entity', () => {
23
+ it('destroyEntity removes entity', () => {
22
24
  const id = em.createEntity();
23
25
  em.addComponent(id, Position, { x: 0, y: 0 });
24
26
  em.destroyEntity(id);
25
- expect(em.getAllEntities()).toEqual([]);
26
- expect(em.getComponent(id, Position)).toBeUndefined();
27
+ assert.deepEqual(em.getAllEntities(), []);
28
+ assert.equal(em.getComponent(id, Position), undefined);
27
29
  });
28
30
  });
29
31
 
30
32
  describe('addComponent / getComponent / hasComponent', () => {
31
- test('adds and retrieves a component', () => {
33
+ it('adds and retrieves a component', () => {
32
34
  const id = em.createEntity();
33
35
  em.addComponent(id, Position, { x: 1, y: 2 });
34
- expect(em.getComponent(id, Position)).toEqual({ x: 1, y: 2 });
35
- expect(em.hasComponent(id, Position)).toBe(true);
36
+ assert.deepEqual(em.getComponent(id, Position), { x: 1, y: 2 });
37
+ assert.equal(em.hasComponent(id, Position), true);
36
38
  });
37
39
 
38
- test('returns undefined for missing component', () => {
40
+ it('returns undefined for missing component', () => {
39
41
  const id = em.createEntity();
40
- expect(em.getComponent(id, Position)).toBeUndefined();
41
- expect(em.hasComponent(id, Position)).toBe(false);
42
+ assert.equal(em.getComponent(id, Position), undefined);
43
+ assert.equal(em.hasComponent(id, Position), false);
42
44
  });
43
45
 
44
- test('overwrites component data on duplicate add', () => {
46
+ it('overwrites component data on duplicate add', () => {
45
47
  const id = em.createEntity();
46
48
  em.addComponent(id, Position, { x: 1, y: 2 });
47
49
  em.addComponent(id, Position, { x: 10, y: 20 });
48
- expect(em.getComponent(id, Position)).toEqual({ x: 10, y: 20 });
50
+ assert.deepEqual(em.getComponent(id, Position), { x: 10, y: 20 });
49
51
  });
50
52
 
51
- test('adds multiple component types', () => {
53
+ it('adds multiple component types', () => {
52
54
  const id = em.createEntity();
53
55
  em.addComponent(id, Position, { x: 0, y: 0 });
54
56
  em.addComponent(id, Velocity, { vx: 1, vy: 1 });
55
- expect(em.getComponent(id, Position)).toEqual({ x: 0, y: 0 });
56
- expect(em.getComponent(id, Velocity)).toEqual({ vx: 1, vy: 1 });
57
+ assert.deepEqual(em.getComponent(id, Position), { x: 0, y: 0 });
58
+ assert.deepEqual(em.getComponent(id, Velocity), { vx: 1, vy: 1 });
57
59
  });
58
60
  });
59
61
 
60
62
  describe('removeComponent', () => {
61
- test('removes a component', () => {
63
+ it('removes a component', () => {
62
64
  const id = em.createEntity();
63
65
  em.addComponent(id, Position, { x: 1, y: 2 });
64
66
  em.addComponent(id, Velocity, { vx: 1, vy: 1 });
65
67
  em.removeComponent(id, Position);
66
- expect(em.hasComponent(id, Position)).toBe(false);
67
- expect(em.hasComponent(id, Velocity)).toBe(true);
68
+ assert.equal(em.hasComponent(id, Position), false);
69
+ assert.equal(em.hasComponent(id, Velocity), true);
68
70
  });
69
71
 
70
- test('removing last component leaves entity alive but without archetype', () => {
72
+ it('removing last component leaves entity alive but without archetype', () => {
71
73
  const id = em.createEntity();
72
74
  em.addComponent(id, Position, { x: 1, y: 2 });
73
75
  em.removeComponent(id, Position);
74
- expect(em.getAllEntities()).toContain(id);
75
- expect(em.hasComponent(id, Position)).toBe(false);
76
+ assert.ok(em.getAllEntities().includes(id));
77
+ assert.equal(em.hasComponent(id, Position), false);
76
78
  });
77
79
 
78
- test('removing non-existent component is a no-op', () => {
80
+ it('removing non-existent component is a no-op', () => {
79
81
  const id = em.createEntity();
80
- em.removeComponent(id, Position); // no-op
81
- expect(em.getAllEntities()).toContain(id);
82
+ em.removeComponent(id, Position);
83
+ assert.ok(em.getAllEntities().includes(id));
82
84
  });
83
85
  });
84
86
 
85
87
  describe('query', () => {
86
- test('returns entities matching component types', () => {
88
+ it('returns entities matching component types', () => {
87
89
  const a = em.createEntity();
88
90
  em.addComponent(a, Position, { x: 0, y: 0 });
89
91
  em.addComponent(a, Velocity, { vx: 1, vy: 1 });
@@ -92,11 +94,11 @@ describe('EntityManager', () => {
92
94
  em.addComponent(b, Position, { x: 5, y: 5 });
93
95
 
94
96
  const result = em.query([Position, Velocity]);
95
- expect(result).toContain(a);
96
- expect(result).not.toContain(b);
97
+ assert.ok(result.includes(a));
98
+ assert.ok(!result.includes(b));
97
99
  });
98
100
 
99
- test('exclude types filters out entities', () => {
101
+ it('exclude types filters out entities', () => {
100
102
  const a = em.createEntity();
101
103
  em.addComponent(a, Position, { x: 0, y: 0 });
102
104
 
@@ -105,25 +107,21 @@ describe('EntityManager', () => {
105
107
  em.addComponent(b, Health, { hp: 100 });
106
108
 
107
109
  const result = em.query([Position], [Health]);
108
- expect(result).toContain(a);
109
- expect(result).not.toContain(b);
110
+ assert.ok(result.includes(a));
111
+ assert.ok(!result.includes(b));
110
112
  });
111
113
  });
112
114
 
113
115
  describe('createEntityWith', () => {
114
- test('creates entity with multiple components at once', () => {
115
- const map = new Map();
116
- map.set(Position, { x: 3, y: 4 });
117
- map.set(Velocity, { vx: 1, vy: 0 });
118
-
119
- const id = em.createEntityWith(map);
120
- expect(em.getComponent(id, Position)).toEqual({ x: 3, y: 4 });
121
- expect(em.getComponent(id, Velocity)).toEqual({ vx: 1, vy: 0 });
116
+ it('creates entity with multiple components at once', () => {
117
+ const id = em.createEntityWith(Position, { x: 3, y: 4 }, Velocity, { vx: 1, vy: 0 });
118
+ assert.deepEqual(em.getComponent(id, Position), { x: 3, y: 4 });
119
+ assert.deepEqual(em.getComponent(id, Velocity), { vx: 1, vy: 0 });
122
120
  });
123
121
  });
124
122
 
125
123
  describe('count', () => {
126
- test('counts entities matching query', () => {
124
+ it('counts entities matching query', () => {
127
125
  const a = em.createEntity();
128
126
  em.addComponent(a, Position, { x: 0, y: 0 });
129
127
 
@@ -131,22 +129,20 @@ describe('EntityManager', () => {
131
129
  em.addComponent(b, Position, { x: 1, y: 1 });
132
130
  em.addComponent(b, Velocity, { vx: 1, vy: 0 });
133
131
 
134
- expect(em.count([Position])).toBe(2);
135
- expect(em.count([Position, Velocity])).toBe(1);
132
+ assert.equal(em.count([Position]), 2);
133
+ assert.equal(em.count([Position, Velocity]), 1);
136
134
  });
137
135
  });
138
136
 
139
137
  describe('serialize / deserialize', () => {
140
138
  const symbolToName = new Map([
141
- [Position, 'Position'],
142
- [Velocity, 'Velocity'],
143
- [Health, 'Health']
139
+ [Position._sym, 'Position'],
140
+ [Velocity._sym, 'Velocity'],
141
+ [Health._sym, 'Health']
144
142
  ]);
145
- const nameToSymbol = {
146
- Position, Velocity, Health
147
- };
143
+ const nameToSymbol = { Position, Velocity, Health };
148
144
 
149
- test('round-trips entities and components', () => {
145
+ it('round-trips entities and components', () => {
150
146
  const a = em.createEntity();
151
147
  em.addComponent(a, Position, { x: 1, y: 2 });
152
148
  em.addComponent(a, Velocity, { vx: 3, vy: 4 });
@@ -157,23 +153,23 @@ describe('EntityManager', () => {
157
153
  const data = em.serialize(symbolToName);
158
154
  em.deserialize(data, nameToSymbol);
159
155
 
160
- expect(em.getAllEntities().sort()).toEqual([a, b].sort());
161
- expect(em.getComponent(a, Position)).toEqual({ x: 1, y: 2 });
162
- expect(em.getComponent(a, Velocity)).toEqual({ vx: 3, vy: 4 });
163
- expect(em.getComponent(b, Position)).toEqual({ x: 5, y: 6 });
156
+ assert.deepEqual(em.getAllEntities().sort(), [a, b].sort());
157
+ assert.deepEqual(em.getComponent(a, Position), { x: 1, y: 2 });
158
+ assert.deepEqual(em.getComponent(a, Velocity), { vx: 3, vy: 4 });
159
+ assert.deepEqual(em.getComponent(b, Position), { x: 5, y: 6 });
164
160
  });
165
161
 
166
- test('strip components excludes component data but keeps entity', () => {
162
+ it('strip components excludes component data but keeps entity', () => {
167
163
  const a = em.createEntity();
168
164
  em.addComponent(a, Position, { x: 1, y: 2 });
169
165
  em.addComponent(a, Velocity, { vx: 3, vy: 4 });
170
166
 
171
167
  const data = em.serialize(symbolToName, [Velocity]);
172
- expect(data.components['Velocity']).toBeUndefined();
173
- expect(data.components['Position']).toBeDefined();
168
+ assert.equal(data.components['Velocity'], undefined);
169
+ assert.notEqual(data.components['Position'], undefined);
174
170
  });
175
171
 
176
- test('skip entities with component excludes entire entity', () => {
172
+ it('skip entities with component excludes entire entity', () => {
177
173
  const a = em.createEntity();
178
174
  em.addComponent(a, Position, { x: 1, y: 2 });
179
175
 
@@ -182,43 +178,312 @@ describe('EntityManager', () => {
182
178
  em.addComponent(b, Health, { hp: 100 });
183
179
 
184
180
  const data = em.serialize(symbolToName, [], [Health]);
185
- expect(data.entities).toContain(a);
186
- expect(data.entities).not.toContain(b);
181
+ assert.ok(data.entities.includes(a));
182
+ assert.ok(!data.entities.includes(b));
187
183
  });
188
184
 
189
- test('custom serializers are used when provided', () => {
185
+ it('custom serializers are used when provided', () => {
186
+ const Meta = component('Meta', { x: 'f32', y: 'f32', secret: 'i32' });
187
+ const metaSymbolToName = new Map([...symbolToName, [Meta._sym, 'Meta']]);
188
+
190
189
  const a = em.createEntity();
191
- em.addComponent(a, Position, { x: 1, y: 2, _internal: 'secret' });
190
+ em.addComponent(a, Meta, { x: 1, y: 2, secret: 42 });
192
191
 
193
192
  const serializers = new Map([
194
- ['Position', (data) => ({ x: data.x, y: data.y })]
193
+ ['Meta', (data) => ({ x: data.x, y: data.y })]
195
194
  ]);
196
195
 
197
- const result = em.serialize(symbolToName, [], [], { serializers });
198
- expect(result.components['Position'][a]).toEqual({ x: 1, y: 2 });
199
- expect(result.components['Position'][a]._internal).toBeUndefined();
196
+ const result = em.serialize(metaSymbolToName, [], [], { serializers });
197
+ assert.deepEqual(result.components['Meta'][a], { x: 1, y: 2 });
198
+ assert.equal(result.components['Meta'][a].secret, undefined);
200
199
  });
201
200
 
202
- test('custom deserializers are used when provided', () => {
201
+ it('custom deserializers are used when provided', () => {
202
+ const Meta = component('Meta2', { x: 'f32', y: 'f32' });
203
+ const metaSymbolToName = new Map([...symbolToName, [Meta._sym, 'Meta2']]);
204
+ const metaNameToSymbol = { ...nameToSymbol, Meta2: Meta };
205
+
203
206
  const a = em.createEntity();
204
- em.addComponent(a, Position, { x: 1, y: 2 });
207
+ em.addComponent(a, Meta, { x: 1, y: 2 });
205
208
 
206
- const data = em.serialize(symbolToName);
209
+ const data = em.serialize(metaSymbolToName);
207
210
 
208
211
  const deserializers = new Map([
209
- ['Position', (compData) => ({ ...compData, restored: true })]
212
+ ['Meta2', (compData) => ({ ...compData, restored: true })]
210
213
  ]);
211
214
 
212
- em.deserialize(data, nameToSymbol, { deserializers });
213
- expect(em.getComponent(a, Position)).toEqual({ x: 1, y: 2, restored: true });
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);
214
219
  });
215
220
 
216
- test('deserialize clears previous state', () => {
221
+ it('deserialize clears previous state', () => {
217
222
  const a = em.createEntity();
218
223
  em.addComponent(a, Position, { x: 1, y: 2 });
219
224
 
220
225
  em.deserialize({ nextId: 1, entities: [], components: {} }, nameToSymbol);
221
- expect(em.getAllEntities()).toEqual([]);
226
+ assert.deepEqual(em.getAllEntities(), []);
227
+ });
228
+ });
229
+ });
230
+
231
+ describe('Typed Components (SoA)', () => {
232
+ let em;
233
+
234
+ beforeEach(() => {
235
+ em = createEntityManager();
236
+ });
237
+
238
+ it('typed component round-trip (add/get)', () => {
239
+ const Pos = component('Pos', 'f32', ['x', 'y']);
240
+ const id = em.createEntity();
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);
245
+ });
246
+
247
+ it('growth past initial capacity (>64 entities)', () => {
248
+ const Pos = component('PosGrow', 'f32', ['x', 'y']);
249
+ const ids = [];
250
+ for (let i = 0; i < 100; i++) {
251
+ const id = em.createEntity();
252
+ em.addComponent(id, Pos, { x: i, y: i * 2 });
253
+ ids.push(id);
254
+ }
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);
259
+ }
260
+ });
261
+
262
+ it('swap-remove preserves typed data', () => {
263
+ const Pos = component('PosSwap', 'f32', ['x', 'y']);
264
+ const a = em.createEntity();
265
+ const b = em.createEntity();
266
+ const c = em.createEntity();
267
+ em.addComponent(a, Pos, { x: 1, y: 2 });
268
+ em.addComponent(b, Pos, { x: 3, y: 4 });
269
+ em.addComponent(c, Pos, { x: 5, y: 6 });
270
+
271
+ em.destroyEntity(a);
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);
280
+ });
281
+
282
+ it('typed + tag on same entity', () => {
283
+ const Pos = component('PosMixed', 'f32', ['x', 'y']);
284
+ const Tag = component('Tag');
285
+ const id = em.createEntity();
286
+ em.addComponent(id, Pos, { x: 10, y: 20 });
287
+ em.addComponent(id, Tag, {});
288
+
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
294
+ assert.equal(em.getComponent(id, Tag), undefined);
295
+ assert.equal(em.hasComponent(id, Tag), true);
296
+ });
297
+
298
+ it('forEach raw field access and mutation', () => {
299
+ const Pos = component('PosLoop', 'f32', ['x', 'y']);
300
+ const Vel = component('VelLoop', 'f32', ['vx', 'vy']);
301
+
302
+ for (let i = 0; i < 10; i++) {
303
+ const id = em.createEntity();
304
+ em.addComponent(id, Pos, { x: i, y: 0 });
305
+ em.addComponent(id, Vel, { vx: 1, vy: 2 });
306
+ }
307
+
308
+ 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);
313
+ for (let i = 0; i < arch.count; i++) {
314
+ px[i] += vx[i];
315
+ py[i] += vy[i];
316
+ }
317
+ });
318
+
319
+ const ids = em.query([Pos, Vel]);
320
+ 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);
324
+ }
325
+ });
326
+
327
+ it('serialize/deserialize round-trip with typed components', () => {
328
+ const Pos = component('PosSer', 'f32', ['x', 'y']);
329
+ const symbolToName = new Map([[Pos._sym, 'PosSer']]);
330
+ const nameToSymbol = { PosSer: Pos };
331
+
332
+ const a = em.createEntity();
333
+ em.addComponent(a, Pos, { x: 1.5, y: 2.5 });
334
+ const b = em.createEntity();
335
+ em.addComponent(b, Pos, { x: 3.5, y: 4.5 });
336
+
337
+ const data = em.serialize(symbolToName);
338
+ em.deserialize(data, nameToSymbol);
339
+
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);
347
+ });
348
+
349
+ it('archetype migration with typed components', () => {
350
+ const Pos = component('PosMig', 'f32', ['x', 'y']);
351
+ const Vel = component('VelMig', 'f32', ['vx', 'vy']);
352
+
353
+ const id = em.createEntity();
354
+ em.addComponent(id, Pos, { x: 5, y: 10 });
355
+ em.addComponent(id, Vel, { vx: 1, vy: 2 });
356
+
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);
364
+
365
+ 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);
369
+ assert.equal(em.hasComponent(id, Vel), false);
370
+ });
371
+
372
+ it('overwrite typed component data in-place', () => {
373
+ const Pos = component('PosOw', 'f32', ['x', 'y']);
374
+ const id = em.createEntity();
375
+ em.addComponent(id, Pos, { x: 1, y: 2 });
376
+ 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);
380
+ });
381
+
382
+ it('get/set for zero-allocation field access', () => {
383
+ const Pos = component('PosGS', 'f32', ['x', 'y']);
384
+ const id = em.createEntity();
385
+ 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);
391
+ });
392
+
393
+ it('get returns undefined for missing entity/component', () => {
394
+ const Pos = component('PosGFM', 'f32', ['x', 'y']);
395
+ assert.equal(em.get(999, Pos.x), undefined);
396
+ const id = em.createEntity();
397
+ assert.equal(em.get(id, Pos.x), undefined);
398
+ });
399
+
400
+ it('forEach field returns undefined for tag component', () => {
401
+ const Tag = component('TagFE');
402
+ const Pos = component('PosFE', 'f32', ['x', 'y']);
403
+ const id = em.createEntity();
404
+ em.addComponent(id, Pos, { x: 1, y: 2 });
405
+ em.addComponent(id, Tag, {});
406
+
407
+ em.forEach([Pos, Tag], (arch) => {
408
+ assert.ok(arch.field(Pos.x) instanceof Float32Array);
409
+ });
410
+ });
411
+
412
+ it('string component round-trip (add/get/set)', () => {
413
+ const Name = component('NameRT', { name: 'string', title: 'string' });
414
+ const id = em.createEntity();
415
+ em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
416
+
417
+ assert.equal(em.get(id, Name.name), 'Hero');
418
+ assert.equal(em.get(id, Name.title), 'Sir');
419
+
420
+ em.set(id, Name.name, 'Villain');
421
+ assert.equal(em.get(id, Name.name), 'Villain');
422
+
423
+ const obj = em.getComponent(id, Name);
424
+ assert.deepEqual(obj, { name: 'Villain', title: 'Sir' });
425
+ });
426
+
427
+ it('string component short form', () => {
428
+ const Label = component('LabelSF', 'string', ['text', 'color']);
429
+ const id = em.createEntity();
430
+ em.addComponent(id, Label, { text: 'hello', color: 'red' });
431
+
432
+ assert.equal(em.get(id, Label.text), 'hello');
433
+ assert.equal(em.get(id, Label.color), 'red');
434
+ });
435
+
436
+ it('string component growth past capacity', () => {
437
+ const Name = component('NameGrow', 'string', ['value']);
438
+ for (let i = 0; i < 100; i++) {
439
+ const id = em.createEntity();
440
+ em.addComponent(id, Name, { value: `entity_${i}` });
441
+ }
442
+ const ids = em.query([Name]);
443
+ 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');
446
+ });
447
+
448
+ it('string component swap-remove preserves data', () => {
449
+ const Name = component('NameSwap', 'string', ['value']);
450
+ const a = em.createEntity();
451
+ const b = em.createEntity();
452
+ const c = em.createEntity();
453
+ em.addComponent(a, Name, { value: 'aaa' });
454
+ em.addComponent(b, Name, { value: 'bbb' });
455
+ em.addComponent(c, Name, { value: 'ccc' });
456
+
457
+ em.destroyEntity(a);
458
+ assert.equal(em.get(b, Name.value), 'bbb');
459
+ assert.equal(em.get(c, Name.value), 'ccc');
460
+ });
461
+
462
+ it('mixed string + numeric fields in one component', () => {
463
+ const Item = component('Item', { name: 'string', weight: 'f32' });
464
+ const id = em.createEntity();
465
+ em.addComponent(id, Item, { name: 'Sword', weight: 3.5 });
466
+
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);
473
+ });
474
+
475
+ it('string component forEach field access', () => {
476
+ const Name = component('NameFE', 'string', ['value']);
477
+ for (let i = 0; i < 5; i++) {
478
+ const id = em.createEntity();
479
+ em.addComponent(id, Name, { value: `e${i}` });
480
+ }
481
+
482
+ em.forEach([Name], (arch) => {
483
+ const values = arch.field(Name.value);
484
+ assert.ok(Array.isArray(values));
485
+ assert.equal(values[0], 'e0');
486
+ assert.equal(values[4], 'e4');
222
487
  });
223
488
  });
224
489
  });
package/tests/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ // Compile-time type tests — run with: npx tsc --noEmit
2
+ // These tests validate TS generics flow correctly through the API.
3
+ // No runtime execution needed; if this file compiles, the types are correct.
4
+
5
+ import { createEntityManager, component, type ComponentDef, type FieldRef, type EntityId } from '../src/index.js';
6
+
7
+ // --- component() infers schema fields ---
8
+ const Position = component('Position', 'f32', ['x', 'y']);
9
+ const Velocity = component('Velocity', { vx: 'f32', vy: 'f32' });
10
+ const Tag = component('Tag');
11
+ const Name = component('Name', { name: 'string', title: 'string' });
12
+ const Label = component('Label', 'string', ['text', 'color']);
13
+
14
+ // Position should be ComponentDef<{ x: number, y: number }>
15
+ type AssertPosition = typeof Position extends ComponentDef<{ x: number; y: number }> ? true : never;
16
+ const _assertPos: AssertPosition = true;
17
+
18
+ // Position.x should be a FieldRef<number>
19
+ type AssertFieldRef = typeof Position.x extends FieldRef<number> ? true : never;
20
+ const _assertField: AssertFieldRef = true;
21
+
22
+ // Name should be ComponentDef<{ name: string, title: string }>
23
+ type AssertName = typeof Name extends ComponentDef<{ name: string; title: string }> ? true : never;
24
+ const _assertName: AssertName = true;
25
+
26
+ // Name.name should be a FieldRef<string>
27
+ type AssertNameField = typeof Name.name extends FieldRef<string> ? true : never;
28
+ const _assertNameField: AssertNameField = true;
29
+
30
+ // Label short form: ComponentDef<{ text: string, color: string }>
31
+ type AssertLabel = typeof Label extends ComponentDef<{ text: string; color: string }> ? true : never;
32
+ const _assertLabel: AssertLabel = true;
33
+
34
+ // Tag should be ComponentDef<unknown> (no schema)
35
+ type AssertTag = typeof Tag extends ComponentDef ? true : never;
36
+ const _assertTag: AssertTag = true;
37
+
38
+ // --- EntityManager typed methods ---
39
+ const em = createEntityManager();
40
+ const id: EntityId = em.createEntity();
41
+
42
+ // addComponent: accepts correct data shape
43
+ em.addComponent(id, Position, { x: 1, y: 2 });
44
+ em.addComponent(id, Velocity, { vx: 0, vy: 0 });
45
+ em.addComponent(id, Name, { name: 'Hero', title: 'Sir' });
46
+
47
+ // getComponent: returns typed object or undefined
48
+ const pos = em.getComponent(id, Position);
49
+ if (pos) {
50
+ const x: number = pos.x;
51
+ const y: number = pos.y;
52
+ }
53
+
54
+ const nameComp = em.getComponent(id, Name);
55
+ if (nameComp) {
56
+ const n: string = nameComp.name;
57
+ const t: string = nameComp.title;
58
+ }
59
+
60
+ // get/set: numeric field descriptor access
61
+ const px: number | undefined = em.get(id, Position.x);
62
+ const py: number | undefined = em.get(id, Position.y);
63
+ em.set(id, Position.x, 10);
64
+ em.set(id, Position.y, 20);
65
+
66
+ // get/set: string field descriptor access
67
+ const nameVal: string | undefined = em.get(id, Name.name);
68
+ em.set(id, Name.name, 'Villain');
69
+
70
+ // forEach + field: accepts FieldRef
71
+ em.forEach([Position, Velocity], (arch) => {
72
+ const count: number = arch.count;
73
+ const ids: EntityId[] = arch.entityIds;
74
+ const arrX = arch.field(Position.x);
75
+ const arrVx = arch.field(Velocity.vx);
76
+ });
77
+
78
+ // createEntityWith: alternating type, data
79
+ em.createEntityWith(Position, { x: 0, y: 0 }, Velocity, { vx: 1, vy: 1 });
80
+
81
+ // --- These should fail if uncommented (negative tests) ---
82
+ // @ts-expect-error 'z' does not exist on Position
83
+ em.get(id, Position.z);
84
+
85
+ // @ts-expect-error 'z' does not exist on Position
86
+ em.set(id, Position.z, 5);
87
+
88
+ // @ts-expect-error 'x' does not exist on Velocity
89
+ em.get(id, Velocity.x);
90
+
91
+ // @ts-expect-error missing 'y' in Position data
92
+ em.addComponent(id, Position, { x: 1 });
93
+
94
+ // @ts-expect-error 'z' does not exist on Position
95
+ em.forEach([Position], (arch) => { arch.field(Position.z); });
96
+
97
+ // @ts-expect-error wrong data shape for Name
98
+ em.addComponent(id, Name, { foo: 'bar' });
99
+
100
+ // @ts-expect-error number not assignable to string field
101
+ em.set(id, Name.name, 42);
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "noEmit": true,
5
+ "moduleResolution": "node",
6
+ "module": "ESNext",
7
+ "target": "ESNext"
8
+ },
9
+ "include": ["tests/types.ts"]
10
+ }