@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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Archetype } from "../../archetype/archetype";
|
|
3
|
+
import { SparseStoreImpl } from "../../archetype/store";
|
|
4
|
+
import { ComponentChangeset } from "../../commands/changeset";
|
|
5
|
+
import { component, createEntityId, relation, type ComponentId, type EntityId } from "../../entity";
|
|
6
|
+
import {
|
|
7
|
+
applyChangeset,
|
|
8
|
+
areComponentTypesEqual,
|
|
9
|
+
filterRegularComponentTypes,
|
|
10
|
+
maybeRemoveWildcardMarker,
|
|
11
|
+
type CommandProcessorContext,
|
|
12
|
+
} from "../../world/commands";
|
|
13
|
+
import { World } from "../../world/world";
|
|
14
|
+
|
|
15
|
+
function createSparseStore() {
|
|
16
|
+
return new SparseStoreImpl();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeArchetype(componentTypes: EntityId<any>[]): Archetype {
|
|
20
|
+
return new Archetype(componentTypes, createSparseStore());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeCtx(): CommandProcessorContext {
|
|
24
|
+
const store = createSparseStore();
|
|
25
|
+
return {
|
|
26
|
+
sparseStore: store,
|
|
27
|
+
ensureArchetype: (types) => new Archetype(Array.from(types), store),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("world/commands internal coverage", () => {
|
|
32
|
+
const A = component<number>();
|
|
33
|
+
const B = component<string>();
|
|
34
|
+
const Tag = component({ dontFragment: true });
|
|
35
|
+
const Data = component<{ v: number }>({ dontFragment: true });
|
|
36
|
+
const ExclusiveTag = component({ dontFragment: true, exclusive: true });
|
|
37
|
+
|
|
38
|
+
describe("areComponentTypesEqual", () => {
|
|
39
|
+
it("returns true for equal arrays (same order)", () => {
|
|
40
|
+
expect(areComponentTypesEqual([A, B], [A, B])).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns true for equal arrays after normalization (different order)", () => {
|
|
44
|
+
expect(areComponentTypesEqual([B, A], [A, B])).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns false for different lengths", () => {
|
|
48
|
+
expect(areComponentTypesEqual([A], [A, B])).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns false for different content after normalization", () => {
|
|
52
|
+
expect(areComponentTypesEqual([A, B], [A, Tag])).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("filterRegularComponentTypes", () => {
|
|
57
|
+
it("keeps wildcard markers for dontFragment and drops specific dontFragment relations", () => {
|
|
58
|
+
const wild = relation(Tag, "*");
|
|
59
|
+
const specific = relation(Tag, createEntityId(9999) as EntityId);
|
|
60
|
+
const result = filterRegularComponentTypes([A, specific, wild, B]);
|
|
61
|
+
// specific dontFragment relation should be filtered out; wildcard marker kept
|
|
62
|
+
expect(result).toContain(wild);
|
|
63
|
+
expect(result).toContain(A);
|
|
64
|
+
expect(result).toContain(B);
|
|
65
|
+
expect(result).not.toContain(specific);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("removeMatchingRelations + maybeRemoveWildcardMarker (marker retention)", () => {
|
|
70
|
+
it("keeps wildcard marker when removing one of multiple non-exclusive dontFragment relations (archetype path)", () => {
|
|
71
|
+
const store = createSparseStore();
|
|
72
|
+
const p1 = createEntityId(2001) as EntityId;
|
|
73
|
+
const p2 = createEntityId(2002) as EntityId;
|
|
74
|
+
const r1 = relation(Tag, p1);
|
|
75
|
+
const r2 = relation(Tag, p2);
|
|
76
|
+
const wild = relation(Tag, "*");
|
|
77
|
+
|
|
78
|
+
// Archetype declares the wildcard marker (as required for dontFragment)
|
|
79
|
+
const arch = new Archetype([A, wild], store);
|
|
80
|
+
const entity = createEntityId(5001) as EntityId;
|
|
81
|
+
|
|
82
|
+
// Put the entity in the archetype with two concrete relations stored in dontFragment store
|
|
83
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
84
|
+
store.setValue(entity, r1, undefined);
|
|
85
|
+
store.setValue(entity, r2, undefined);
|
|
86
|
+
|
|
87
|
+
const changeset = new ComponentChangeset();
|
|
88
|
+
// Remove only one target
|
|
89
|
+
changeset.delete(r1);
|
|
90
|
+
|
|
91
|
+
// Call the function under test (simulates what processDeleteCommand does for non-wildcard delete)
|
|
92
|
+
maybeRemoveWildcardMarker(entity, arch, r1, Tag as unknown as ComponentId<any>, changeset);
|
|
93
|
+
|
|
94
|
+
// Marker must NOT have been scheduled for removal
|
|
95
|
+
expect(changeset.removes.has(wild)).toBe(false);
|
|
96
|
+
// We only mutated changeset; the store still contains the other relation entry
|
|
97
|
+
// (presence is observable via getAllForEntity because value may legitimately be undefined for tags)
|
|
98
|
+
const remaining = store.getAllForEntity(entity).some(([t]) => t === r2);
|
|
99
|
+
expect(remaining).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps wildcard marker when other relation exists only in dontFragmentData for the entity", () => {
|
|
103
|
+
const store = createSparseStore();
|
|
104
|
+
const p1 = createEntityId(2001) as EntityId;
|
|
105
|
+
const p2 = createEntityId(2002) as EntityId;
|
|
106
|
+
const r1 = relation(Tag, p1);
|
|
107
|
+
const r2 = relation(Tag, p2);
|
|
108
|
+
const wild = relation(Tag, "*");
|
|
109
|
+
|
|
110
|
+
const arch = new Archetype([A, wild], store);
|
|
111
|
+
const entity = createEntityId(5002) as EntityId;
|
|
112
|
+
|
|
113
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
114
|
+
// Only store relations in dontFragment (no regular archetype components for them)
|
|
115
|
+
store.setValue(entity, r1, undefined);
|
|
116
|
+
store.setValue(entity, r2, undefined);
|
|
117
|
+
|
|
118
|
+
const changeset = new ComponentChangeset();
|
|
119
|
+
changeset.delete(r1);
|
|
120
|
+
|
|
121
|
+
maybeRemoveWildcardMarker(entity, arch, r1, Tag as unknown as ComponentId<any>, changeset);
|
|
122
|
+
|
|
123
|
+
expect(changeset.removes.has(wild)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("keeps wildcard marker on exclusive dontFragment flip when remove+add are in same changeset (batch)", () => {
|
|
127
|
+
const store = createSparseStore();
|
|
128
|
+
const p1 = createEntityId(3001) as EntityId;
|
|
129
|
+
const p2 = createEntityId(3002) as EntityId;
|
|
130
|
+
const r1 = relation(ExclusiveTag, p1);
|
|
131
|
+
const r2 = relation(ExclusiveTag, p2);
|
|
132
|
+
const wild = relation(ExclusiveTag, "*");
|
|
133
|
+
|
|
134
|
+
const arch = new Archetype([A, wild], store);
|
|
135
|
+
const entity = createEntityId(5003) as EntityId;
|
|
136
|
+
|
|
137
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
138
|
+
store.setValue(entity, r1, { x: 1 }); // some value
|
|
139
|
+
|
|
140
|
+
const changeset = new ComponentChangeset();
|
|
141
|
+
// Simulate exclusive flip: remove old target, add new target in one batch
|
|
142
|
+
changeset.delete(r1);
|
|
143
|
+
changeset.set(r2, undefined); // ExclusiveTag is a void/tag dontFragment component
|
|
144
|
+
|
|
145
|
+
maybeRemoveWildcardMarker(entity, arch, r1, ExclusiveTag as unknown as ComponentId<any>, changeset);
|
|
146
|
+
|
|
147
|
+
// Because a replacement add is present, marker must be kept
|
|
148
|
+
expect(changeset.removes.has(wild)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("pruneMissingRemovals (via applyChangeset)", () => {
|
|
153
|
+
it("prunes remove commands for components the entity does not actually have", () => {
|
|
154
|
+
const ctx = makeCtx();
|
|
155
|
+
const entity = createEntityId(6001) as EntityId;
|
|
156
|
+
|
|
157
|
+
// Archetype with only A
|
|
158
|
+
const arch = makeArchetype([A]);
|
|
159
|
+
arch.addEntity(entity, new Map([[A, 42]]));
|
|
160
|
+
|
|
161
|
+
const changeset = new ComponentChangeset();
|
|
162
|
+
// Spurious removes that do not exist on the entity
|
|
163
|
+
changeset.delete(B);
|
|
164
|
+
changeset.delete(relation(Tag, createEntityId(7777) as EntityId));
|
|
165
|
+
|
|
166
|
+
const entityToArch = new Map([[entity, arch]]);
|
|
167
|
+
const removed: Map<EntityId<any>, any> | null = new Map();
|
|
168
|
+
|
|
169
|
+
const resultArch = applyChangeset(ctx, entity, arch, changeset, entityToArch, removed);
|
|
170
|
+
|
|
171
|
+
// No structural change should have occurred (pruned), entity stays in same archetype
|
|
172
|
+
expect(resultArch).toBe(arch);
|
|
173
|
+
expect(entityToArch.get(entity)).toBe(arch);
|
|
174
|
+
// The spurious removes should have been pruned (no error, no move)
|
|
175
|
+
expect(changeset.removes.size).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("applyDontFragmentChanges recording paths (with hooks)", () => {
|
|
180
|
+
it("records removal of dontFragment relation even when stored value is undefined (tag/void case)", () => {
|
|
181
|
+
const store = createSparseStore();
|
|
182
|
+
const p = createEntityId(4001) as EntityId;
|
|
183
|
+
const r = relation(Tag, p); // Tag is dontFragment void-style
|
|
184
|
+
const wild = relation(Tag, "*");
|
|
185
|
+
|
|
186
|
+
const arch = new Archetype([A, wild], store);
|
|
187
|
+
const entity = createEntityId(7001) as EntityId;
|
|
188
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
189
|
+
store.setValue(entity, r, undefined); // explicitly undefined payload
|
|
190
|
+
|
|
191
|
+
const changeset = new ComponentChangeset();
|
|
192
|
+
changeset.delete(r);
|
|
193
|
+
|
|
194
|
+
// removedComponents non-null triggers the hook-recording path
|
|
195
|
+
const removedComponents = new Map<EntityId<any>, any>();
|
|
196
|
+
|
|
197
|
+
const ctx: CommandProcessorContext = {
|
|
198
|
+
sparseStore: store,
|
|
199
|
+
ensureArchetype: (t) => new Archetype(Array.from(t), store),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// No regular structural change (only dontFragment relation change)
|
|
203
|
+
applyChangeset(ctx, entity, arch, changeset, new Map(), removedComponents);
|
|
204
|
+
|
|
205
|
+
// The key: we should have recorded something for the removed dontFragment relation
|
|
206
|
+
// even though its value was undefined. This exercises the getAllForEntity fallback.
|
|
207
|
+
expect(removedComponents.has(r)).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("records and applies normal dontFragment add/remove with data payloads", () => {
|
|
211
|
+
const store = createSparseStore();
|
|
212
|
+
const p = createEntityId(4002) as EntityId;
|
|
213
|
+
const r = relation(Data, p);
|
|
214
|
+
const wild = relation(Data, "*");
|
|
215
|
+
|
|
216
|
+
const arch = new Archetype([A, wild], store);
|
|
217
|
+
const entity = createEntityId(7002) as EntityId;
|
|
218
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
219
|
+
|
|
220
|
+
const changeset = new ComponentChangeset();
|
|
221
|
+
changeset.set(r, { v: 99 });
|
|
222
|
+
|
|
223
|
+
const ctx: CommandProcessorContext = {
|
|
224
|
+
sparseStore: store,
|
|
225
|
+
ensureArchetype: (t) => new Archetype(Array.from(t), store),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
applyChangeset(ctx, entity, arch, changeset, new Map(), null);
|
|
229
|
+
|
|
230
|
+
expect(store.getValue(entity, r)).toEqual({ v: 99 });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("applyChangeset archetype move vs in-place dontFragment update", () => {
|
|
235
|
+
it("moves archetype when regular component is added/removed alongside dontFragment changes", () => {
|
|
236
|
+
const store = createSparseStore();
|
|
237
|
+
const p = createEntityId(5005) as EntityId;
|
|
238
|
+
const r = relation(Data, p);
|
|
239
|
+
const wild = relation(Data, "*");
|
|
240
|
+
|
|
241
|
+
const arch = new Archetype([A, wild], store);
|
|
242
|
+
const entity = createEntityId(8001) as EntityId;
|
|
243
|
+
arch.addEntity(entity, new Map([[A, 1]]));
|
|
244
|
+
store.setValue(entity, r, { v: 1 });
|
|
245
|
+
|
|
246
|
+
const changeset = new ComponentChangeset();
|
|
247
|
+
changeset.delete(A); // regular component removal → structural change
|
|
248
|
+
changeset.delete(r); // dontFragment
|
|
249
|
+
|
|
250
|
+
const ctx: CommandProcessorContext = {
|
|
251
|
+
sparseStore: store,
|
|
252
|
+
ensureArchetype: (t) => new Archetype(Array.from(t), store),
|
|
253
|
+
};
|
|
254
|
+
const entityToArch = new Map([[entity, arch]]);
|
|
255
|
+
const removed = new Map();
|
|
256
|
+
|
|
257
|
+
const newArch = applyChangeset(ctx, entity, arch, changeset, entityToArch, removed);
|
|
258
|
+
|
|
259
|
+
expect(newArch).not.toBe(arch);
|
|
260
|
+
expect(entityToArch.get(entity)).toBe(newArch);
|
|
261
|
+
expect(removed.has(r)).toBe(true); // recorded because removedComponents was provided
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// These tests go through the public World API + full command processing pipeline
|
|
266
|
+
// (processCommands → processDeleteCommand → maybeRemoveWildcardMarker, etc.)
|
|
267
|
+
// to ensure branch coverage is attributed correctly for the early-return paths.
|
|
268
|
+
describe("public API paths for remaining branch coverage (processDeleteCommand etc.)", () => {
|
|
269
|
+
it("non-exclusive dontFragment: removing one target keeps wildcard marker (via World commands)", () => {
|
|
270
|
+
const world = new World();
|
|
271
|
+
const ParentTag = component({ dontFragment: true }); // non-exclusive
|
|
272
|
+
const p1 = world.new();
|
|
273
|
+
const p2 = world.new();
|
|
274
|
+
const child = world.new();
|
|
275
|
+
|
|
276
|
+
const r1 = relation(ParentTag, p1);
|
|
277
|
+
const r2 = relation(ParentTag, p2);
|
|
278
|
+
const wild = relation(ParentTag, "*");
|
|
279
|
+
|
|
280
|
+
world.set(child, r1, undefined);
|
|
281
|
+
world.set(child, r2, undefined);
|
|
282
|
+
world.sync();
|
|
283
|
+
|
|
284
|
+
// At this point child should have the wildcard marker in its (regular) component types
|
|
285
|
+
// Remove only one concrete relation
|
|
286
|
+
world.remove(child, r1);
|
|
287
|
+
world.sync();
|
|
288
|
+
|
|
289
|
+
// Marker must still exist (we can observe via wildcard query or has on the marker)
|
|
290
|
+
expect(world.has(child, wild)).toBe(true);
|
|
291
|
+
// The other relation survives
|
|
292
|
+
expect(world.has(child, r2)).toBe(true);
|
|
293
|
+
// The removed one is gone
|
|
294
|
+
expect(world.has(child, r1)).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("exclusive dontFragment replacement via set (normal path) retains marker and cleans old target", () => {
|
|
298
|
+
const world = new World();
|
|
299
|
+
const ChildOf = component({ dontFragment: true, exclusive: true });
|
|
300
|
+
const parentA = world.new();
|
|
301
|
+
const parentB = world.new();
|
|
302
|
+
const child = world.new();
|
|
303
|
+
|
|
304
|
+
const rA = relation(ChildOf, parentA);
|
|
305
|
+
const rB = relation(ChildOf, parentB);
|
|
306
|
+
const wild = relation(ChildOf, "*");
|
|
307
|
+
|
|
308
|
+
world.set(child, rA, undefined);
|
|
309
|
+
world.sync();
|
|
310
|
+
|
|
311
|
+
// Normal exclusive flip via set — handleExclusiveRelation removes the old target
|
|
312
|
+
// using removeMatchingRelations (direct changeset delete, marker kept by other logic)
|
|
313
|
+
world.set(child, rB, undefined);
|
|
314
|
+
world.sync();
|
|
315
|
+
|
|
316
|
+
expect(world.has(child, wild)).toBe(true);
|
|
317
|
+
expect(world.has(child, rB)).toBe(true);
|
|
318
|
+
expect(world.has(child, rA)).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("spurious remove of non-present component is a no-op (pruneMissingRemovals via World)", () => {
|
|
322
|
+
const world = new World();
|
|
323
|
+
const Pos = component<{ x: number }>();
|
|
324
|
+
const e = world.new();
|
|
325
|
+
world.set(e, Pos, { x: 10 });
|
|
326
|
+
world.sync();
|
|
327
|
+
|
|
328
|
+
// Remove something that was never present
|
|
329
|
+
world.remove(e, B);
|
|
330
|
+
// Should not throw and not affect existing data
|
|
331
|
+
world.sync();
|
|
332
|
+
|
|
333
|
+
expect(world.has(e, Pos)).toBe(true);
|
|
334
|
+
expect(world.get(e, Pos)).toEqual({ x: 10 });
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -111,15 +111,16 @@ describe("World - Component Management", () => {
|
|
|
111
111
|
const Inbox = component<string[]>({
|
|
112
112
|
merge: (prev, next) => [...prev, ...next],
|
|
113
113
|
});
|
|
114
|
+
const inbox = world.singleton(Inbox);
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
inbox.set(["A"]);
|
|
117
|
+
inbox.set(["B"]);
|
|
117
118
|
world.sync();
|
|
118
119
|
expect(world.get(Inbox)).toEqual(["A", "B"]);
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
inbox.remove();
|
|
122
|
+
inbox.set(["C"]);
|
|
123
|
+
inbox.set(["D"]);
|
|
123
124
|
world.sync();
|
|
124
125
|
expect(world.get(Inbox)).toEqual(["C", "D"]);
|
|
125
126
|
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, relation, type EntityId } from "../../entity";
|
|
3
|
+
import type { SyncDebugStats } from "../../types";
|
|
4
|
+
import { World } from "../../world/world";
|
|
5
|
+
|
|
6
|
+
describe("World - Debug Stats Collector", () => {
|
|
7
|
+
type Position = { x: number; y: number };
|
|
8
|
+
type Velocity = { x: number; y: number };
|
|
9
|
+
|
|
10
|
+
const Position = component<Position>();
|
|
11
|
+
const Velocity = component<Velocity>();
|
|
12
|
+
|
|
13
|
+
it("should deliver stats after sync when collector is active", () => {
|
|
14
|
+
const world = new World();
|
|
15
|
+
const entity = world.new();
|
|
16
|
+
|
|
17
|
+
const received: SyncDebugStats[] = [];
|
|
18
|
+
|
|
19
|
+
using _collector = world.createDebugStatsCollector((stats) => {
|
|
20
|
+
received.push(stats);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
world.set(entity, Position, { x: 1, y: 2 });
|
|
24
|
+
world.sync();
|
|
25
|
+
|
|
26
|
+
expect(received.length).toBe(1);
|
|
27
|
+
const stats = received[0]!;
|
|
28
|
+
expect(stats.commandIterations).toBeGreaterThanOrEqual(0);
|
|
29
|
+
expect(stats.entities.total).toBeGreaterThanOrEqual(1);
|
|
30
|
+
expect(stats.archetypes.total).toBeGreaterThanOrEqual(1);
|
|
31
|
+
expect(typeof stats.timestamps.syncStart).toBe("number");
|
|
32
|
+
expect(stats.timestamps.syncEnd).toBeGreaterThanOrEqual(stats.timestamps.syncStart);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should report archetype creation and removal activity", () => {
|
|
36
|
+
const world = new World();
|
|
37
|
+
const e1 = world.new();
|
|
38
|
+
const e2 = world.new();
|
|
39
|
+
|
|
40
|
+
const received: SyncDebugStats[] = [];
|
|
41
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
42
|
+
|
|
43
|
+
// First sync creates the empty archetype + entities
|
|
44
|
+
world.sync();
|
|
45
|
+
const afterFirst = received[received.length - 1]!;
|
|
46
|
+
|
|
47
|
+
// Cause archetype migrations by adding different components
|
|
48
|
+
world.set(e1, Position, { x: 0, y: 0 });
|
|
49
|
+
world.set(e2, Velocity, { x: 1, y: 1 });
|
|
50
|
+
world.sync();
|
|
51
|
+
|
|
52
|
+
const afterMigration = received[received.length - 1]!;
|
|
53
|
+
|
|
54
|
+
expect(afterMigration.activity.archetypesCreated).toBeGreaterThanOrEqual(0);
|
|
55
|
+
// We created at least one new archetype in the second sync
|
|
56
|
+
expect(afterMigration.activity.archetypesCreated + afterFirst.activity.archetypesCreated).toBeGreaterThan(0);
|
|
57
|
+
|
|
58
|
+
// Now remove components to potentially clean up archetypes
|
|
59
|
+
world.remove(e1, Position);
|
|
60
|
+
world.remove(e2, Velocity);
|
|
61
|
+
world.sync();
|
|
62
|
+
|
|
63
|
+
const afterRemove = received[received.length - 1]!;
|
|
64
|
+
expect(afterRemove.activity.archetypesRemoved).toBeGreaterThanOrEqual(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should count actual entity migrations", () => {
|
|
68
|
+
const world = new World();
|
|
69
|
+
const entity = world.new();
|
|
70
|
+
|
|
71
|
+
const received: SyncDebugStats[] = [];
|
|
72
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
73
|
+
|
|
74
|
+
world.set(entity, Position, { x: 1, y: 1 });
|
|
75
|
+
world.sync();
|
|
76
|
+
|
|
77
|
+
// Adding a second component should cause a migration
|
|
78
|
+
world.set(entity, Velocity, { x: 0, y: 0 });
|
|
79
|
+
world.sync();
|
|
80
|
+
|
|
81
|
+
const last = received[received.length - 1]!;
|
|
82
|
+
expect(last.activity.migrations).toBeGreaterThanOrEqual(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should count hook executions", () => {
|
|
86
|
+
const world = new World();
|
|
87
|
+
const entity = world.new();
|
|
88
|
+
|
|
89
|
+
const received: SyncDebugStats[] = [];
|
|
90
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
91
|
+
|
|
92
|
+
// Register a hook
|
|
93
|
+
world.hook([Position], {
|
|
94
|
+
on_set: () => {},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
world.set(entity, Position, { x: 10, y: 20 });
|
|
98
|
+
world.sync();
|
|
99
|
+
|
|
100
|
+
const stats = received[received.length - 1]!;
|
|
101
|
+
expect(stats.activity.hooksExecuted).toBeGreaterThanOrEqual(1);
|
|
102
|
+
expect(stats.hooks.total).toBeGreaterThanOrEqual(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should deliver identical object reference to multiple collectors", () => {
|
|
106
|
+
const world = new World();
|
|
107
|
+
const e = world.new();
|
|
108
|
+
|
|
109
|
+
const receivedA: SyncDebugStats[] = [];
|
|
110
|
+
const receivedB: SyncDebugStats[] = [];
|
|
111
|
+
|
|
112
|
+
using _c1 = world.createDebugStatsCollector((s) => receivedA.push(s));
|
|
113
|
+
using _c2 = world.createDebugStatsCollector((s) => receivedB.push(s));
|
|
114
|
+
|
|
115
|
+
world.set(e, Position, { x: 5, y: 5 });
|
|
116
|
+
world.sync();
|
|
117
|
+
|
|
118
|
+
expect(receivedA.length).toBe(1);
|
|
119
|
+
expect(receivedB.length).toBe(1);
|
|
120
|
+
expect(receivedA[0]).toBe(receivedB[0]); // exact same reference
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should stop delivering after collector is disposed", () => {
|
|
124
|
+
const world = new World();
|
|
125
|
+
const e = world.new();
|
|
126
|
+
|
|
127
|
+
const received: SyncDebugStats[] = [];
|
|
128
|
+
const collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
129
|
+
|
|
130
|
+
world.set(e, Position, { x: 1, y: 1 });
|
|
131
|
+
world.sync();
|
|
132
|
+
expect(received.length).toBe(1);
|
|
133
|
+
|
|
134
|
+
collector[Symbol.dispose]();
|
|
135
|
+
|
|
136
|
+
world.set(e, Position, { x: 2, y: 2 });
|
|
137
|
+
world.sync();
|
|
138
|
+
// Should not have received a second stats payload
|
|
139
|
+
expect(received.length).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should report command iterations for complex command batches", () => {
|
|
143
|
+
const world = new World();
|
|
144
|
+
const entities: EntityId[] = [];
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < 10; i++) {
|
|
147
|
+
entities.push(world.new());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const received: SyncDebugStats[] = [];
|
|
151
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
152
|
+
|
|
153
|
+
// Many sets on different entities should require at least one iteration
|
|
154
|
+
for (const e of entities) {
|
|
155
|
+
world.set(e, Position, { x: 1, y: 1 });
|
|
156
|
+
}
|
|
157
|
+
world.sync();
|
|
158
|
+
|
|
159
|
+
const stats = received[received.length - 1]!;
|
|
160
|
+
expect(stats.commandIterations).toBeGreaterThanOrEqual(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should only collect for syncs after the collector was created", () => {
|
|
164
|
+
const world = new World();
|
|
165
|
+
const e = world.new();
|
|
166
|
+
|
|
167
|
+
world.set(e, Position, { x: 1, y: 1 });
|
|
168
|
+
world.sync(); // This sync happens before any collector exists
|
|
169
|
+
|
|
170
|
+
const received: SyncDebugStats[] = [];
|
|
171
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
172
|
+
|
|
173
|
+
world.set(e, Position, { x: 2, y: 2 });
|
|
174
|
+
world.sync(); // This one should be observed
|
|
175
|
+
|
|
176
|
+
expect(received.length).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should observe activity and index changes from relations", () => {
|
|
180
|
+
const world = new World();
|
|
181
|
+
|
|
182
|
+
const ChildOf = component<void>({ exclusive: true });
|
|
183
|
+
|
|
184
|
+
const parent1 = world.new();
|
|
185
|
+
const parent2 = world.new();
|
|
186
|
+
const child = world.new();
|
|
187
|
+
|
|
188
|
+
const received: SyncDebugStats[] = [];
|
|
189
|
+
using _collector = world.createDebugStatsCollector((s) => received.push(s));
|
|
190
|
+
|
|
191
|
+
// Establish a relation (should populate entity reference indices)
|
|
192
|
+
world.set(child, relation(ChildOf, parent1));
|
|
193
|
+
world.sync();
|
|
194
|
+
|
|
195
|
+
const afterRelation = received[received.length - 1]!;
|
|
196
|
+
expect(afterRelation.indices.entityReferences).toBeGreaterThanOrEqual(1);
|
|
197
|
+
|
|
198
|
+
// Switching an exclusive relation should cause a migration/remove + add
|
|
199
|
+
world.set(child, relation(ChildOf, parent2));
|
|
200
|
+
world.sync();
|
|
201
|
+
|
|
202
|
+
const afterSwitch = received[received.length - 1]!;
|
|
203
|
+
// Exclusive relation flip typically causes at least one structural change
|
|
204
|
+
expect(afterSwitch.activity.migrations).toBeGreaterThanOrEqual(0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { component, type EntityId } from "../../entity";
|
|
3
|
+
import type { SyncDebugStats } from "../../types";
|
|
3
4
|
import { World } from "../../world/world";
|
|
4
5
|
|
|
5
6
|
describe("World - Multi-Component Hooks", () => {
|
|
@@ -11,6 +12,9 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
11
12
|
const setCalls: { entityId: EntityId; value: number }[] = [];
|
|
12
13
|
const removeCalls: { entityId: EntityId; value: number }[] = [];
|
|
13
14
|
|
|
15
|
+
const collectedStats: SyncDebugStats[] = [];
|
|
16
|
+
using _collector = world.createDebugStatsCollector((stats) => collectedStats.push(stats));
|
|
17
|
+
|
|
14
18
|
// First create an entity before registering the hook (for on_init test)
|
|
15
19
|
const existingEntity = world.spawn().with(A, 100).build();
|
|
16
20
|
world.sync();
|
|
@@ -64,6 +68,15 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
64
68
|
expect(removeCalls.length).toBe(2);
|
|
65
69
|
expect(removeCalls[1]!.entityId).toBe(existingEntity);
|
|
66
70
|
expect(removeCalls[1]!.value).toBe(100);
|
|
71
|
+
|
|
72
|
+
// Cross-verify with the new debug stats collector
|
|
73
|
+
const lastStats = collectedStats[collectedStats.length - 1];
|
|
74
|
+
expect(lastStats).toBeDefined();
|
|
75
|
+
expect(lastStats!.hooks.total).toBeGreaterThanOrEqual(1);
|
|
76
|
+
// Note: hooksExecuted counts individual invokeHook calls. The exact number
|
|
77
|
+
// can be lower than the sum of manual arrays depending on event paths.
|
|
78
|
+
// We only assert that at least some hook activity was recorded.
|
|
79
|
+
expect(lastStats!.activity.hooksExecuted).toBeGreaterThanOrEqual(1);
|
|
67
80
|
});
|
|
68
81
|
|
|
69
82
|
it("should throw error when hook has no required components (only optional)", () => {
|
|
@@ -499,4 +512,35 @@ describe("World - Multi-Component Hooks", () => {
|
|
|
499
512
|
expect(world.has(entity1, A)).toBe(true);
|
|
500
513
|
expect(world.has(entity2, A)).toBe(true);
|
|
501
514
|
});
|
|
515
|
+
|
|
516
|
+
it("should support callback-style registration for multi-component hooks (set/remove)", () => {
|
|
517
|
+
const world = new World();
|
|
518
|
+
const A = component<number>();
|
|
519
|
+
const B = component<string>();
|
|
520
|
+
|
|
521
|
+
const calls: { event: string; entityId: EntityId; components: any[] }[] = [];
|
|
522
|
+
|
|
523
|
+
// Register callback-style hook *before* any entities to ensure runtime set/remove
|
|
524
|
+
// go through invokeHook's callback branch (init replay for existing bypasses invokeHook).
|
|
525
|
+
const unhook = world.hook([A, B], (event, entityId, a, b) => {
|
|
526
|
+
calls.push({ event, entityId, components: [a, b] });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// New entity after registration → "set" via normal trigger path + invokeHook(callback)
|
|
530
|
+
const e = world.spawn().with(A, 42).with(B, "hi").build();
|
|
531
|
+
world.sync();
|
|
532
|
+
expect(calls.some((c) => c.event === "set" && c.entityId === e)).toBe(true);
|
|
533
|
+
|
|
534
|
+
// Update also triggers set
|
|
535
|
+
world.set(e, A, 100);
|
|
536
|
+
world.sync();
|
|
537
|
+
expect(calls.some((c) => c.event === "set" && c.components[0] === 100)).toBe(true);
|
|
538
|
+
|
|
539
|
+
// Remove required component → "remove" via invokeHook(callback)
|
|
540
|
+
world.remove(e, A);
|
|
541
|
+
world.sync();
|
|
542
|
+
expect(calls.some((c) => c.event === "remove" && c.entityId === e)).toBe(true);
|
|
543
|
+
|
|
544
|
+
unhook();
|
|
545
|
+
});
|
|
502
546
|
});
|
|
@@ -80,4 +80,21 @@ describe("World serialization", () => {
|
|
|
80
80
|
const c = restored.new();
|
|
81
81
|
expect(c).toBeGreaterThanOrEqual(b + 1);
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it("should serialize and deserialize component-relations", () => {
|
|
85
|
+
const world = new World();
|
|
86
|
+
const A = component<string>("A");
|
|
87
|
+
const B = component<number>("B");
|
|
88
|
+
const relAB = relation(A, B); // component-relation
|
|
89
|
+
|
|
90
|
+
const e = world.new();
|
|
91
|
+
world.set(e, relAB, "linked-via-comp");
|
|
92
|
+
world.sync();
|
|
93
|
+
|
|
94
|
+
const snapshot = world.serialize();
|
|
95
|
+
const restored = new World(snapshot);
|
|
96
|
+
|
|
97
|
+
expect(restored.has(e, relAB)).toBe(true);
|
|
98
|
+
expect(restored.get(e, relAB)).toBe("linked-via-comp");
|
|
99
|
+
});
|
|
83
100
|
});
|