@codehz/ecs 0.8.2 → 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 +4 -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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { World, component, relation, type EntityId } from "../../index";
|
|
3
|
+
import type { SyncDebugStats } from "../../types";
|
|
4
|
+
|
|
5
|
+
describe("Relation & Hierarchy Companion Tools", () => {
|
|
6
|
+
let world: World;
|
|
7
|
+
let ChildOf: any;
|
|
8
|
+
let InInventory: any;
|
|
9
|
+
let ItemData: any;
|
|
10
|
+
|
|
11
|
+
let collectedStats: SyncDebugStats[] = [];
|
|
12
|
+
let debugCollector: { [Symbol.dispose](): void } | null = null;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
world = new World();
|
|
16
|
+
// IMPORTANT: do not use .name here — the component name registry is global
|
|
17
|
+
// across the test process and previous tests may have used similar names.
|
|
18
|
+
ChildOf = component<void>({ exclusive: true, dontFragment: true });
|
|
19
|
+
InInventory = component<void>({ dontFragment: true });
|
|
20
|
+
ItemData = component<{ name: string }>();
|
|
21
|
+
|
|
22
|
+
collectedStats = [];
|
|
23
|
+
debugCollector = world.createDebugStatsCollector((stats) => {
|
|
24
|
+
collectedStats.push(stats);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (debugCollector) {
|
|
30
|
+
debugCollector[Symbol.dispose]();
|
|
31
|
+
debugCollector = null;
|
|
32
|
+
}
|
|
33
|
+
collectedStats = [];
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function makeTree() {
|
|
37
|
+
const root = world.new();
|
|
38
|
+
const a = world.new();
|
|
39
|
+
const b = world.new();
|
|
40
|
+
const c = world.new();
|
|
41
|
+
const d = world.new(); // grandchild under a
|
|
42
|
+
|
|
43
|
+
world.set(a, relation(ChildOf, root));
|
|
44
|
+
world.set(b, relation(ChildOf, root));
|
|
45
|
+
world.set(c, relation(ChildOf, a));
|
|
46
|
+
world.set(d, relation(ChildOf, a));
|
|
47
|
+
world.sync();
|
|
48
|
+
|
|
49
|
+
return { root, a, b, c, d };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it("getChildren / getParent roundtrip for exclusive hierarchy", () => {
|
|
53
|
+
const { root, a, b, c, d } = makeTree();
|
|
54
|
+
|
|
55
|
+
expect(world.getChildren(root, ChildOf)).toEqual(expect.arrayContaining([a, b]));
|
|
56
|
+
expect(world.getChildren(a, ChildOf)).toEqual(expect.arrayContaining([c, d]));
|
|
57
|
+
expect(world.getChildren(b, ChildOf)).toEqual([]);
|
|
58
|
+
|
|
59
|
+
expect(world.getParent(a, ChildOf)).toBe(root);
|
|
60
|
+
expect(world.getParent(c, ChildOf)).toBe(a);
|
|
61
|
+
expect(world.getParent(root, ChildOf)).toBeUndefined();
|
|
62
|
+
|
|
63
|
+
// Use the new debug collector to verify structural activity
|
|
64
|
+
const lastStats = collectedStats[collectedStats.length - 1];
|
|
65
|
+
expect(lastStats).toBeDefined();
|
|
66
|
+
// Building the tree creates relation-related archetypes and populates reference indices
|
|
67
|
+
expect(lastStats!.archetypes.total).toBeGreaterThanOrEqual(2);
|
|
68
|
+
expect(lastStats!.indices.entityReferences).toBeGreaterThanOrEqual(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("getRelationTargets and has/count work for both exclusive and non-exclusive", () => {
|
|
72
|
+
const owner = world.new();
|
|
73
|
+
const item1 = world.new();
|
|
74
|
+
const item2 = world.new();
|
|
75
|
+
|
|
76
|
+
world.set(owner, relation(InInventory, item1));
|
|
77
|
+
world.set(owner, relation(InInventory, item2));
|
|
78
|
+
world.sync();
|
|
79
|
+
|
|
80
|
+
const targets = world.getRelationTargets(owner, InInventory);
|
|
81
|
+
expect(targets.length).toBe(2);
|
|
82
|
+
expect(targets.map(([t]) => t)).toEqual(expect.arrayContaining([item1, item2]));
|
|
83
|
+
|
|
84
|
+
expect(world.hasRelation(owner, InInventory)).toBe(true);
|
|
85
|
+
expect(world.hasRelation(owner, InInventory, item1)).toBe(true);
|
|
86
|
+
expect(world.hasRelation(owner, InInventory, world.new())).toBe(false);
|
|
87
|
+
expect(world.countRelations(owner, InInventory)).toBe(2);
|
|
88
|
+
|
|
89
|
+
expect(world.countRelations(item1, InInventory)).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("getRelationSources (reverse) works for non-exclusive inventory modeling", () => {
|
|
93
|
+
const player = world.new();
|
|
94
|
+
const chest = world.new();
|
|
95
|
+
const sword = world.new();
|
|
96
|
+
|
|
97
|
+
world.set(player, relation(InInventory, sword));
|
|
98
|
+
world.set(chest, relation(InInventory, sword));
|
|
99
|
+
world.sync();
|
|
100
|
+
|
|
101
|
+
const owners = world.getRelationSources(sword, InInventory);
|
|
102
|
+
expect(owners).toEqual(expect.arrayContaining([player, chest]));
|
|
103
|
+
expect(owners.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("iterateDescendants and traverseDescendants produce correct order and depths (iterative)", () => {
|
|
107
|
+
const { root, a, b, c, d } = makeTree();
|
|
108
|
+
|
|
109
|
+
const visited: Array<{ id: EntityId; depth: number }> = [];
|
|
110
|
+
world.traverseDescendants(root, ChildOf, (e, depth) => {
|
|
111
|
+
visited.push({ id: e, depth });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(visited.length).toBe(4);
|
|
115
|
+
expect(visited.find((v) => v.id === a)!.depth).toBe(1);
|
|
116
|
+
expect(visited.find((v) => v.id === c)!.depth).toBe(2);
|
|
117
|
+
expect(visited.find((v) => v.id === d)!.depth).toBe(2);
|
|
118
|
+
expect(visited.find((v) => v.id === b)!.depth).toBe(1);
|
|
119
|
+
|
|
120
|
+
const viaIter = Array.from(world.iterateDescendants(root, ChildOf, { includeSelf: false }));
|
|
121
|
+
expect(viaIter.length).toBe(4);
|
|
122
|
+
expect(viaIter.every((x) => x.parent !== null)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("getAncestors returns path to root (not including self)", () => {
|
|
126
|
+
const { root, a, c } = makeTree();
|
|
127
|
+
|
|
128
|
+
expect(world.getAncestors(c, ChildOf)).toEqual([a, root]);
|
|
129
|
+
expect(world.getAncestors(a, ChildOf)).toEqual([root]);
|
|
130
|
+
expect(world.getAncestors(root, ChildOf)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("reparenting (exclusive) is visible after sync", () => {
|
|
134
|
+
const { root, a, b } = makeTree();
|
|
135
|
+
const newRoot = world.new();
|
|
136
|
+
world.sync();
|
|
137
|
+
|
|
138
|
+
const statsBeforeReparent = collectedStats.length;
|
|
139
|
+
|
|
140
|
+
// Move a under newRoot (exclusive relation flip → structural change expected)
|
|
141
|
+
world.set(a, relation(ChildOf, newRoot));
|
|
142
|
+
world.sync();
|
|
143
|
+
|
|
144
|
+
expect(world.getParent(a, ChildOf)).toBe(newRoot);
|
|
145
|
+
expect(world.getChildren(root, ChildOf)).not.toContain(a);
|
|
146
|
+
expect(world.getChildren(newRoot, ChildOf)).toContain(a);
|
|
147
|
+
expect(world.getChildren(root, ChildOf)).toContain(b);
|
|
148
|
+
|
|
149
|
+
// The debug collector should have recorded activity for the exclusive relation change
|
|
150
|
+
expect(collectedStats.length).toBeGreaterThan(statsBeforeReparent);
|
|
151
|
+
const last = collectedStats[collectedStats.length - 1]!;
|
|
152
|
+
// Exclusive reparenting typically triggers structural activity (migrations or new archetypes for the new parent relation)
|
|
153
|
+
expect(
|
|
154
|
+
last.activity.migrations + last.activity.archetypesCreated + last.activity.archetypesRemoved,
|
|
155
|
+
).toBeGreaterThanOrEqual(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("relations with payload data are returned correctly", () => {
|
|
159
|
+
const owner = world.new();
|
|
160
|
+
const item = world.new();
|
|
161
|
+
world.set(item, ItemData, { name: "Magic Sword" });
|
|
162
|
+
|
|
163
|
+
const Owns = component<{ slot: string }>({ dontFragment: true });
|
|
164
|
+
world.set(owner, relation(Owns, item), { slot: "hand" });
|
|
165
|
+
world.sync();
|
|
166
|
+
|
|
167
|
+
const targets = world.getRelationTargets(owner, Owns);
|
|
168
|
+
expect(targets.length).toBe(1);
|
|
169
|
+
expect(targets[0]![0]).toBe(item);
|
|
170
|
+
expect(targets[0]![1]).toEqual({ slot: "hand" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("throws on missing entity for forward access (getRelationTargets etc.)", () => {
|
|
174
|
+
const fake = 999999 as EntityId;
|
|
175
|
+
expect(() => world.getRelationTargets(fake, ChildOf)).toThrow();
|
|
176
|
+
expect(() => world.hasRelation(fake, ChildOf)).toThrow();
|
|
177
|
+
expect(() => world.countRelations(fake, ChildOf)).toThrow();
|
|
178
|
+
|
|
179
|
+
// Reverse lookup on a non-existent parent safely returns []
|
|
180
|
+
expect(world.getChildren(fake, ChildOf)).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("findRoots stub + recommended pattern with domain query works", () => {
|
|
184
|
+
const { root, a, b, c, d } = makeTree();
|
|
185
|
+
|
|
186
|
+
const all = [root, a, b, c, d];
|
|
187
|
+
const roots = all.filter((e) => !world.hasRelation(e, ChildOf));
|
|
188
|
+
expect(roots).toEqual([root]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("deletion removes entities from relation views after sync", () => {
|
|
192
|
+
const { root, a } = makeTree();
|
|
193
|
+
const statsBefore = collectedStats.length;
|
|
194
|
+
|
|
195
|
+
world.delete(a);
|
|
196
|
+
world.sync();
|
|
197
|
+
|
|
198
|
+
const kids = world.getChildren(root, ChildOf);
|
|
199
|
+
expect(kids).not.toContain(a);
|
|
200
|
+
expect(world.exists(a)).toBe(false);
|
|
201
|
+
|
|
202
|
+
// Deletion + relation cleanup should be visible in debug stats
|
|
203
|
+
expect(collectedStats.length).toBeGreaterThan(statsBefore);
|
|
204
|
+
const last = collectedStats[collectedStats.length - 1]!;
|
|
205
|
+
// We expect at least some archetype or reference maintenance activity
|
|
206
|
+
expect(last.activity.archetypesRemoved + last.indices.entityReferences).toBeGreaterThanOrEqual(0);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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();
|