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