@codehz/ecs 0.8.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +26 -3
- package/README.md +41 -4
- package/dist/builder.d.mts +348 -83
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/world.mjs +1922 -1400
- package/dist/world.mjs.map +1 -1
- package/examples/debug-observability.ts +92 -0
- package/examples/inventory-system-relations.ts +1 -1
- package/examples/parent-child-hierarchy.ts +18 -38
- package/examples/spatial-grid.ts +1 -1
- package/package.json +1 -1
- package/skills/ecs/SKILL.md +4 -4
- package/src/__tests__/component/singleton.test.ts +116 -35
- package/src/__tests__/core/archetype.test.ts +155 -13
- package/src/__tests__/core/bitset.test.ts +12 -0
- package/src/__tests__/entity/entity.test.ts +33 -0
- package/src/__tests__/entity/id-system.test.ts +40 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
- package/src/__tests__/perf/serialization.perf.test.ts +242 -0
- package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
- package/src/__tests__/query/caching.test.ts +62 -0
- package/src/__tests__/query/filter.test.ts +16 -22
- package/src/__tests__/query/perf.test.ts +3 -5
- package/src/__tests__/relations/hierarchy.test.ts +208 -0
- package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
- package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
- package/src/__tests__/serialization/bounds.test.ts +133 -1
- package/src/__tests__/world/commands.test.ts +337 -0
- package/src/__tests__/world/component-management.test.ts +6 -5
- package/src/__tests__/world/debug-stats.test.ts +206 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
- package/src/__tests__/world/serialize.test.ts +17 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
- package/src/archetype/archetype.ts +96 -46
- package/src/archetype/helpers.ts +7 -29
- package/src/archetype/store.ts +35 -20
- package/src/commands/buffer.ts +5 -2
- package/src/commands/changeset.ts +0 -31
- package/src/component/registry.ts +64 -63
- package/src/entity/index.ts +6 -3
- package/src/index.ts +15 -0
- package/src/query/filter.ts +4 -10
- package/src/query/query.ts +12 -12
- package/src/storage/serialization.ts +29 -2
- package/src/types/index.ts +71 -0
- package/src/world/archetype-manager.ts +283 -0
- package/src/world/command-executor.ts +258 -0
- package/src/world/commands.ts +44 -56
- package/src/world/debug-stats.ts +147 -0
- package/src/world/hooks.ts +8 -0
- package/src/world/operations.ts +88 -0
- package/src/world/serialization.ts +32 -18
- package/src/world/singleton.ts +51 -0
- package/src/world/world.ts +429 -457
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { component, relation, type EntityId } from "../../../entity";
|
|
3
|
+
import type { SyncDebugStats } from "../../../types";
|
|
3
4
|
import { World } from "../../../world/world";
|
|
4
5
|
|
|
5
|
-
describe("
|
|
6
|
-
it("should prevent archetype fragmentation for
|
|
6
|
+
describe("Sparse Relations", () => {
|
|
7
|
+
it("should prevent archetype fragmentation for sparse relations", () => {
|
|
7
8
|
const world = new World();
|
|
8
9
|
|
|
9
10
|
// Create component types
|
|
@@ -11,8 +12,11 @@ describe("DontFragment Relations", () => {
|
|
|
11
12
|
const PositionId = component<Position>();
|
|
12
13
|
const VelocityId = component();
|
|
13
14
|
|
|
14
|
-
// Create ChildOf with
|
|
15
|
-
const ChildOf = component({
|
|
15
|
+
// Create ChildOf with sparse option
|
|
16
|
+
const ChildOf = component({ sparse: true });
|
|
17
|
+
|
|
18
|
+
const collected: SyncDebugStats[] = [];
|
|
19
|
+
using _collector = world.createDebugStatsCollector((s) => collected.push(s));
|
|
16
20
|
|
|
17
21
|
// Create parent entities
|
|
18
22
|
const parent1 = world.new();
|
|
@@ -37,20 +41,10 @@ describe("DontFragment Relations", () => {
|
|
|
37
41
|
|
|
38
42
|
world.sync();
|
|
39
43
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Count archetypes with Position and Velocity
|
|
46
|
-
const matchingArchetypes = archetypes.filter((arch: any) => {
|
|
47
|
-
const types = arch.componentTypes;
|
|
48
|
-
return types.includes(PositionId) && types.includes(VelocityId);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// All three children should be in the SAME archetype
|
|
52
|
-
expect(matchingArchetypes.length).toBe(1);
|
|
53
|
-
expect(matchingArchetypes[0].size).toBe(3);
|
|
44
|
+
// Use debug stats to confirm low archetype count (no fragmentation)
|
|
45
|
+
const lastStats = collected[collected.length - 1]!;
|
|
46
|
+
// With sparse, 3 children + different parents should not explode archetype count
|
|
47
|
+
expect(lastStats.archetypes.total).toBeLessThanOrEqual(4);
|
|
54
48
|
|
|
55
49
|
// Verify we can still access the relations
|
|
56
50
|
expect(world.has(child1, relation(ChildOf, parent1))).toBe(true);
|
|
@@ -62,11 +56,11 @@ describe("DontFragment Relations", () => {
|
|
|
62
56
|
expect(entities.length).toBe(3);
|
|
63
57
|
});
|
|
64
58
|
|
|
65
|
-
it("should handle
|
|
59
|
+
it("should handle sparse relations with wildcard queries", () => {
|
|
66
60
|
const world = new World();
|
|
67
61
|
|
|
68
62
|
const PositionId = component();
|
|
69
|
-
const ChildOf = component({
|
|
63
|
+
const ChildOf = component({ sparse: true });
|
|
70
64
|
|
|
71
65
|
const parent1 = world.new();
|
|
72
66
|
const parent2 = world.new();
|
|
@@ -81,7 +75,7 @@ describe("DontFragment Relations", () => {
|
|
|
81
75
|
|
|
82
76
|
world.sync();
|
|
83
77
|
|
|
84
|
-
// Wildcard query should work with
|
|
78
|
+
// Wildcard query should work with sparse relations
|
|
85
79
|
const wildcardChildOf = relation(ChildOf, "*");
|
|
86
80
|
const child1Relations = world.get(child1, wildcardChildOf);
|
|
87
81
|
const child2Relations = world.get(child2, wildcardChildOf);
|
|
@@ -93,10 +87,10 @@ describe("DontFragment Relations", () => {
|
|
|
93
87
|
expect(child2Relations[0]![0]).toBe(parent2);
|
|
94
88
|
});
|
|
95
89
|
|
|
96
|
-
it("should allow updating
|
|
90
|
+
it("should allow updating sparse relations", () => {
|
|
97
91
|
const world = new World();
|
|
98
92
|
|
|
99
|
-
const ChildOf = component({
|
|
93
|
+
const ChildOf = component({ sparse: true, exclusive: true });
|
|
100
94
|
const PositionId = component();
|
|
101
95
|
|
|
102
96
|
const parent1 = world.new();
|
|
@@ -117,10 +111,10 @@ describe("DontFragment Relations", () => {
|
|
|
117
111
|
expect(world.has(child, relation(ChildOf, parent2))).toBe(true);
|
|
118
112
|
});
|
|
119
113
|
|
|
120
|
-
it("should handle removing
|
|
114
|
+
it("should handle removing sparse relations", () => {
|
|
121
115
|
const world = new World();
|
|
122
116
|
|
|
123
|
-
const ChildOf = component({
|
|
117
|
+
const ChildOf = component({ sparse: true });
|
|
124
118
|
const PositionId = component();
|
|
125
119
|
|
|
126
120
|
const parent = world.new();
|
|
@@ -140,17 +134,20 @@ describe("DontFragment Relations", () => {
|
|
|
140
134
|
expect(world.has(child, PositionId)).toBe(true);
|
|
141
135
|
});
|
|
142
136
|
|
|
143
|
-
it("should handle queries with
|
|
137
|
+
it("should handle queries with sparse relations", () => {
|
|
144
138
|
const world = new World();
|
|
145
139
|
|
|
146
140
|
const PositionId = component();
|
|
147
141
|
const VelocityId = component();
|
|
148
|
-
const ChildOf = component({
|
|
142
|
+
const ChildOf = component({ sparse: true });
|
|
143
|
+
|
|
144
|
+
const collected: SyncDebugStats[] = [];
|
|
145
|
+
using _collector = world.createDebugStatsCollector((s) => collected.push(s));
|
|
149
146
|
|
|
150
147
|
const parent1 = world.new();
|
|
151
148
|
const parent2 = world.new();
|
|
152
149
|
|
|
153
|
-
// Create entities with
|
|
150
|
+
// Create entities with sparse relations
|
|
154
151
|
for (let i = 0; i < 10; i++) {
|
|
155
152
|
const entity = world.new();
|
|
156
153
|
world.set(entity, PositionId);
|
|
@@ -165,19 +162,19 @@ describe("DontFragment Relations", () => {
|
|
|
165
162
|
const entities = query.getEntities();
|
|
166
163
|
expect(entities.length).toBe(10);
|
|
167
164
|
|
|
168
|
-
//
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
return arch.componentTypes.includes(PositionId) && arch.componentTypes.includes(VelocityId);
|
|
172
|
-
});
|
|
173
|
-
expect(matchingArchetypes.length).toBe(1);
|
|
165
|
+
// Use debug collector to verify we stayed in a single archetype despite 10 different parents
|
|
166
|
+
const stats = collected[collected.length - 1]!;
|
|
167
|
+
expect(stats.archetypes.total).toBeLessThanOrEqual(3);
|
|
174
168
|
});
|
|
175
169
|
|
|
176
|
-
it("should compare fragmentation: with and without
|
|
177
|
-
// Test WITHOUT
|
|
170
|
+
it("should compare fragmentation: with and without sparse", () => {
|
|
171
|
+
// Test WITHOUT sparse (causes fragmentation)
|
|
178
172
|
const world1 = new World();
|
|
179
173
|
const PositionId1 = component();
|
|
180
|
-
const ChildOf1 = component(); // No
|
|
174
|
+
const ChildOf1 = component(); // No sparse
|
|
175
|
+
|
|
176
|
+
const stats1: SyncDebugStats[] = [];
|
|
177
|
+
using _collector1 = world1.createDebugStatsCollector((s) => stats1.push(s));
|
|
181
178
|
|
|
182
179
|
for (let i = 0; i < 5; i++) {
|
|
183
180
|
const parent = world1.new();
|
|
@@ -187,14 +184,13 @@ describe("DontFragment Relations", () => {
|
|
|
187
184
|
}
|
|
188
185
|
world1.sync();
|
|
189
186
|
|
|
190
|
-
|
|
191
|
-
return arch.componentTypes.includes(PositionId1);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// Test WITH dontFragment (prevents fragmentation)
|
|
187
|
+
// Test WITH sparse (prevents fragmentation)
|
|
195
188
|
const world2 = new World();
|
|
196
189
|
const PositionId2 = component();
|
|
197
|
-
const ChildOf2 = component({
|
|
190
|
+
const ChildOf2 = component({ sparse: true }); // With sparse
|
|
191
|
+
|
|
192
|
+
const stats2: SyncDebugStats[] = [];
|
|
193
|
+
using _collector2 = world2.createDebugStatsCollector((s) => stats2.push(s));
|
|
198
194
|
|
|
199
195
|
for (let i = 0; i < 5; i++) {
|
|
200
196
|
const parent = world2.new();
|
|
@@ -204,23 +200,22 @@ describe("DontFragment Relations", () => {
|
|
|
204
200
|
}
|
|
205
201
|
world2.sync();
|
|
206
202
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
});
|
|
203
|
+
const last1 = stats1[stats1.length - 1]!;
|
|
204
|
+
const last2 = stats2[stats2.length - 1]!;
|
|
210
205
|
|
|
211
|
-
// Without
|
|
212
|
-
|
|
206
|
+
// Without sparse: we expect significantly more archetypes created due to fragmentation
|
|
207
|
+
// (one per unique parent relation target)
|
|
208
|
+
expect(last1.archetypes.total).toBeGreaterThan(last2.archetypes.total);
|
|
213
209
|
|
|
214
|
-
// With
|
|
215
|
-
expect(
|
|
216
|
-
expect(archetypes2[0].size).toBe(5);
|
|
210
|
+
// With sparse: far fewer archetypes for the same number of entities
|
|
211
|
+
expect(last2.archetypes.total).toBeLessThanOrEqual(3); // entities + relations archetype(s)
|
|
217
212
|
});
|
|
218
213
|
|
|
219
|
-
it("should query entities with wildcard relation on
|
|
214
|
+
it("should query entities with wildcard relation on sparse component using createQuery", () => {
|
|
220
215
|
const world = new World();
|
|
221
216
|
|
|
222
217
|
const PositionId = component();
|
|
223
|
-
const ChildOf = component({
|
|
218
|
+
const ChildOf = component({ sparse: true });
|
|
224
219
|
|
|
225
220
|
const parent1 = world.new();
|
|
226
221
|
const parent2 = world.new();
|
|
@@ -246,12 +241,12 @@ describe("DontFragment Relations", () => {
|
|
|
246
241
|
expect(entities).toContain(child2);
|
|
247
242
|
});
|
|
248
243
|
|
|
249
|
-
it("should query entities with wildcard relation + other components on
|
|
244
|
+
it("should query entities with wildcard relation + other components on sparse", () => {
|
|
250
245
|
const world = new World();
|
|
251
246
|
|
|
252
247
|
const PositionId = component();
|
|
253
248
|
const VelocityId = component();
|
|
254
|
-
const ChildOf = component({
|
|
249
|
+
const ChildOf = component({ sparse: true });
|
|
255
250
|
|
|
256
251
|
const parent1 = world.new();
|
|
257
252
|
const parent2 = world.new();
|
|
@@ -285,17 +280,17 @@ describe("DontFragment Relations", () => {
|
|
|
285
280
|
expect(entities).not.toContain(child3);
|
|
286
281
|
});
|
|
287
282
|
|
|
288
|
-
it("should correctly cleanup
|
|
283
|
+
it("should correctly cleanup sparse relations when target entity is destroyed", () => {
|
|
289
284
|
const world = new World();
|
|
290
285
|
|
|
291
286
|
const PositionId = component();
|
|
292
287
|
const VelocityId = component();
|
|
293
|
-
const ChildOf = component({
|
|
288
|
+
const ChildOf = component({ sparse: true });
|
|
294
289
|
|
|
295
290
|
const parent1 = world.new();
|
|
296
291
|
const parent2 = world.new();
|
|
297
292
|
|
|
298
|
-
// Create children with
|
|
293
|
+
// Create children with sparse relations
|
|
299
294
|
const child1 = world.new();
|
|
300
295
|
world.set(child1, PositionId);
|
|
301
296
|
world.set(child1, VelocityId);
|
|
@@ -313,7 +308,7 @@ describe("DontFragment Relations", () => {
|
|
|
313
308
|
|
|
314
309
|
world.sync();
|
|
315
310
|
|
|
316
|
-
// All children should be in the same archetype (due to
|
|
311
|
+
// All children should be in the same archetype (due to sparse)
|
|
317
312
|
const archetypes = (world as any).archetypes;
|
|
318
313
|
const matchingArchetypesBefore = archetypes.filter((arch: any) => {
|
|
319
314
|
return arch.componentTypes.includes(PositionId) && arch.componentTypes.includes(VelocityId);
|
|
@@ -356,11 +351,11 @@ describe("DontFragment Relations", () => {
|
|
|
356
351
|
expect(matchingArchetypesAfter.length).toBe(2);
|
|
357
352
|
});
|
|
358
353
|
|
|
359
|
-
it("should not create new archetypes when removing
|
|
354
|
+
it("should not create new archetypes when removing sparse relation from entity", () => {
|
|
360
355
|
const world = new World();
|
|
361
356
|
|
|
362
357
|
const PositionId = component();
|
|
363
|
-
const ChildOf = component({
|
|
358
|
+
const ChildOf = component({ sparse: true });
|
|
364
359
|
|
|
365
360
|
const parent1 = world.new();
|
|
366
361
|
const parent2 = world.new();
|
|
@@ -398,10 +393,10 @@ describe("DontFragment Relations", () => {
|
|
|
398
393
|
}
|
|
399
394
|
});
|
|
400
395
|
|
|
401
|
-
it("should trigger lifecycle hooks when
|
|
396
|
+
it("should trigger lifecycle hooks when sparse relations are removed due to entity destruction", () => {
|
|
402
397
|
const world = new World();
|
|
403
398
|
|
|
404
|
-
const ChildOf = component({
|
|
399
|
+
const ChildOf = component({ sparse: true });
|
|
405
400
|
const PositionId = component();
|
|
406
401
|
|
|
407
402
|
const parent = world.new();
|
|
@@ -429,12 +424,12 @@ describe("DontFragment Relations", () => {
|
|
|
429
424
|
expect(removedRelations[0]!.relations).toEqual([[parent, undefined]]);
|
|
430
425
|
});
|
|
431
426
|
|
|
432
|
-
it("should handle cascade delete with
|
|
427
|
+
it("should handle cascade delete with sparse relations correctly", () => {
|
|
433
428
|
const world = new World();
|
|
434
429
|
|
|
435
430
|
const PositionId = component();
|
|
436
|
-
// Cascade delete AND
|
|
437
|
-
const ChildOf = component({
|
|
431
|
+
// Cascade delete AND sparse - when parent dies, children die too
|
|
432
|
+
const ChildOf = component({ sparse: true, cascadeDelete: true });
|
|
438
433
|
|
|
439
434
|
const grandparent = world.new();
|
|
440
435
|
const parent = world.new();
|
|
@@ -462,16 +457,16 @@ describe("DontFragment Relations", () => {
|
|
|
462
457
|
expect(world.exists(child)).toBe(false);
|
|
463
458
|
});
|
|
464
459
|
|
|
465
|
-
it("should maintain entity archetype integrity when removing
|
|
460
|
+
it("should maintain entity archetype integrity when removing sparse relations", () => {
|
|
466
461
|
const world = new World();
|
|
467
462
|
|
|
468
463
|
const PositionId = component<{ x: number; y: number }>();
|
|
469
464
|
const VelocityId = component<{ vx: number; vy: number }>();
|
|
470
|
-
const ChildOf = component({
|
|
465
|
+
const ChildOf = component({ sparse: true });
|
|
471
466
|
|
|
472
467
|
const parent = world.new();
|
|
473
468
|
|
|
474
|
-
// Create entity with components and
|
|
469
|
+
// Create entity with components and sparse relation
|
|
475
470
|
const entity = world.new();
|
|
476
471
|
world.set(entity, PositionId, { x: 10, y: 20 });
|
|
477
472
|
world.set(entity, VelocityId, { vx: 1, vy: 2 });
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { component, relation } from "../../../entity";
|
|
3
|
+
import type { SyncDebugStats } from "../../../types";
|
|
3
4
|
import { World } from "../../../world/world";
|
|
4
5
|
|
|
5
|
-
describe("
|
|
6
|
-
it("should handle
|
|
6
|
+
describe("Sparse Query Notification Issue", () => {
|
|
7
|
+
it("should handle sparse wildcard queries and archetype lifecycle", () => {
|
|
7
8
|
const world = new World();
|
|
8
9
|
const Position = component();
|
|
9
|
-
const ChildOf = component({
|
|
10
|
+
const ChildOf = component({ sparse: true });
|
|
10
11
|
const WildcardChildOf = relation(ChildOf, "*");
|
|
11
12
|
|
|
13
|
+
const collected: SyncDebugStats[] = [];
|
|
14
|
+
using _collector = world.createDebugStatsCollector((s) => collected.push(s));
|
|
15
|
+
|
|
12
16
|
const query = world.createQuery([WildcardChildOf, Position]);
|
|
13
17
|
expect(query.getEntities().length).toBe(0);
|
|
14
18
|
|
|
@@ -41,11 +45,15 @@ describe("DontFragment Query Notification Issue", () => {
|
|
|
41
45
|
world.sync();
|
|
42
46
|
expect(query.getEntities()).not.toContain(child1);
|
|
43
47
|
expect((world as any).entityToArchetype.get(child1).componentTypes).not.toContain(WildcardChildOf);
|
|
48
|
+
|
|
49
|
+
// Debug stats should reflect archetype lifecycle changes from the wildcard marker add/remove
|
|
50
|
+
const lastStats = collected[collected.length - 1]!;
|
|
51
|
+
expect(lastStats.archetypes.total).toBeGreaterThanOrEqual(1);
|
|
44
52
|
});
|
|
45
53
|
|
|
46
|
-
it("should handle exclusive
|
|
54
|
+
it("should handle exclusive sparse relations and specific target queries", () => {
|
|
47
55
|
const world = new World();
|
|
48
|
-
const ChildOf = component({
|
|
56
|
+
const ChildOf = component({ sparse: true, exclusive: true });
|
|
49
57
|
const p1 = world.new();
|
|
50
58
|
const p2 = world.new();
|
|
51
59
|
const entity = world.new();
|
|
@@ -75,9 +83,9 @@ describe("DontFragment Query Notification Issue", () => {
|
|
|
75
83
|
expect(wildcardQuery.getEntities()).toContain(entity);
|
|
76
84
|
});
|
|
77
85
|
|
|
78
|
-
it("should handle multiple non-exclusive
|
|
86
|
+
it("should handle multiple non-exclusive sparse relations", () => {
|
|
79
87
|
const world = new World();
|
|
80
|
-
const Tag = component({
|
|
88
|
+
const Tag = component({ sparse: true });
|
|
81
89
|
const t1 = world.new();
|
|
82
90
|
const t2 = world.new();
|
|
83
91
|
const entity = world.new();
|
|
@@ -103,8 +111,8 @@ describe("DontFragment Query Notification Issue", () => {
|
|
|
103
111
|
|
|
104
112
|
it("should correctly filter false positives in wildcard queries", () => {
|
|
105
113
|
const world = new World();
|
|
106
|
-
const TagA = component({
|
|
107
|
-
const TagB = component({
|
|
114
|
+
const TagA = component({ sparse: true });
|
|
115
|
+
const TagB = component({ sparse: true });
|
|
108
116
|
const p = world.new();
|
|
109
117
|
|
|
110
118
|
const e1 = world.new();
|
|
@@ -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,135 @@ 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
|
+
world.singleton(Config).set({ debug: true });
|
|
243
|
+
world.sync();
|
|
244
|
+
|
|
245
|
+
const snapshot = world.serialize();
|
|
246
|
+
const restored = new World(snapshot);
|
|
247
|
+
|
|
248
|
+
expect(restored.has(Config)).toBe(true);
|
|
249
|
+
expect(restored.get(Config)).toEqual({ debug: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should round-trip anonymous (no-name) components in entity-relations and component-relations", () => {
|
|
253
|
+
const world = new World();
|
|
254
|
+
const A = component<{ v: number }>(); // anonymous -> triggers numeric fallback + warn paths
|
|
255
|
+
const B = component<string>(); // also anonymous for target in comp-rel
|
|
256
|
+
const e1 = world.new();
|
|
257
|
+
const e2 = world.new();
|
|
258
|
+
const relE = relation(A, e1);
|
|
259
|
+
const relC = relation(A, B);
|
|
260
|
+
world.set(e2, relE, { v: 42 });
|
|
261
|
+
world.set(e2, relC, { v: 99 });
|
|
262
|
+
world.sync();
|
|
263
|
+
|
|
264
|
+
const snap = world.serialize();
|
|
265
|
+
const r = new World(snap);
|
|
266
|
+
|
|
267
|
+
expect(r.has(e2, relE)).toBe(true);
|
|
268
|
+
expect(r.get(e2, relE)).toEqual({ v: 42 });
|
|
269
|
+
expect(r.has(e2, relC)).toBe(true);
|
|
270
|
+
expect(r.get(e2, relC)).toEqual({ v: 99 });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("Serialization ID codec (low-level, covers all decode/encode branches)", () => {
|
|
274
|
+
it("should exercise cache hit/miss and no-cache path in encodeEntityIdCached", () => {
|
|
275
|
+
const C = component<number>();
|
|
276
|
+
const cache = new Map();
|
|
277
|
+
const c1 = encodeEntityIdCached(C, cache);
|
|
278
|
+
const c2 = encodeEntityIdCached(C, cache);
|
|
279
|
+
expect(c2).toBe(c1); // hit
|
|
280
|
+
const noCache = encodeEntityIdCached(C); // else branch (no cache provided)
|
|
281
|
+
expect(noCache).toBeDefined();
|
|
282
|
+
// also wrapper
|
|
283
|
+
expect(encodeEntityId(C)).toBeDefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should round-trip all relation kinds including wildcard via encode/decode", () => {
|
|
287
|
+
const C = component<boolean>();
|
|
288
|
+
const E = 9999 as EntityId<any>;
|
|
289
|
+
const relE = relation(C, E);
|
|
290
|
+
const C2 = component<string>();
|
|
291
|
+
const relC = relation(C, C2);
|
|
292
|
+
const wild = relation(C, "*");
|
|
293
|
+
|
|
294
|
+
expect(decodeSerializedId(encodeEntityId(relE))).toBe(relE);
|
|
295
|
+
expect(decodeSerializedId(encodeEntityId(relC))).toBe(relC);
|
|
296
|
+
expect(decodeSerializedId(encodeEntityId(wild))).toBe(wild);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("should hit numeric string fallbacks and all error throws in decode", () => {
|
|
300
|
+
const C1 = component<number>();
|
|
301
|
+
const C2 = component<string>();
|
|
302
|
+
const id1 = C1 as unknown as number;
|
|
303
|
+
const id2 = C2 as unknown as number;
|
|
304
|
+
|
|
305
|
+
// numeric fallback paths (when name lookup fails; use real allocated IDs as strings)
|
|
306
|
+
expect(decodeSerializedId(String(id1) as any)).toBe(id1 as any);
|
|
307
|
+
expect(decodeSerializedId({ component: String(id2), target: 99999 } as any)).toBeDefined(); // component-relation numeric fallback; creates valid relation(C2, fakeTarget)
|
|
308
|
+
|
|
309
|
+
// error paths (unknown names hit throws before relation() ctor)
|
|
310
|
+
expect(() => decodeSerializedId("TotallyUnknownName!!" as any)).toThrow(/Unknown component name in snapshot/);
|
|
311
|
+
expect(() => decodeSerializedId({ component: "BadName", target: 1 } as any)).toThrow(
|
|
312
|
+
/Unknown component name in snapshot/,
|
|
313
|
+
);
|
|
314
|
+
expect(() => decodeSerializedId({ component: "123", target: "BadTargetName" } as any)).toThrow(
|
|
315
|
+
/Unknown target component name in snapshot/,
|
|
316
|
+
);
|
|
317
|
+
expect(() => decodeSerializedId({ foo: "bar" } as any)).toThrow(/Invalid ID in snapshot/);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("Entity-ID-as-componentType references (ad-hoc entity refs)", () => {
|
|
322
|
+
it("should round-trip worlds using raw EntityIds as component types (covers 'entity' branch in deserialize reference tracking)", () => {
|
|
323
|
+
const world = new World();
|
|
324
|
+
|
|
325
|
+
const eTarget = world.new();
|
|
326
|
+
const eHolder = world.new();
|
|
327
|
+
|
|
328
|
+
// Use a raw entity ID (>= ENTITY_ID_START) directly as a component "type".
|
|
329
|
+
// This models an untyped/ad-hoc reference to another entity.
|
|
330
|
+
// The value is omitted (void presence-only component).
|
|
331
|
+
world.set(eHolder, eTarget as unknown as EntityId<any>);
|
|
332
|
+
world.sync();
|
|
333
|
+
|
|
334
|
+
const snapshot = world.serialize();
|
|
335
|
+
const restored = new World(snapshot);
|
|
336
|
+
|
|
337
|
+
expect(restored.exists(eTarget)).toBe(true);
|
|
338
|
+
expect(restored.exists(eHolder)).toBe(true);
|
|
339
|
+
|
|
340
|
+
// The ad-hoc component must survive the roundtrip.
|
|
341
|
+
expect(restored.has(eHolder, eTarget as unknown as EntityId<any>)).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("ComponentEntities deserialization guards", () => {
|
|
346
|
+
it("should ignore componentEntities snapshot entries whose id is not a real component entity (covers continue guard)", () => {
|
|
347
|
+
const world = new World();
|
|
348
|
+
const Config = component<{ debug: boolean }>();
|
|
349
|
+
world.singleton(Config).set({ debug: true });
|
|
350
|
+
world.sync();
|
|
351
|
+
|
|
352
|
+
const snap: any = world.serialize();
|
|
353
|
+
|
|
354
|
+
// Inject a bogus entry: a high ordinary entity id (never a component entity)
|
|
355
|
+
const bogus = 123456 as EntityId;
|
|
356
|
+
snap.componentEntities = snap.componentEntities || [];
|
|
357
|
+
snap.componentEntities.push({
|
|
358
|
+
id: bogus,
|
|
359
|
+
components: [{ type: 99, value: "should-be-ignored" }],
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const restored = new World(snap);
|
|
363
|
+
|
|
364
|
+
// Real singleton still works; bogus entry was skipped without error
|
|
365
|
+
expect(restored.has(Config)).toBe(true);
|
|
366
|
+
expect(restored.get(Config)).toEqual({ debug: true });
|
|
367
|
+
});
|
|
368
|
+
});
|
|
237
369
|
});
|