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