@codehz/ecs 0.8.1 → 0.9.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 +28 -3
- package/dist/builder.d.mts +296 -46
- 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 +452 -179
- 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/package.json +1 -1
- package/skills/ecs/SKILL.md +9 -4
- package/src/__tests__/component/singleton.test.ts +40 -1
- 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 +134 -1
- package/src/__tests__/world/commands.test.ts +337 -0
- 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 +13 -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/commands.ts +44 -56
- package/src/world/hooks.ts +8 -0
- package/src/world/serialization.ts +32 -18
- package/src/world/world.ts +387 -20
|
@@ -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
|
+
});
|
package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts}
RENAMED
|
@@ -3,10 +3,10 @@ import { component, relation, type EntityId } from "../../entity";
|
|
|
3
3
|
import { World } from "../../world/world";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Focused performance tests for
|
|
6
|
+
* Focused performance tests for sparse relation storage.
|
|
7
7
|
*
|
|
8
|
-
* These tests specifically exercise the
|
|
9
|
-
* - Wildcard queries over
|
|
8
|
+
* These tests specifically exercise the hot paths:
|
|
9
|
+
* - Wildcard queries over sparse relations (relation(Comp, "*"))
|
|
10
10
|
* - Frequent exclusive relation flips (the classic ChildOf pattern)
|
|
11
11
|
* - hasRelationWithComponentId / archetype filtering cost
|
|
12
12
|
*/
|
|
@@ -31,11 +31,11 @@ function benchmark(label: string, warmupRounds: number, measuredRounds: number,
|
|
|
31
31
|
return average;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
describe("
|
|
35
|
-
it("should handle large numbers of entities with exclusive
|
|
34
|
+
describe("Sparse + Wildcard Performance", () => {
|
|
35
|
+
it("should handle large numbers of entities with exclusive sparse relations + wildcard queries efficiently", () => {
|
|
36
36
|
const world = new World();
|
|
37
37
|
const Position = component<{ x: number; y: number }>();
|
|
38
|
-
const ChildOf = component({
|
|
38
|
+
const ChildOf = component({ sparse: true, exclusive: true });
|
|
39
39
|
|
|
40
40
|
const parentCount = 20;
|
|
41
41
|
const parents: EntityId[] = [];
|
|
@@ -56,9 +56,9 @@ describe("DontFragment + Wildcard Performance (post-refactor)", () => {
|
|
|
56
56
|
world.sync();
|
|
57
57
|
|
|
58
58
|
const wildcard = relation(ChildOf, "*");
|
|
59
|
-
|
|
59
|
+
using q = world.createQuery([Position, wildcard]);
|
|
60
60
|
|
|
61
|
-
const avg = benchmark("10k entities: wildcard query over exclusive
|
|
61
|
+
const avg = benchmark("10k entities: wildcard query over exclusive sparse relations", 2, 6, () => {
|
|
62
62
|
let count = 0;
|
|
63
63
|
q.forEach([Position, wildcard], (_entity, _pos, rels) => {
|
|
64
64
|
count += rels.length; // force materialization
|
|
@@ -66,16 +66,13 @@ describe("DontFragment + Wildcard Performance (post-refactor)", () => {
|
|
|
66
66
|
expect(count).toBeGreaterThan(0);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// These numbers will be tuned after the implementation stabilizes.
|
|
72
|
-
// The goal is to verify we did not regress the hot wildcard + dontFragment path.
|
|
73
|
-
expect(avg).toBeLessThan(50); // generous upper bound post-refactor
|
|
69
|
+
// The goal is to verify we did not regress the hot wildcard + sparse storage path.
|
|
70
|
+
expect(avg).toBeLessThan(50); // generous upper bound
|
|
74
71
|
});
|
|
75
72
|
|
|
76
|
-
it("should support frequent exclusive
|
|
73
|
+
it("should support frequent exclusive sparse relation flips without leaking relations", () => {
|
|
77
74
|
const world = new World();
|
|
78
|
-
const ChildOf = component({
|
|
75
|
+
const ChildOf = component({ sparse: true, exclusive: true });
|
|
79
76
|
|
|
80
77
|
const parentA = world.new();
|
|
81
78
|
const parentB = world.new();
|
|
@@ -90,7 +87,7 @@ describe("DontFragment + Wildcard Performance (post-refactor)", () => {
|
|
|
90
87
|
world.sync();
|
|
91
88
|
|
|
92
89
|
// Flip many times
|
|
93
|
-
const flipAvg = benchmark("2k entities: exclusive
|
|
90
|
+
const flipAvg = benchmark("2k entities: exclusive sparse relation flip (100 rounds)", 1, 3, (round) => {
|
|
94
91
|
const target = round % 2 === 0 ? parentB : parentA;
|
|
95
92
|
for (const e of entities) {
|
|
96
93
|
world.set(e, relation(ChildOf, target));
|
|
@@ -109,4 +109,66 @@ describe("Query", () => {
|
|
|
109
109
|
expect(query1).not.toBe(query3);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
describe("Query disposal and archetype removal notification", () => {
|
|
114
|
+
type Pos = { x: number };
|
|
115
|
+
const PosC = component<Pos>();
|
|
116
|
+
|
|
117
|
+
it("should support dispose and disposed flag, and release via registry refcount", () => {
|
|
118
|
+
const world = new World();
|
|
119
|
+
const q1 = world.createQuery([PosC]);
|
|
120
|
+
expect(q1.disposed).toBe(false);
|
|
121
|
+
|
|
122
|
+
q1.dispose();
|
|
123
|
+
// After dispose, further use may be no-op but flag set when ref hits 0
|
|
124
|
+
// Note: createQuery reuses, so need unique to drop ref
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should trigger removeArchetype on query when archetypes are cleaned up", () => {
|
|
128
|
+
const world = new World();
|
|
129
|
+
const q = world.createQuery([PosC]);
|
|
130
|
+
|
|
131
|
+
// Spawn then delete all of archetype to trigger cleanup
|
|
132
|
+
const e1 = world.spawn().with(PosC, { x: 1 }).build();
|
|
133
|
+
const e2 = world.spawn().with(PosC, { x: 2 }).build();
|
|
134
|
+
world.sync();
|
|
135
|
+
|
|
136
|
+
expect(q.getEntities().length).toBe(2);
|
|
137
|
+
|
|
138
|
+
world.delete(e1);
|
|
139
|
+
world.delete(e2);
|
|
140
|
+
world.sync();
|
|
141
|
+
|
|
142
|
+
// Archetype should be removed, query notified (no crash, internal removeArchetype called)
|
|
143
|
+
expect(q.getEntities().length).toBe(0);
|
|
144
|
+
|
|
145
|
+
// Also test Symbol.dispose via fixture or direct (dispose marks)
|
|
146
|
+
q.dispose();
|
|
147
|
+
expect(q.disposed).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should directly cover Query removeArchetype and dispose internal branches", () => {
|
|
151
|
+
const world = new World();
|
|
152
|
+
const q = world.createQuery([PosC]);
|
|
153
|
+
// Force an archetype in cache then remove it (covers splice path)
|
|
154
|
+
const dummy: any = {};
|
|
155
|
+
(q as any).cachedArchetypes.push(dummy);
|
|
156
|
+
(q as any).removeArchetype(dummy);
|
|
157
|
+
expect((q as any).cachedArchetypes).not.toContain(dummy);
|
|
158
|
+
|
|
159
|
+
// cover disposed early return
|
|
160
|
+
(q as any).isDisposed = true;
|
|
161
|
+
(q as any).removeArchetype({} as any); // no-op
|
|
162
|
+
(q as any).checkNewArchetype({} as any); // no-op
|
|
163
|
+
|
|
164
|
+
// cover _disposeInternal again
|
|
165
|
+
(q as any)._disposeInternal();
|
|
166
|
+
|
|
167
|
+
// cover disposed getter (line ~310)
|
|
168
|
+
void (q as any).disposed;
|
|
169
|
+
|
|
170
|
+
// cover Symbol.dispose method
|
|
171
|
+
(q as any)[Symbol.dispose]();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
112
174
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { Archetype } from "../../archetype/archetype";
|
|
3
|
-
import {
|
|
3
|
+
import { SparseStoreImpl } from "../../archetype/store";
|
|
4
4
|
import type { ComponentId, EntityId } from "../../entity";
|
|
5
5
|
import { relation } from "../../entity";
|
|
6
6
|
import { matchesComponentTypes, matchesFilter, type QueryFilter } from "../../query/filter";
|
|
@@ -11,34 +11,31 @@ const velocityComponent = 2 as ComponentId<{ dx: number; dy: number }>;
|
|
|
11
11
|
const healthComponent = 3 as ComponentId<{ value: number }>;
|
|
12
12
|
const relationComponent = 4 as ComponentId<{ strength: number }>;
|
|
13
13
|
|
|
14
|
-
// Helper function to create a real
|
|
15
|
-
const
|
|
14
|
+
// Helper function to create a real SparseStore for testing.
|
|
15
|
+
const createSparseStore = () => new SparseStoreImpl();
|
|
16
16
|
|
|
17
17
|
describe("Query Filter Functions", () => {
|
|
18
18
|
describe("matchesComponentTypes", () => {
|
|
19
19
|
it("should return true when archetype contains all required component types", () => {
|
|
20
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
20
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
21
21
|
const componentTypes = [positionComponent, velocityComponent];
|
|
22
22
|
expect(matchesComponentTypes(archetype, componentTypes)).toBe(true);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it("should return true when archetype contains required component types and more", () => {
|
|
26
|
-
const archetype = new Archetype(
|
|
27
|
-
[positionComponent, velocityComponent, healthComponent],
|
|
28
|
-
createDontFragmentRelations(),
|
|
29
|
-
);
|
|
26
|
+
const archetype = new Archetype([positionComponent, velocityComponent, healthComponent], createSparseStore());
|
|
30
27
|
const componentTypes = [positionComponent, velocityComponent];
|
|
31
28
|
expect(matchesComponentTypes(archetype, componentTypes)).toBe(true);
|
|
32
29
|
});
|
|
33
30
|
|
|
34
31
|
it("should return false when archetype is missing a required component type", () => {
|
|
35
|
-
const archetype = new Archetype([positionComponent],
|
|
32
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
36
33
|
const componentTypes = [positionComponent, velocityComponent];
|
|
37
34
|
expect(matchesComponentTypes(archetype, componentTypes)).toBe(false);
|
|
38
35
|
});
|
|
39
36
|
|
|
40
37
|
it("should return true for empty component types array", () => {
|
|
41
|
-
const archetype = new Archetype([positionComponent],
|
|
38
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
42
39
|
const componentTypes: EntityId<any>[] = [];
|
|
43
40
|
expect(matchesComponentTypes(archetype, componentTypes)).toBe(true);
|
|
44
41
|
});
|
|
@@ -46,41 +43,38 @@ describe("Query Filter Functions", () => {
|
|
|
46
43
|
|
|
47
44
|
describe("matchesFilter", () => {
|
|
48
45
|
it("should return true when no negative component types are specified", () => {
|
|
49
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
46
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
50
47
|
const filter: QueryFilter = {};
|
|
51
48
|
expect(matchesFilter(archetype, filter)).toBe(true);
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
it("should return true when archetype does not contain any negative component types", () => {
|
|
55
|
-
const archetype = new Archetype([positionComponent, velocityComponent],
|
|
52
|
+
const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
|
|
56
53
|
const filter: QueryFilter = { negativeComponentTypes: [healthComponent] };
|
|
57
54
|
expect(matchesFilter(archetype, filter)).toBe(true);
|
|
58
55
|
});
|
|
59
56
|
|
|
60
57
|
it("should return false when archetype contains a negative component type", () => {
|
|
61
|
-
const archetype = new Archetype(
|
|
62
|
-
[positionComponent, velocityComponent, healthComponent],
|
|
63
|
-
createDontFragmentRelations(),
|
|
64
|
-
);
|
|
58
|
+
const archetype = new Archetype([positionComponent, velocityComponent, healthComponent], createSparseStore());
|
|
65
59
|
const filter: QueryFilter = { negativeComponentTypes: [healthComponent] };
|
|
66
60
|
expect(matchesFilter(archetype, filter)).toBe(false);
|
|
67
61
|
});
|
|
68
62
|
|
|
69
63
|
it("should return false when archetype contains any of multiple negative component types", () => {
|
|
70
|
-
const archetype = new Archetype([positionComponent, healthComponent],
|
|
64
|
+
const archetype = new Archetype([positionComponent, healthComponent], createSparseStore());
|
|
71
65
|
const filter: QueryFilter = { negativeComponentTypes: [velocityComponent, healthComponent] };
|
|
72
66
|
expect(matchesFilter(archetype, filter)).toBe(false);
|
|
73
67
|
});
|
|
74
68
|
|
|
75
69
|
it("should return true when archetype contains none of multiple negative component types", () => {
|
|
76
|
-
const archetype = new Archetype([positionComponent],
|
|
70
|
+
const archetype = new Archetype([positionComponent], createSparseStore());
|
|
77
71
|
const filter: QueryFilter = { negativeComponentTypes: [velocityComponent, healthComponent] };
|
|
78
72
|
expect(matchesFilter(archetype, filter)).toBe(true);
|
|
79
73
|
});
|
|
80
74
|
|
|
81
75
|
it("should return false when archetype contains a negative wildcard relation component", () => {
|
|
82
76
|
const wildcardRelation = relation(relationComponent, "*");
|
|
83
|
-
const archetype = new Archetype([positionComponent, wildcardRelation],
|
|
77
|
+
const archetype = new Archetype([positionComponent, wildcardRelation], createSparseStore());
|
|
84
78
|
const filter: QueryFilter = { negativeComponentTypes: [wildcardRelation] };
|
|
85
79
|
expect(matchesFilter(archetype, filter)).toBe(false);
|
|
86
80
|
});
|
|
@@ -88,7 +82,7 @@ describe("Query Filter Functions", () => {
|
|
|
88
82
|
it("should return false when archetype contains a specific relation matching negative wildcard filter", () => {
|
|
89
83
|
const wildcardRelation = relation(relationComponent, "*");
|
|
90
84
|
const otherRelation = relation(relationComponent, 1025 as EntityId);
|
|
91
|
-
const archetype = new Archetype([positionComponent, otherRelation],
|
|
85
|
+
const archetype = new Archetype([positionComponent, otherRelation], createSparseStore());
|
|
92
86
|
const filter: QueryFilter = { negativeComponentTypes: [wildcardRelation] };
|
|
93
87
|
expect(matchesFilter(archetype, filter)).toBe(false);
|
|
94
88
|
});
|
|
@@ -96,7 +90,7 @@ describe("Query Filter Functions", () => {
|
|
|
96
90
|
it("should return true when archetype does not contain any relations with the wildcard component", () => {
|
|
97
91
|
const wildcardRelation = relation(relationComponent, "*");
|
|
98
92
|
const otherComponent = 5 as EntityId<{ other: number }>;
|
|
99
|
-
const archetype = new Archetype([positionComponent, otherComponent],
|
|
93
|
+
const archetype = new Archetype([positionComponent, otherComponent], createSparseStore());
|
|
100
94
|
const filter: QueryFilter = { negativeComponentTypes: [wildcardRelation] };
|
|
101
95
|
expect(matchesFilter(archetype, filter)).toBe(true);
|
|
102
96
|
});
|
|
@@ -104,7 +98,7 @@ describe("Query Filter Functions", () => {
|
|
|
104
98
|
it("should return false when archetype contains wildcard relation matching negative filter", () => {
|
|
105
99
|
const wildcardRelation = relation(relationComponent, "*");
|
|
106
100
|
const matchingRelation = relation(relationComponent, 1026 as EntityId);
|
|
107
|
-
const archetype = new Archetype([positionComponent, matchingRelation],
|
|
101
|
+
const archetype = new Archetype([positionComponent, matchingRelation], createSparseStore());
|
|
108
102
|
const filter: QueryFilter = { negativeComponentTypes: [wildcardRelation] };
|
|
109
103
|
expect(matchesFilter(archetype, filter)).toBe(false);
|
|
110
104
|
});
|
|
@@ -51,8 +51,8 @@ function performanceTest() {
|
|
|
51
51
|
console.log(`Entity creation time: ${(endCreate - startCreate).toFixed(2)}ms`);
|
|
52
52
|
|
|
53
53
|
// Create queries
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
using positionVelocityQuery = world.createQuery([positionComponent, velocityComponent]);
|
|
55
|
+
using _healthQuery = world.createQuery([healthComponent]);
|
|
56
56
|
|
|
57
57
|
// Test getEntitiesWithComponents performance
|
|
58
58
|
console.log("\nTesting getEntitiesWithComponents performance...");
|
|
@@ -89,9 +89,7 @@ function performanceTest() {
|
|
|
89
89
|
});
|
|
90
90
|
console.log(`forEach iterated over ${forEachCount} entities`);
|
|
91
91
|
|
|
92
|
-
// Cleanup
|
|
93
|
-
positionVelocityQuery.dispose();
|
|
94
|
-
healthQuery.dispose();
|
|
92
|
+
// Cleanup handled by using declarations for positionVelocityQuery / healthQuery
|
|
95
93
|
|
|
96
94
|
console.log("\nPerformance test completed!");
|
|
97
95
|
}
|