archetype-ecs 1.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.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "archetype-ecs",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight archetype-based Entity Component System",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "scripts": {
12
+ "test": "node --experimental-vm-modules node_modules/.bin/jest"
13
+ },
14
+ "keywords": ["ecs", "entity", "component", "system", "archetype", "gamedev"],
15
+ "license": "MIT",
16
+ "devDependencies": {
17
+ "jest": "^29.7.0",
18
+ "@jest/globals": "^29.7.0"
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ export function createContainer() {
2
+ const factories = new Map();
3
+ const instances = new Map();
4
+
5
+ return {
6
+ register(key, factory) {
7
+ factories.set(key, factory);
8
+ },
9
+
10
+ resolve(key) {
11
+ if (instances.has(key)) {
12
+ return instances.get(key);
13
+ }
14
+ const factory = factories.get(key);
15
+ if (!factory) {
16
+ throw new Error(`No factory registered for key: ${key}`);
17
+ }
18
+ const instance = factory(this);
19
+ instances.set(key, instance);
20
+ return instance;
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,362 @@
1
+ export function createEntityManager() {
2
+ let nextId = 1;
3
+ const allEntityIds = new Set();
4
+
5
+ // Archetype storage
6
+ const archetypes = new Map(); // key → Archetype
7
+ const entityArchetype = new Map(); // entityId → Archetype
8
+
9
+ // Query cache
10
+ let queryCacheVersion = 0;
11
+ const queryCache = new Map(); // queryKey → { version, archetypes[] }
12
+
13
+ function makeArchetypeKey(types) {
14
+ const descs = [];
15
+ for (const sym of types) {
16
+ descs.push(sym.description);
17
+ }
18
+ descs.sort();
19
+ return descs.join('|');
20
+ }
21
+
22
+ function getOrCreateArchetype(types) {
23
+ const key = makeArchetypeKey(types);
24
+ let arch = archetypes.get(key);
25
+ if (!arch) {
26
+ arch = {
27
+ key,
28
+ types: new Set(types),
29
+ entityIds: [],
30
+ components: new Map(),
31
+ entityToIndex: new Map(),
32
+ count: 0
33
+ };
34
+ for (const t of types) {
35
+ arch.components.set(t, []);
36
+ }
37
+ archetypes.set(key, arch);
38
+ queryCacheVersion++;
39
+ }
40
+ return arch;
41
+ }
42
+
43
+ function addToArchetype(arch, entityId, componentMap) {
44
+ const idx = arch.count;
45
+ arch.entityIds[idx] = entityId;
46
+ for (const t of arch.types) {
47
+ arch.components.get(t)[idx] = componentMap.get(t);
48
+ }
49
+ arch.entityToIndex.set(entityId, idx);
50
+ arch.count++;
51
+ entityArchetype.set(entityId, arch);
52
+ }
53
+
54
+ function removeFromArchetype(arch, entityId) {
55
+ const idx = arch.entityToIndex.get(entityId);
56
+ const lastIdx = arch.count - 1;
57
+
58
+ if (idx !== lastIdx) {
59
+ // Swap with last
60
+ const lastEntity = arch.entityIds[lastIdx];
61
+ arch.entityIds[idx] = lastEntity;
62
+ for (const [type, arr] of arch.components) {
63
+ arr[idx] = arr[lastIdx];
64
+ }
65
+ arch.entityToIndex.set(lastEntity, idx);
66
+ }
67
+
68
+ // Pop last
69
+ arch.entityIds.length = lastIdx;
70
+ for (const [type, arr] of arch.components) {
71
+ arr.length = lastIdx;
72
+ }
73
+ arch.entityToIndex.delete(entityId);
74
+ arch.count--;
75
+ entityArchetype.delete(entityId);
76
+ }
77
+
78
+ function getMatchingArchetypes(types, excludeTypes) {
79
+ let queryKey = makeArchetypeKey(types);
80
+ if (excludeTypes && excludeTypes.length > 0) {
81
+ queryKey += '!' + makeArchetypeKey(excludeTypes);
82
+ }
83
+ const cached = queryCache.get(queryKey);
84
+ if (cached && cached.version === queryCacheVersion) {
85
+ return cached.archetypes;
86
+ }
87
+
88
+ const matching = [];
89
+ for (const arch of archetypes.values()) {
90
+ let match = true;
91
+ for (const t of types) {
92
+ if (!arch.types.has(t)) {
93
+ match = false;
94
+ break;
95
+ }
96
+ }
97
+ if (match && excludeTypes) {
98
+ for (const t of excludeTypes) {
99
+ if (arch.types.has(t)) {
100
+ match = false;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ if (match) matching.push(arch);
106
+ }
107
+
108
+ queryCache.set(queryKey, { version: queryCacheVersion, archetypes: matching });
109
+ return matching;
110
+ }
111
+
112
+ return {
113
+ createEntity() {
114
+ const id = nextId++;
115
+ allEntityIds.add(id);
116
+ return id;
117
+ },
118
+
119
+ destroyEntity(id) {
120
+ const arch = entityArchetype.get(id);
121
+ if (arch) {
122
+ removeFromArchetype(arch, id);
123
+ }
124
+ allEntityIds.delete(id);
125
+ },
126
+
127
+ addComponent(entityId, componentName, data) {
128
+ const arch = entityArchetype.get(entityId);
129
+
130
+ if (!arch) {
131
+ // Entity has no archetype yet — create single-type archetype
132
+ const newArch = getOrCreateArchetype([componentName]);
133
+ const map = new Map();
134
+ map.set(componentName, data);
135
+ addToArchetype(newArch, entityId, map);
136
+ return;
137
+ }
138
+
139
+ if (arch.types.has(componentName)) {
140
+ // Already has this component type — just update data
141
+ const idx = arch.entityToIndex.get(entityId);
142
+ arch.components.get(componentName)[idx] = data;
143
+ return;
144
+ }
145
+
146
+ // Need to move to a new archetype with the extra type
147
+ const newTypes = [...arch.types, componentName];
148
+ const newArch = getOrCreateArchetype(newTypes);
149
+
150
+ // Collect component data from old archetype
151
+ const idx = arch.entityToIndex.get(entityId);
152
+ const map = new Map();
153
+ for (const t of arch.types) {
154
+ map.set(t, arch.components.get(t)[idx]);
155
+ }
156
+ map.set(componentName, data);
157
+
158
+ removeFromArchetype(arch, entityId);
159
+ addToArchetype(newArch, entityId, map);
160
+ },
161
+
162
+ removeComponent(entityId, componentName) {
163
+ const arch = entityArchetype.get(entityId);
164
+ if (!arch || !arch.types.has(componentName)) return;
165
+
166
+ if (arch.types.size === 1) {
167
+ // Removing last component — entity has no archetype
168
+ removeFromArchetype(arch, entityId);
169
+ return;
170
+ }
171
+
172
+ const newTypes = [];
173
+ for (const t of arch.types) {
174
+ if (t !== componentName) newTypes.push(t);
175
+ }
176
+ const newArch = getOrCreateArchetype(newTypes);
177
+
178
+ const idx = arch.entityToIndex.get(entityId);
179
+ const map = new Map();
180
+ for (const t of newTypes) {
181
+ map.set(t, arch.components.get(t)[idx]);
182
+ }
183
+
184
+ removeFromArchetype(arch, entityId);
185
+ addToArchetype(newArch, entityId, map);
186
+ },
187
+
188
+ getComponent(entityId, componentName) {
189
+ const arch = entityArchetype.get(entityId);
190
+ if (!arch) return undefined;
191
+ const arr = arch.components.get(componentName);
192
+ if (!arr) return undefined;
193
+ return arr[arch.entityToIndex.get(entityId)];
194
+ },
195
+
196
+ hasComponent(entityId, componentName) {
197
+ const arch = entityArchetype.get(entityId);
198
+ return arch ? arch.types.has(componentName) : false;
199
+ },
200
+
201
+ query(includeTypes, excludeTypes) {
202
+ const matching = getMatchingArchetypes(includeTypes, excludeTypes);
203
+ const result = [];
204
+ for (let a = 0; a < matching.length; a++) {
205
+ const arch = matching[a];
206
+ const ids = arch.entityIds;
207
+ for (let i = 0; i < arch.count; i++) {
208
+ result.push(ids[i]);
209
+ }
210
+ }
211
+ return result;
212
+ },
213
+
214
+ getAllEntities() {
215
+ return [...allEntityIds];
216
+ },
217
+
218
+ createEntityWith(componentMap) {
219
+ const id = nextId++;
220
+ allEntityIds.add(id);
221
+
222
+ const types = [...componentMap.keys()];
223
+ const arch = getOrCreateArchetype(types);
224
+ addToArchetype(arch, id, componentMap);
225
+
226
+ return id;
227
+ },
228
+
229
+ count(includeTypes, excludeTypes) {
230
+ const matching = getMatchingArchetypes(includeTypes, excludeTypes);
231
+ let total = 0;
232
+ for (let a = 0; a < matching.length; a++) {
233
+ total += matching[a].count;
234
+ }
235
+ return total;
236
+ },
237
+
238
+ serialize(symbolToName, stripComponents = [], skipEntitiesWith = [], { serializers } = {}) {
239
+ const stripSymbols = new Set(stripComponents);
240
+ const skipSymbols = new Set(skipEntitiesWith);
241
+ const skipEntityIds = new Set();
242
+
243
+ // Find entities that have any "skip entity" component — these are fully excluded
244
+ if (skipSymbols.size > 0) {
245
+ for (const arch of archetypes.values()) {
246
+ let hasSkip = false;
247
+ for (const sym of skipSymbols) {
248
+ if (arch.types.has(sym)) { hasSkip = true; break; }
249
+ }
250
+ if (!hasSkip) continue;
251
+ for (let i = 0; i < arch.count; i++) {
252
+ skipEntityIds.add(arch.entityIds[i]);
253
+ }
254
+ }
255
+ }
256
+
257
+ const serializedComponents = {};
258
+
259
+ for (const arch of archetypes.values()) {
260
+ for (const [sym, arr] of arch.components) {
261
+ if (stripSymbols.has(sym) || skipSymbols.has(sym)) continue;
262
+ const name = symbolToName.get(sym);
263
+ if (!name) continue;
264
+
265
+ if (!serializedComponents[name]) {
266
+ serializedComponents[name] = {};
267
+ }
268
+ const entries = serializedComponents[name];
269
+
270
+ const customSerializer = serializers && serializers.get(name);
271
+
272
+ for (let i = 0; i < arch.count; i++) {
273
+ const entityId = arch.entityIds[i];
274
+ if (skipEntityIds.has(entityId)) continue;
275
+ if (customSerializer) {
276
+ entries[entityId] = customSerializer(arr[i]);
277
+ } else {
278
+ entries[entityId] = structuredClone(arr[i]);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // Remove empty component groups
285
+ for (const name of Object.keys(serializedComponents)) {
286
+ if (Object.keys(serializedComponents[name]).length === 0) {
287
+ delete serializedComponents[name];
288
+ }
289
+ }
290
+
291
+ const serializedEntities = [];
292
+ for (const id of allEntityIds) {
293
+ if (!skipEntityIds.has(id)) serializedEntities.push(id);
294
+ }
295
+
296
+ return {
297
+ nextId,
298
+ entities: serializedEntities,
299
+ components: serializedComponents
300
+ };
301
+ },
302
+
303
+ deserialize(data, nameToSymbol, { deserializers } = {}) {
304
+ // Clear all state
305
+ allEntityIds.clear();
306
+ archetypes.clear();
307
+ entityArchetype.clear();
308
+ queryCache.clear();
309
+ queryCacheVersion = 0;
310
+
311
+ nextId = data.nextId;
312
+
313
+ // Build per-entity component maps
314
+ const entityComponents = new Map();
315
+
316
+ for (const id of data.entities) {
317
+ allEntityIds.add(id);
318
+ entityComponents.set(id, new Map());
319
+ }
320
+
321
+ for (const [name, store] of Object.entries(data.components)) {
322
+ const sym = nameToSymbol[name];
323
+ if (!sym) continue;
324
+
325
+ const customDeserializer = deserializers && deserializers.get(name);
326
+
327
+ for (const [entityIdStr, compData] of Object.entries(store)) {
328
+ const entityId = Number(entityIdStr);
329
+ const map = entityComponents.get(entityId);
330
+ if (!map) continue;
331
+
332
+ if (customDeserializer) {
333
+ map.set(sym, customDeserializer(compData));
334
+ } else {
335
+ map.set(sym, compData);
336
+ }
337
+ }
338
+ }
339
+
340
+ // Group by archetype key and bulk-insert
341
+ const groupedByKey = new Map();
342
+ for (const [entityId, compMap] of entityComponents) {
343
+ if (compMap.size === 0) continue; // entity with no components
344
+
345
+ const key = makeArchetypeKey([...compMap.keys()]);
346
+ if (!groupedByKey.has(key)) {
347
+ groupedByKey.set(key, []);
348
+ }
349
+ groupedByKey.get(key).push({ entityId, compMap });
350
+ }
351
+
352
+ for (const [key, entries] of groupedByKey) {
353
+ const types = [...entries[0].compMap.keys()];
354
+ const arch = getOrCreateArchetype(types);
355
+
356
+ for (const { entityId, compMap } of entries) {
357
+ addToArchetype(arch, entityId, compMap);
358
+ }
359
+ }
360
+ }
361
+ };
362
+ }
@@ -0,0 +1,29 @@
1
+ export function createEventBus() {
2
+ const listeners = new Map();
3
+
4
+ return {
5
+ on(event, callback) {
6
+ if (!listeners.has(event)) {
7
+ listeners.set(event, []);
8
+ }
9
+ listeners.get(event).push(callback);
10
+ },
11
+
12
+ off(event, callback) {
13
+ const cbs = listeners.get(event);
14
+ if (cbs) {
15
+ const idx = cbs.indexOf(callback);
16
+ if (idx !== -1) cbs.splice(idx, 1);
17
+ }
18
+ },
19
+
20
+ emit(event, data) {
21
+ const cbs = listeners.get(event);
22
+ if (cbs) {
23
+ for (const cb of cbs) {
24
+ cb(data);
25
+ }
26
+ }
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,110 @@
1
+ import { profiler } from './Profiler.js';
2
+
3
+ export function createGameLoop(systems, renderSteps, { tickRate = 20, maxFrameTime = 250 } = {}) {
4
+ let running = false;
5
+ let lastTime = 0;
6
+ let accumulator = 0;
7
+ let tickCount = 0;
8
+ let frameCount = 0;
9
+ let fpsTime = 0;
10
+ let fps = 0;
11
+ let tps = 0;
12
+ let fpsLimit = 0;
13
+ let lastRenderTime = 0;
14
+ let tickDuration = 1000 / tickRate;
15
+ let rafId = 0;
16
+
17
+ function loop(currentTime) {
18
+ if (!running) return;
19
+
20
+ const frameTime = Math.min(currentTime - lastTime, maxFrameTime);
21
+ lastTime = currentTime;
22
+ accumulator += frameTime;
23
+
24
+ // Fixed timestep simulation
25
+ while (accumulator >= tickDuration) {
26
+ if (profiler.enabled) {
27
+ const tickStart = performance.now();
28
+ for (const system of systems) {
29
+ if (system.update) {
30
+ const t0 = performance.now();
31
+ system.update(tickDuration);
32
+ profiler.end(system.name || 'unnamed', t0);
33
+ }
34
+ }
35
+ profiler.end('Tick Total', tickStart);
36
+ } else {
37
+ for (const system of systems) {
38
+ if (system.update) system.update(tickDuration);
39
+ }
40
+ }
41
+ accumulator -= tickDuration;
42
+ tickCount++;
43
+ }
44
+
45
+ const alpha = accumulator / tickDuration;
46
+
47
+ // Render (respecting fps cap)
48
+ const minRenderInterval = fpsLimit > 0 ? 1000 / fpsLimit : 0;
49
+ if (minRenderInterval === 0 || currentTime - lastRenderTime >= minRenderInterval) {
50
+ if (profiler.enabled) {
51
+ const renderStart = performance.now();
52
+ for (const step of renderSteps) {
53
+ const t0 = performance.now();
54
+ step.fn(alpha, frameTime);
55
+ profiler.end(step.name, t0);
56
+ }
57
+ profiler.end('Render Total', renderStart);
58
+ } else {
59
+ for (const step of renderSteps) {
60
+ step.fn(alpha, frameTime);
61
+ }
62
+ }
63
+ lastRenderTime = currentTime;
64
+ frameCount++;
65
+ }
66
+
67
+ fpsTime += frameTime;
68
+ if (fpsTime >= 1000) {
69
+ fps = frameCount;
70
+ tps = tickCount;
71
+ frameCount = 0;
72
+ tickCount = 0;
73
+ fpsTime -= 1000;
74
+ }
75
+
76
+ rafId = requestAnimationFrame(loop);
77
+ }
78
+
79
+ return {
80
+ start() {
81
+ if (running) return;
82
+ running = true;
83
+ lastTime = performance.now();
84
+ lastRenderTime = 0;
85
+ rafId = requestAnimationFrame(loop);
86
+ },
87
+
88
+ stop() {
89
+ running = false;
90
+ cancelAnimationFrame(rafId);
91
+ },
92
+
93
+ setFpsLimit(limit) {
94
+ fpsLimit = limit;
95
+ },
96
+
97
+ setTickRate(rate) {
98
+ tickDuration = 1000 / rate;
99
+ accumulator = 0;
100
+ },
101
+
102
+ setProfilingEnabled(enabled) {
103
+ profiler.setEnabled(enabled);
104
+ },
105
+
106
+ getProfileData() { return profiler.getData(); },
107
+ getFps() { return fps; },
108
+ getTps() { return tps; }
109
+ };
110
+ }
@@ -0,0 +1,39 @@
1
+ const EMA_ALPHA = 0.1;
2
+ const data = new Map();
3
+ let enabled = false;
4
+
5
+ export const profiler = {
6
+ get enabled() { return enabled; },
7
+
8
+ setEnabled(value) {
9
+ enabled = value;
10
+ if (!value) data.clear();
11
+ },
12
+
13
+ begin() {
14
+ return enabled ? performance.now() : 0;
15
+ },
16
+
17
+ end(name, t0) {
18
+ if (!enabled) return;
19
+ const ms = performance.now() - t0;
20
+ const entry = data.get(name);
21
+ if (entry) {
22
+ entry.avg += (ms - entry.avg) * EMA_ALPHA;
23
+ } else {
24
+ data.set(name, { avg: ms });
25
+ }
26
+ },
27
+
28
+ record(name, ms) {
29
+ if (!enabled) return;
30
+ const entry = data.get(name);
31
+ if (entry) {
32
+ entry.avg += (ms - entry.avg) * EMA_ALPHA;
33
+ } else {
34
+ data.set(name, { avg: ms });
35
+ }
36
+ },
37
+
38
+ getData() { return data; }
39
+ };
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createEntityManager } from './EntityManager.js';
2
+ export { createEventBus } from './EventBus.js';
3
+ export { createContainer } from './Container.js';
4
+ export { createGameLoop } from './GameLoop.js';
5
+ export { profiler } from './Profiler.js';
@@ -0,0 +1,38 @@
1
+ import { describe, test, expect } from '@jest/globals';
2
+ import { createContainer } from '../src/Container.js';
3
+
4
+ describe('Container', () => {
5
+ test('register and resolve returns factory result', () => {
6
+ const container = createContainer();
7
+ container.register('greeting', () => 'hello');
8
+ expect(container.resolve('greeting')).toBe('hello');
9
+ });
10
+
11
+ test('singleton caching — resolves same instance', () => {
12
+ const container = createContainer();
13
+ let callCount = 0;
14
+ container.register('service', () => {
15
+ callCount++;
16
+ return { id: callCount };
17
+ });
18
+ const a = container.resolve('service');
19
+ const b = container.resolve('service');
20
+ expect(a).toBe(b);
21
+ expect(callCount).toBe(1);
22
+ });
23
+
24
+ test('factory receives container for dependency injection', () => {
25
+ const container = createContainer();
26
+ container.register('config', () => ({ port: 3000 }));
27
+ container.register('server', (c) => {
28
+ const config = c.resolve('config');
29
+ return { port: config.port };
30
+ });
31
+ expect(container.resolve('server')).toEqual({ port: 3000 });
32
+ });
33
+
34
+ test('throws for unregistered key', () => {
35
+ const container = createContainer();
36
+ expect(() => container.resolve('missing')).toThrow('No factory registered for key: missing');
37
+ });
38
+ });
@@ -0,0 +1,224 @@
1
+ import { describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createEntityManager } from '../src/EntityManager.js';
3
+
4
+ describe('EntityManager', () => {
5
+ let em;
6
+ const Position = Symbol('Position');
7
+ const Velocity = Symbol('Velocity');
8
+ const Health = Symbol('Health');
9
+
10
+ beforeEach(() => {
11
+ em = createEntityManager();
12
+ });
13
+
14
+ describe('createEntity / destroyEntity', () => {
15
+ test('creates entities with incrementing ids', () => {
16
+ const a = em.createEntity();
17
+ const b = em.createEntity();
18
+ expect(b).toBe(a + 1);
19
+ });
20
+
21
+ test('destroyEntity removes entity', () => {
22
+ const id = em.createEntity();
23
+ em.addComponent(id, Position, { x: 0, y: 0 });
24
+ em.destroyEntity(id);
25
+ expect(em.getAllEntities()).toEqual([]);
26
+ expect(em.getComponent(id, Position)).toBeUndefined();
27
+ });
28
+ });
29
+
30
+ describe('addComponent / getComponent / hasComponent', () => {
31
+ test('adds and retrieves a component', () => {
32
+ const id = em.createEntity();
33
+ 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
+ });
37
+
38
+ test('returns undefined for missing component', () => {
39
+ const id = em.createEntity();
40
+ expect(em.getComponent(id, Position)).toBeUndefined();
41
+ expect(em.hasComponent(id, Position)).toBe(false);
42
+ });
43
+
44
+ test('overwrites component data on duplicate add', () => {
45
+ const id = em.createEntity();
46
+ em.addComponent(id, Position, { x: 1, y: 2 });
47
+ em.addComponent(id, Position, { x: 10, y: 20 });
48
+ expect(em.getComponent(id, Position)).toEqual({ x: 10, y: 20 });
49
+ });
50
+
51
+ test('adds multiple component types', () => {
52
+ const id = em.createEntity();
53
+ em.addComponent(id, Position, { x: 0, y: 0 });
54
+ 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
+ });
58
+ });
59
+
60
+ describe('removeComponent', () => {
61
+ test('removes a component', () => {
62
+ const id = em.createEntity();
63
+ em.addComponent(id, Position, { x: 1, y: 2 });
64
+ em.addComponent(id, Velocity, { vx: 1, vy: 1 });
65
+ em.removeComponent(id, Position);
66
+ expect(em.hasComponent(id, Position)).toBe(false);
67
+ expect(em.hasComponent(id, Velocity)).toBe(true);
68
+ });
69
+
70
+ test('removing last component leaves entity alive but without archetype', () => {
71
+ const id = em.createEntity();
72
+ em.addComponent(id, Position, { x: 1, y: 2 });
73
+ em.removeComponent(id, Position);
74
+ expect(em.getAllEntities()).toContain(id);
75
+ expect(em.hasComponent(id, Position)).toBe(false);
76
+ });
77
+
78
+ test('removing non-existent component is a no-op', () => {
79
+ const id = em.createEntity();
80
+ em.removeComponent(id, Position); // no-op
81
+ expect(em.getAllEntities()).toContain(id);
82
+ });
83
+ });
84
+
85
+ describe('query', () => {
86
+ test('returns entities matching component types', () => {
87
+ const a = em.createEntity();
88
+ em.addComponent(a, Position, { x: 0, y: 0 });
89
+ em.addComponent(a, Velocity, { vx: 1, vy: 1 });
90
+
91
+ const b = em.createEntity();
92
+ em.addComponent(b, Position, { x: 5, y: 5 });
93
+
94
+ const result = em.query([Position, Velocity]);
95
+ expect(result).toContain(a);
96
+ expect(result).not.toContain(b);
97
+ });
98
+
99
+ test('exclude types filters out entities', () => {
100
+ const a = em.createEntity();
101
+ em.addComponent(a, Position, { x: 0, y: 0 });
102
+
103
+ const b = em.createEntity();
104
+ em.addComponent(b, Position, { x: 1, y: 1 });
105
+ em.addComponent(b, Health, { hp: 100 });
106
+
107
+ const result = em.query([Position], [Health]);
108
+ expect(result).toContain(a);
109
+ expect(result).not.toContain(b);
110
+ });
111
+ });
112
+
113
+ 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 });
122
+ });
123
+ });
124
+
125
+ describe('count', () => {
126
+ test('counts entities matching query', () => {
127
+ const a = em.createEntity();
128
+ em.addComponent(a, Position, { x: 0, y: 0 });
129
+
130
+ const b = em.createEntity();
131
+ em.addComponent(b, Position, { x: 1, y: 1 });
132
+ em.addComponent(b, Velocity, { vx: 1, vy: 0 });
133
+
134
+ expect(em.count([Position])).toBe(2);
135
+ expect(em.count([Position, Velocity])).toBe(1);
136
+ });
137
+ });
138
+
139
+ describe('serialize / deserialize', () => {
140
+ const symbolToName = new Map([
141
+ [Position, 'Position'],
142
+ [Velocity, 'Velocity'],
143
+ [Health, 'Health']
144
+ ]);
145
+ const nameToSymbol = {
146
+ Position, Velocity, Health
147
+ };
148
+
149
+ test('round-trips entities and components', () => {
150
+ const a = em.createEntity();
151
+ em.addComponent(a, Position, { x: 1, y: 2 });
152
+ em.addComponent(a, Velocity, { vx: 3, vy: 4 });
153
+
154
+ const b = em.createEntity();
155
+ em.addComponent(b, Position, { x: 5, y: 6 });
156
+
157
+ const data = em.serialize(symbolToName);
158
+ em.deserialize(data, nameToSymbol);
159
+
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 });
164
+ });
165
+
166
+ test('strip components excludes component data but keeps entity', () => {
167
+ const a = em.createEntity();
168
+ em.addComponent(a, Position, { x: 1, y: 2 });
169
+ em.addComponent(a, Velocity, { vx: 3, vy: 4 });
170
+
171
+ const data = em.serialize(symbolToName, [Velocity]);
172
+ expect(data.components['Velocity']).toBeUndefined();
173
+ expect(data.components['Position']).toBeDefined();
174
+ });
175
+
176
+ test('skip entities with component excludes entire entity', () => {
177
+ const a = em.createEntity();
178
+ em.addComponent(a, Position, { x: 1, y: 2 });
179
+
180
+ const b = em.createEntity();
181
+ em.addComponent(b, Position, { x: 5, y: 6 });
182
+ em.addComponent(b, Health, { hp: 100 });
183
+
184
+ const data = em.serialize(symbolToName, [], [Health]);
185
+ expect(data.entities).toContain(a);
186
+ expect(data.entities).not.toContain(b);
187
+ });
188
+
189
+ test('custom serializers are used when provided', () => {
190
+ const a = em.createEntity();
191
+ em.addComponent(a, Position, { x: 1, y: 2, _internal: 'secret' });
192
+
193
+ const serializers = new Map([
194
+ ['Position', (data) => ({ x: data.x, y: data.y })]
195
+ ]);
196
+
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();
200
+ });
201
+
202
+ test('custom deserializers are used when provided', () => {
203
+ const a = em.createEntity();
204
+ em.addComponent(a, Position, { x: 1, y: 2 });
205
+
206
+ const data = em.serialize(symbolToName);
207
+
208
+ const deserializers = new Map([
209
+ ['Position', (compData) => ({ ...compData, restored: true })]
210
+ ]);
211
+
212
+ em.deserialize(data, nameToSymbol, { deserializers });
213
+ expect(em.getComponent(a, Position)).toEqual({ x: 1, y: 2, restored: true });
214
+ });
215
+
216
+ test('deserialize clears previous state', () => {
217
+ const a = em.createEntity();
218
+ em.addComponent(a, Position, { x: 1, y: 2 });
219
+
220
+ em.deserialize({ nextId: 1, entities: [], components: {} }, nameToSymbol);
221
+ expect(em.getAllEntities()).toEqual([]);
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, test, expect } from '@jest/globals';
2
+ import { createEventBus } from '../src/EventBus.js';
3
+
4
+ describe('EventBus', () => {
5
+ test('on/emit calls listener with data', () => {
6
+ const bus = createEventBus();
7
+ const received = [];
8
+ bus.on('test', (data) => received.push(data));
9
+ bus.emit('test', { value: 42 });
10
+ expect(received).toEqual([{ value: 42 }]);
11
+ });
12
+
13
+ test('multiple listeners on same event', () => {
14
+ const bus = createEventBus();
15
+ const results = [];
16
+ bus.on('test', () => results.push('a'));
17
+ bus.on('test', () => results.push('b'));
18
+ bus.emit('test');
19
+ expect(results).toEqual(['a', 'b']);
20
+ });
21
+
22
+ test('off removes listener', () => {
23
+ const bus = createEventBus();
24
+ const results = [];
25
+ const cb = () => results.push('called');
26
+ bus.on('test', cb);
27
+ bus.off('test', cb);
28
+ bus.emit('test');
29
+ expect(results).toEqual([]);
30
+ });
31
+
32
+ test('emit on non-existent event is a no-op', () => {
33
+ const bus = createEventBus();
34
+ expect(() => bus.emit('nonexistent', {})).not.toThrow();
35
+ });
36
+
37
+ test('off on non-existent event is a no-op', () => {
38
+ const bus = createEventBus();
39
+ expect(() => bus.off('nonexistent', () => {})).not.toThrow();
40
+ });
41
+ });