@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { component, type EntityId } from "../../entity";
|
|
2
|
+
import { component, relation, type EntityId } from "../../entity";
|
|
3
|
+
import { decodeSerializedId, encodeEntityId, encodeEntityIdCached } from "../../storage/serialization";
|
|
3
4
|
import { World } from "../../world/world";
|
|
4
5
|
|
|
5
6
|
describe("Serialization edge cases", () => {
|
|
@@ -234,4 +235,136 @@ describe("Serialization edge cases", () => {
|
|
|
234
235
|
expect(newWorld.has(e3, Position)).toBe(false);
|
|
235
236
|
expect(newWorld.get(e3, Velocity)).toEqual({ vx: 15 });
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
it("should serialize and deserialize singleton components (covers componentEntities paths)", () => {
|
|
240
|
+
const world = new World();
|
|
241
|
+
const Config = component<{ debug: boolean }>();
|
|
242
|
+
// Singleton shorthand populates internal component entity
|
|
243
|
+
world.set(Config, { debug: true });
|
|
244
|
+
world.sync();
|
|
245
|
+
|
|
246
|
+
const snapshot = world.serialize();
|
|
247
|
+
const restored = new World(snapshot);
|
|
248
|
+
|
|
249
|
+
expect(restored.has(Config)).toBe(true);
|
|
250
|
+
expect(restored.get(Config)).toEqual({ debug: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should round-trip anonymous (no-name) components in entity-relations and component-relations", () => {
|
|
254
|
+
const world = new World();
|
|
255
|
+
const A = component<{ v: number }>(); // anonymous -> triggers numeric fallback + warn paths
|
|
256
|
+
const B = component<string>(); // also anonymous for target in comp-rel
|
|
257
|
+
const e1 = world.new();
|
|
258
|
+
const e2 = world.new();
|
|
259
|
+
const relE = relation(A, e1);
|
|
260
|
+
const relC = relation(A, B);
|
|
261
|
+
world.set(e2, relE, { v: 42 });
|
|
262
|
+
world.set(e2, relC, { v: 99 });
|
|
263
|
+
world.sync();
|
|
264
|
+
|
|
265
|
+
const snap = world.serialize();
|
|
266
|
+
const r = new World(snap);
|
|
267
|
+
|
|
268
|
+
expect(r.has(e2, relE)).toBe(true);
|
|
269
|
+
expect(r.get(e2, relE)).toEqual({ v: 42 });
|
|
270
|
+
expect(r.has(e2, relC)).toBe(true);
|
|
271
|
+
expect(r.get(e2, relC)).toEqual({ v: 99 });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("Serialization ID codec (low-level, covers all decode/encode branches)", () => {
|
|
275
|
+
it("should exercise cache hit/miss and no-cache path in encodeEntityIdCached", () => {
|
|
276
|
+
const C = component<number>();
|
|
277
|
+
const cache = new Map();
|
|
278
|
+
const c1 = encodeEntityIdCached(C, cache);
|
|
279
|
+
const c2 = encodeEntityIdCached(C, cache);
|
|
280
|
+
expect(c2).toBe(c1); // hit
|
|
281
|
+
const noCache = encodeEntityIdCached(C); // else branch (no cache provided)
|
|
282
|
+
expect(noCache).toBeDefined();
|
|
283
|
+
// also wrapper
|
|
284
|
+
expect(encodeEntityId(C)).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should round-trip all relation kinds including wildcard via encode/decode", () => {
|
|
288
|
+
const C = component<boolean>();
|
|
289
|
+
const E = 9999 as EntityId<any>;
|
|
290
|
+
const relE = relation(C, E);
|
|
291
|
+
const C2 = component<string>();
|
|
292
|
+
const relC = relation(C, C2);
|
|
293
|
+
const wild = relation(C, "*");
|
|
294
|
+
|
|
295
|
+
expect(decodeSerializedId(encodeEntityId(relE))).toBe(relE);
|
|
296
|
+
expect(decodeSerializedId(encodeEntityId(relC))).toBe(relC);
|
|
297
|
+
expect(decodeSerializedId(encodeEntityId(wild))).toBe(wild);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should hit numeric string fallbacks and all error throws in decode", () => {
|
|
301
|
+
const C1 = component<number>();
|
|
302
|
+
const C2 = component<string>();
|
|
303
|
+
const id1 = C1 as unknown as number;
|
|
304
|
+
const id2 = C2 as unknown as number;
|
|
305
|
+
|
|
306
|
+
// numeric fallback paths (when name lookup fails; use real allocated IDs as strings)
|
|
307
|
+
expect(decodeSerializedId(String(id1) as any)).toBe(id1 as any);
|
|
308
|
+
expect(decodeSerializedId({ component: String(id2), target: 99999 } as any)).toBeDefined(); // component-relation numeric fallback; creates valid relation(C2, fakeTarget)
|
|
309
|
+
|
|
310
|
+
// error paths (unknown names hit throws before relation() ctor)
|
|
311
|
+
expect(() => decodeSerializedId("TotallyUnknownName!!" as any)).toThrow(/Unknown component name in snapshot/);
|
|
312
|
+
expect(() => decodeSerializedId({ component: "BadName", target: 1 } as any)).toThrow(
|
|
313
|
+
/Unknown component name in snapshot/,
|
|
314
|
+
);
|
|
315
|
+
expect(() => decodeSerializedId({ component: "123", target: "BadTargetName" } as any)).toThrow(
|
|
316
|
+
/Unknown target component name in snapshot/,
|
|
317
|
+
);
|
|
318
|
+
expect(() => decodeSerializedId({ foo: "bar" } as any)).toThrow(/Invalid ID in snapshot/);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("Entity-ID-as-componentType references (ad-hoc entity refs)", () => {
|
|
323
|
+
it("should round-trip worlds using raw EntityIds as component types (covers 'entity' branch in deserialize reference tracking)", () => {
|
|
324
|
+
const world = new World();
|
|
325
|
+
|
|
326
|
+
const eTarget = world.new();
|
|
327
|
+
const eHolder = world.new();
|
|
328
|
+
|
|
329
|
+
// Use a raw entity ID (>= ENTITY_ID_START) directly as a component "type".
|
|
330
|
+
// This models an untyped/ad-hoc reference to another entity.
|
|
331
|
+
// The value is omitted (void presence-only component).
|
|
332
|
+
world.set(eHolder, eTarget as unknown as EntityId<any>);
|
|
333
|
+
world.sync();
|
|
334
|
+
|
|
335
|
+
const snapshot = world.serialize();
|
|
336
|
+
const restored = new World(snapshot);
|
|
337
|
+
|
|
338
|
+
expect(restored.exists(eTarget)).toBe(true);
|
|
339
|
+
expect(restored.exists(eHolder)).toBe(true);
|
|
340
|
+
|
|
341
|
+
// The ad-hoc component must survive the roundtrip.
|
|
342
|
+
expect(restored.has(eHolder, eTarget as unknown as EntityId<any>)).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("ComponentEntities deserialization guards", () => {
|
|
347
|
+
it("should ignore componentEntities snapshot entries whose id is not a real component entity (covers continue guard)", () => {
|
|
348
|
+
const world = new World();
|
|
349
|
+
const Config = component<{ debug: boolean }>();
|
|
350
|
+
world.set(Config, { debug: true });
|
|
351
|
+
world.sync();
|
|
352
|
+
|
|
353
|
+
const snap: any = world.serialize();
|
|
354
|
+
|
|
355
|
+
// Inject a bogus entry: a high ordinary entity id (never a component entity)
|
|
356
|
+
const bogus = 123456 as EntityId;
|
|
357
|
+
snap.componentEntities = snap.componentEntities || [];
|
|
358
|
+
snap.componentEntities.push({
|
|
359
|
+
id: bogus,
|
|
360
|
+
components: [{ type: 99, value: "should-be-ignored" }],
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const restored = new World(snap);
|
|
364
|
+
|
|
365
|
+
// Real singleton still works; bogus entry was skipped without error
|
|
366
|
+
expect(restored.has(Config)).toBe(true);
|
|
367
|
+
expect(restored.get(Config)).toEqual({ debug: true });
|
|
368
|
+
});
|
|
369
|
+
});
|
|
237
370
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|