@codehz/ecs 0.7.2 → 0.7.3
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/examples/advanced-scheduling.ts +96 -0
- package/examples/collision-detection.ts +229 -0
- package/examples/inventory-system-relations.ts +108 -0
- package/examples/parent-child-hierarchy.ts +206 -0
- package/examples/serialization.ts +337 -0
- package/examples/simple.ts +96 -0
- package/examples/spatial-grid.ts +276 -0
- package/examples/state-machine.ts +273 -0
- package/examples/tag-filtering.ts +266 -0
- package/package.json +58 -12
- package/src/__tests__/commands/buffer-limits.test.ts +72 -0
- package/src/__tests__/commands/buffer.test.ts +195 -0
- package/src/__tests__/component/singleton.test.ts +148 -0
- package/src/__tests__/core/archetype.test.ts +247 -0
- package/src/__tests__/core/bitset.test.ts +171 -0
- package/src/__tests__/core/changeset.test.ts +254 -0
- package/src/__tests__/core/multi-map.test.ts +74 -0
- package/src/__tests__/entity/component-registry.test.ts +66 -0
- package/src/__tests__/entity/entity.test.ts +520 -0
- package/src/__tests__/entity/id-manager.test.ts +157 -0
- package/src/__tests__/entity/id-system.test.ts +260 -0
- package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
- package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
- package/src/__tests__/query/basic.test.ts +341 -0
- package/src/__tests__/query/caching.test.ts +112 -0
- package/src/__tests__/query/filter.test.ts +111 -0
- package/src/__tests__/query/optional.test.ts +231 -0
- package/src/__tests__/query/perf.test.ts +99 -0
- package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
- package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
- package/src/__tests__/relations/wildcard.test.ts +179 -0
- package/src/__tests__/serialization/bounds.test.ts +237 -0
- package/src/__tests__/testing/assertions.test.ts +224 -0
- package/src/__tests__/testing/entity-builder.test.ts +84 -0
- package/src/__tests__/testing/snapshot.test.ts +150 -0
- package/src/__tests__/testing/world-fixture.test.ts +73 -0
- package/src/__tests__/world/component-hooks.test.ts +185 -0
- package/src/__tests__/world/component-management.test.ts +447 -0
- package/src/__tests__/world/entity-management.test.ts +86 -0
- package/src/__tests__/world/get-optional.test.ts +96 -0
- package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
- package/src/__tests__/world/perf.test.ts +93 -0
- package/src/__tests__/world/query.test.ts +223 -0
- package/src/__tests__/world/serialize.test.ts +83 -0
- package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
- package/src/archetype/archetype.ts +472 -0
- package/src/archetype/helpers.ts +186 -0
- package/src/archetype/store.ts +33 -0
- package/src/commands/buffer.ts +110 -0
- package/src/commands/changeset.ts +104 -0
- package/src/component/entity-store.ts +223 -0
- package/src/component/registry.ts +657 -0
- package/src/component/type-utils.ts +9 -0
- package/src/entity/index.ts +63 -0
- package/src/entity/manager.ts +115 -0
- package/src/entity/relation.ts +319 -0
- package/src/entity/types.ts +135 -0
- package/src/index.ts +41 -0
- package/src/query/filter.ts +75 -0
- package/src/query/query.ts +313 -0
- package/src/query/registry.ts +101 -0
- package/src/storage/serialization.ts +130 -0
- package/src/testing/index.ts +634 -0
- package/src/types/index.ts +99 -0
- package/src/utils/bit-set.ts +133 -0
- package/src/utils/multi-map.ts +96 -0
- package/src/utils/utils.ts +19 -0
- package/src/world/builder.ts +100 -0
- package/src/world/commands.ts +378 -0
- package/src/world/hooks.ts +358 -0
- package/src/world/references.ts +38 -0
- package/src/world/serialization.ts +122 -0
- package/src/world/world.ts +1201 -0
- /package/{builder.d.mts → dist/builder.d.mts} +0 -0
- /package/{index.d.mts → dist/index.d.mts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{testing.d.mts → dist/testing.d.mts} +0 -0
- /package/{testing.mjs → dist/testing.mjs} +0 -0
- /package/{testing.mjs.map → dist/testing.mjs.map} +0 -0
- /package/{world.mjs → dist/world.mjs} +0 -0
- /package/{world.mjs.map → dist/world.mjs.map} +0 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, relation } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World - Query", () => {
|
|
6
|
+
type Position = { x: number; y: number };
|
|
7
|
+
type Velocity = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
const markComponent = component();
|
|
10
|
+
const positionComponent = component<Position>();
|
|
11
|
+
const velocityComponent = component<Velocity>();
|
|
12
|
+
|
|
13
|
+
it("should query entities with specific components", () => {
|
|
14
|
+
const world = new World();
|
|
15
|
+
const entity1 = world.new();
|
|
16
|
+
const entity2 = world.new();
|
|
17
|
+
const entity3 = world.new();
|
|
18
|
+
|
|
19
|
+
world.set(entity1, positionComponent, { x: 1, y: 2 });
|
|
20
|
+
world.set(entity1, velocityComponent, { x: 0.1, y: 0.2 });
|
|
21
|
+
|
|
22
|
+
world.set(entity2, positionComponent, { x: 3, y: 4 });
|
|
23
|
+
|
|
24
|
+
// entity3 has no components
|
|
25
|
+
|
|
26
|
+
world.sync(); // Execute deferred commands
|
|
27
|
+
|
|
28
|
+
const positionEntities = world.query([positionComponent]);
|
|
29
|
+
expect(positionEntities).toContain(entity1);
|
|
30
|
+
expect(positionEntities).toContain(entity2);
|
|
31
|
+
expect(positionEntities).not.toContain(entity3);
|
|
32
|
+
|
|
33
|
+
const velocityEntities = world.query([velocityComponent]);
|
|
34
|
+
expect(velocityEntities).toContain(entity1);
|
|
35
|
+
expect(velocityEntities).not.toContain(entity2);
|
|
36
|
+
expect(velocityEntities).not.toContain(entity3);
|
|
37
|
+
|
|
38
|
+
const bothEntities = world.query([positionComponent, velocityComponent]);
|
|
39
|
+
expect(bothEntities).toContain(entity1);
|
|
40
|
+
expect(bothEntities).not.toContain(entity2);
|
|
41
|
+
expect(bothEntities).not.toContain(entity3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should return empty array for queries with no matches", () => {
|
|
45
|
+
const world = new World();
|
|
46
|
+
const entity = world.new();
|
|
47
|
+
world.set(entity, positionComponent, { x: 1, y: 2 });
|
|
48
|
+
|
|
49
|
+
const result = world.query([velocityComponent]);
|
|
50
|
+
expect(result).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should query entities with included component data", () => {
|
|
54
|
+
const world = new World();
|
|
55
|
+
const entity1 = world.new();
|
|
56
|
+
const entity2 = world.new();
|
|
57
|
+
|
|
58
|
+
const pos1 = { x: 1, y: 2 };
|
|
59
|
+
const vel1 = { x: 0.1, y: 0.2 };
|
|
60
|
+
const pos2 = { x: 3, y: 4 };
|
|
61
|
+
|
|
62
|
+
world.set(entity1, positionComponent, pos1);
|
|
63
|
+
world.set(entity1, velocityComponent, vel1);
|
|
64
|
+
world.set(entity2, positionComponent, pos2);
|
|
65
|
+
world.sync();
|
|
66
|
+
|
|
67
|
+
const results = world.query([positionComponent], true);
|
|
68
|
+
|
|
69
|
+
expect(results).toHaveLength(2);
|
|
70
|
+
|
|
71
|
+
const result1 = results.find((result) => result.entity === entity1);
|
|
72
|
+
const result2 = results.find((result) => result.entity === entity2);
|
|
73
|
+
|
|
74
|
+
expect(result1?.components[0]).toEqual(pos1);
|
|
75
|
+
expect(result2?.components[0]).toEqual(pos2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should query entities with wildcard relations", () => {
|
|
79
|
+
const world = new World();
|
|
80
|
+
const entity1 = world.new();
|
|
81
|
+
const entity2 = world.new();
|
|
82
|
+
const entity3 = world.new();
|
|
83
|
+
|
|
84
|
+
// Create a wildcard relation for position component
|
|
85
|
+
const wildcardPositionRelation = relation(markComponent, "*");
|
|
86
|
+
|
|
87
|
+
world.set(entity1, relation(markComponent, positionComponent), { x: 1, y: 2 });
|
|
88
|
+
world.set(entity1, relation(markComponent, velocityComponent), { x: 0.1, y: 0.2 });
|
|
89
|
+
|
|
90
|
+
world.set(entity2, relation(markComponent, positionComponent), { x: 3, y: 4 });
|
|
91
|
+
|
|
92
|
+
world.set(entity3, positionComponent, { x: 5, y: 6 });
|
|
93
|
+
|
|
94
|
+
// entity3 has no position component
|
|
95
|
+
|
|
96
|
+
world.sync(); // Execute deferred commands
|
|
97
|
+
|
|
98
|
+
// Query with wildcard relation should find all entities with position component
|
|
99
|
+
const wildcardEntities = world.query([wildcardPositionRelation]);
|
|
100
|
+
expect(wildcardEntities).toContain(entity1);
|
|
101
|
+
expect(wildcardEntities).toContain(entity2);
|
|
102
|
+
expect(wildcardEntities).not.toContain(entity3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should query entities with mixed component and wildcard relation queries", () => {
|
|
106
|
+
const world = new World();
|
|
107
|
+
const entity1 = world.new();
|
|
108
|
+
const entity2 = world.new();
|
|
109
|
+
const entity3 = world.new();
|
|
110
|
+
|
|
111
|
+
// Create a wildcard relation for position component
|
|
112
|
+
const wildcardPositionRelation = relation(markComponent, "*");
|
|
113
|
+
|
|
114
|
+
world.set(entity1, relation(markComponent, positionComponent), { x: 1, y: 2 });
|
|
115
|
+
world.set(entity1, velocityComponent, { x: 0.1, y: 0.2 });
|
|
116
|
+
|
|
117
|
+
world.set(entity2, relation(markComponent, positionComponent), { x: 3, y: 4 });
|
|
118
|
+
// entity2 doesn't have velocity
|
|
119
|
+
|
|
120
|
+
world.set(entity3, velocityComponent, { x: 0.5, y: 0.6 });
|
|
121
|
+
// entity3 doesn't have position
|
|
122
|
+
|
|
123
|
+
world.sync(); // Execute deferred commands
|
|
124
|
+
|
|
125
|
+
// Query with both velocity component and wildcard position relation
|
|
126
|
+
// Should only match entity1 (has both position and velocity)
|
|
127
|
+
const mixedEntities = world.query([velocityComponent, wildcardPositionRelation]);
|
|
128
|
+
expect(mixedEntities).toContain(entity1);
|
|
129
|
+
expect(mixedEntities).not.toContain(entity2);
|
|
130
|
+
expect(mixedEntities).not.toContain(entity3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should clean up relation components when target entity is destroyed", () => {
|
|
134
|
+
const world = new World();
|
|
135
|
+
|
|
136
|
+
// Create component IDs
|
|
137
|
+
const positionComponent = component<{ x: number; y: number }>();
|
|
138
|
+
const followsComponent = component<void>();
|
|
139
|
+
|
|
140
|
+
// Create entities
|
|
141
|
+
const entity1 = world.new(); // This will be followed
|
|
142
|
+
const entity2 = world.new(); // This will follow entity1
|
|
143
|
+
const entity3 = world.new(); // This will also follow entity1
|
|
144
|
+
|
|
145
|
+
// Add position to entity1
|
|
146
|
+
world.set(entity1, positionComponent, { x: 10, y: 20 });
|
|
147
|
+
world.sync();
|
|
148
|
+
|
|
149
|
+
// Create relation components (entity2 and entity3 follow entity1)
|
|
150
|
+
const followsEntity1 = relation(followsComponent, entity1);
|
|
151
|
+
world.set(entity2, followsEntity1);
|
|
152
|
+
world.set(entity3, followsEntity1);
|
|
153
|
+
world.sync();
|
|
154
|
+
// Add twice to test idempotency
|
|
155
|
+
world.set(entity2, followsEntity1);
|
|
156
|
+
world.set(entity3, followsEntity1);
|
|
157
|
+
world.sync();
|
|
158
|
+
|
|
159
|
+
// Verify relations exist
|
|
160
|
+
expect(world.has(entity2, followsEntity1)).toBe(true);
|
|
161
|
+
expect(world.has(entity3, followsEntity1)).toBe(true);
|
|
162
|
+
|
|
163
|
+
// Query entities that follow entity1
|
|
164
|
+
const followers = world.query([followsEntity1]);
|
|
165
|
+
expect(followers).toContain(entity2);
|
|
166
|
+
expect(followers).toContain(entity3);
|
|
167
|
+
|
|
168
|
+
// Destroy entity1
|
|
169
|
+
world.delete(entity1);
|
|
170
|
+
world.sync();
|
|
171
|
+
|
|
172
|
+
// Verify entity1 is destroyed
|
|
173
|
+
expect(world.exists(entity1)).toBe(false);
|
|
174
|
+
|
|
175
|
+
// Verify relation components are cleaned up
|
|
176
|
+
expect(world.has(entity2, followsEntity1)).toBe(false);
|
|
177
|
+
expect(world.has(entity3, followsEntity1)).toBe(false);
|
|
178
|
+
|
|
179
|
+
// Query should now return empty
|
|
180
|
+
const followersAfterDestroy = world.query([followsEntity1]);
|
|
181
|
+
expect(followersAfterDestroy).toHaveLength(0);
|
|
182
|
+
|
|
183
|
+
// entity2 and entity3 should still exist but without the relation components
|
|
184
|
+
expect(world.exists(entity2)).toBe(true);
|
|
185
|
+
expect(world.exists(entity3)).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should clean up components when entity is used directly as component type and destroyed", () => {
|
|
189
|
+
const world = new World();
|
|
190
|
+
|
|
191
|
+
// Create entities
|
|
192
|
+
const entity1 = world.new(); // This will be used as component type
|
|
193
|
+
const entity2 = world.new(); // This will have entity1 as component
|
|
194
|
+
|
|
195
|
+
// Add entity1 directly as a component type to entity2
|
|
196
|
+
world.set(entity2, entity1);
|
|
197
|
+
world.sync();
|
|
198
|
+
|
|
199
|
+
// Verify the component exists
|
|
200
|
+
expect(world.has(entity2, entity1)).toBe(true);
|
|
201
|
+
|
|
202
|
+
// Query entities that have entity1 as component
|
|
203
|
+
const entitiesWithComponent = world.query([entity1]);
|
|
204
|
+
expect(entitiesWithComponent).toContain(entity2);
|
|
205
|
+
|
|
206
|
+
// Destroy entity1
|
|
207
|
+
world.delete(entity1);
|
|
208
|
+
world.sync();
|
|
209
|
+
|
|
210
|
+
// Verify entity1 is destroyed
|
|
211
|
+
expect(world.exists(entity1)).toBe(false);
|
|
212
|
+
|
|
213
|
+
// Verify the component is cleaned up
|
|
214
|
+
expect(world.has(entity2, entity1)).toBe(false);
|
|
215
|
+
|
|
216
|
+
// Query should now return empty
|
|
217
|
+
const entitiesWithComponentAfterDestroy = world.query([entity1]);
|
|
218
|
+
expect(entitiesWithComponentAfterDestroy).toHaveLength(0);
|
|
219
|
+
|
|
220
|
+
// entity2 should still exist
|
|
221
|
+
expect(world.exists(entity2)).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, relation, type EntityId } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World serialization", () => {
|
|
6
|
+
it("should serialize and deserialize a world with components and relations", () => {
|
|
7
|
+
type Position = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
const PositionComp = component<Position>("position");
|
|
10
|
+
const HealthComp = component<number>("health");
|
|
11
|
+
|
|
12
|
+
const world = new World();
|
|
13
|
+
|
|
14
|
+
// Create entities
|
|
15
|
+
const e1 = world.new();
|
|
16
|
+
const e2 = world.new();
|
|
17
|
+
const e3 = world.new();
|
|
18
|
+
|
|
19
|
+
// Add components
|
|
20
|
+
const p1: Position = { x: 1, y: 2 };
|
|
21
|
+
const p2: Position = { x: 3, y: 4 };
|
|
22
|
+
|
|
23
|
+
world.set(e1, PositionComp, p1);
|
|
24
|
+
world.set(e2, PositionComp, p2);
|
|
25
|
+
world.set(e2, HealthComp, 99);
|
|
26
|
+
|
|
27
|
+
// Add relation component on e3 pointing to e1 and e2
|
|
28
|
+
const relToE1 = relation(PositionComp, e1);
|
|
29
|
+
const relToE2 = relation(PositionComp, e2);
|
|
30
|
+
world.set(e3, relToE1, { x: 10, y: 20 });
|
|
31
|
+
world.set(e3, relToE2, { x: 30, y: 40 });
|
|
32
|
+
|
|
33
|
+
world.sync();
|
|
34
|
+
|
|
35
|
+
// Serialize (returns an in-memory snapshot, not a JSON string)
|
|
36
|
+
const snapshot = world.serialize();
|
|
37
|
+
|
|
38
|
+
// Restore by constructing World with snapshot
|
|
39
|
+
const restored = new World(snapshot);
|
|
40
|
+
|
|
41
|
+
// Basic existence
|
|
42
|
+
expect(restored.exists(e1)).toBe(true);
|
|
43
|
+
expect(restored.exists(e2)).toBe(true);
|
|
44
|
+
expect(restored.exists(e3)).toBe(true);
|
|
45
|
+
|
|
46
|
+
// Components restored
|
|
47
|
+
expect(restored.get(e1, PositionComp)).toEqual(p1);
|
|
48
|
+
expect(restored.get(e2, PositionComp)).toEqual(p2);
|
|
49
|
+
expect(restored.get(e2, HealthComp)).toEqual(99);
|
|
50
|
+
|
|
51
|
+
// Relations restored
|
|
52
|
+
expect(restored.has(e3, relToE1)).toBe(true);
|
|
53
|
+
expect(restored.has(e3, relToE2)).toBe(true);
|
|
54
|
+
|
|
55
|
+
// Wildcard query returns both relations; check contents irrespective of order
|
|
56
|
+
const wildcard = relation(PositionComp, "*") as EntityId<any>;
|
|
57
|
+
const relations = restored.get(e3, wildcard) as [EntityId, any][];
|
|
58
|
+
const targets = relations.map((r) => r[0]);
|
|
59
|
+
expect(targets).toContain(e1);
|
|
60
|
+
expect(targets).toContain(e2);
|
|
61
|
+
|
|
62
|
+
const pair1 = relations.find((r) => r[0] === e1);
|
|
63
|
+
const pair2 = relations.find((r) => r[0] === e2);
|
|
64
|
+
expect(pair1).toBeDefined();
|
|
65
|
+
expect(pair2).toBeDefined();
|
|
66
|
+
expect(pair1![1]).toEqual({ x: 10, y: 20 });
|
|
67
|
+
expect(pair2![1]).toEqual({ x: 30, y: 40 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should preserve entity id allocator state across serialization", () => {
|
|
71
|
+
const world = new World();
|
|
72
|
+
world.new();
|
|
73
|
+
const b = world.new();
|
|
74
|
+
world.sync();
|
|
75
|
+
|
|
76
|
+
const snapshot = world.serialize();
|
|
77
|
+
const restored = new World(snapshot);
|
|
78
|
+
|
|
79
|
+
// Next allocated id after restore should be >= the max existing id + 1
|
|
80
|
+
const c = restored.new();
|
|
81
|
+
expect(c).toBeGreaterThanOrEqual(b + 1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, relation, type EntityId } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("Wildcard-Relation Hooks", () => {
|
|
6
|
+
it("should trigger on_set when wildcard relation matches added relation component", () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const A = component<number>();
|
|
9
|
+
const RelData = component<{ value: string }>();
|
|
10
|
+
const target = world.new();
|
|
11
|
+
const wildcardRel = relation(RelData, "*");
|
|
12
|
+
const concreteRel = relation(RelData, target);
|
|
13
|
+
|
|
14
|
+
const calls: { entityId: EntityId; components: readonly [number, [EntityId, { value: string }][]] }[] = [];
|
|
15
|
+
|
|
16
|
+
world.hook([A, wildcardRel], {
|
|
17
|
+
on_set: (entityId, ...components) => {
|
|
18
|
+
calls.push({ entityId, components });
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Create entity with both A and a concrete relation
|
|
23
|
+
const entity = world.spawn().with(A, 42).with(concreteRel, { value: "hello" }).build();
|
|
24
|
+
world.sync();
|
|
25
|
+
|
|
26
|
+
expect(calls.length).toBe(1);
|
|
27
|
+
expect(calls[0]!.entityId).toBe(entity);
|
|
28
|
+
expect(calls[0]!.components[0]).toBe(42);
|
|
29
|
+
expect(calls[0]!.components[1]).toEqual([[target, { value: "hello" }]]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should trigger on_set when adding a new relation that matches wildcard", () => {
|
|
33
|
+
const world = new World();
|
|
34
|
+
const A = component<number>();
|
|
35
|
+
const RelData = component<{ value: string }>();
|
|
36
|
+
const target1 = world.new();
|
|
37
|
+
const target2 = world.new();
|
|
38
|
+
const wildcardRel = relation(RelData, "*");
|
|
39
|
+
|
|
40
|
+
const calls: { entityId: EntityId; components: readonly [number, [EntityId, { value: string }][]] }[] = [];
|
|
41
|
+
|
|
42
|
+
world.hook([A, wildcardRel], {
|
|
43
|
+
on_set: (entityId, ...components) => {
|
|
44
|
+
calls.push({ entityId, components });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// First, create entity with A and one relation
|
|
49
|
+
const entity = world.spawn().with(A, 42).with(relation(RelData, target1), { value: "first" }).build();
|
|
50
|
+
world.sync();
|
|
51
|
+
|
|
52
|
+
expect(calls.length).toBe(1);
|
|
53
|
+
|
|
54
|
+
// Add another relation - should trigger again
|
|
55
|
+
world.set(entity, relation(RelData, target2), { value: "second" });
|
|
56
|
+
world.sync();
|
|
57
|
+
|
|
58
|
+
expect(calls.length).toBe(2);
|
|
59
|
+
// Check that both relations are present (order may vary)
|
|
60
|
+
const relations = calls[1]!.components[1] as [EntityId, { value: string }][];
|
|
61
|
+
expect(relations.length).toBe(2);
|
|
62
|
+
expect(relations).toContainEqual([target1, { value: "first" }]);
|
|
63
|
+
expect(relations).toContainEqual([target2, { value: "second" }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should not trigger on_set when wildcard relation requirement not met", () => {
|
|
67
|
+
const world = new World();
|
|
68
|
+
const A = component<number>();
|
|
69
|
+
const RelData = component<{ value: string }>();
|
|
70
|
+
const wildcardRel = relation(RelData, "*");
|
|
71
|
+
|
|
72
|
+
const calls: any[] = [];
|
|
73
|
+
|
|
74
|
+
world.hook([A, wildcardRel], {
|
|
75
|
+
on_set: (entityId, ...components) => {
|
|
76
|
+
calls.push({ entityId, components });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Create entity with only A, no relation
|
|
81
|
+
const entity = world.spawn().with(A, 42).build();
|
|
82
|
+
world.sync();
|
|
83
|
+
|
|
84
|
+
expect(calls.length).toBe(0);
|
|
85
|
+
expect(world.has(entity, A)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should trigger on_remove when wildcard relation matches removed relation component", () => {
|
|
89
|
+
const world = new World();
|
|
90
|
+
const A = component<number>();
|
|
91
|
+
const RelData = component<{ value: string }>();
|
|
92
|
+
const target = world.new();
|
|
93
|
+
const wildcardRel = relation(RelData, "*");
|
|
94
|
+
const concreteRel = relation(RelData, target);
|
|
95
|
+
|
|
96
|
+
const removeCalls: { entityId: EntityId; components: any }[] = [];
|
|
97
|
+
|
|
98
|
+
world.hook([A, wildcardRel], {
|
|
99
|
+
on_remove: (entityId, ...components) => {
|
|
100
|
+
removeCalls.push({ entityId, components });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const entity = world.spawn().with(A, 42).with(concreteRel, { value: "hello" }).build();
|
|
105
|
+
world.sync();
|
|
106
|
+
|
|
107
|
+
// Remove the relation
|
|
108
|
+
world.remove(entity, concreteRel);
|
|
109
|
+
world.sync();
|
|
110
|
+
|
|
111
|
+
expect(removeCalls.length).toBe(1);
|
|
112
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should support optional wildcard relation in multi-hook", () => {
|
|
116
|
+
const world = new World();
|
|
117
|
+
const A = component<number>();
|
|
118
|
+
const RelData = component<{ value: string }>();
|
|
119
|
+
const target = world.new();
|
|
120
|
+
const wildcardRel = relation(RelData, "*");
|
|
121
|
+
const concreteRel = relation(RelData, target);
|
|
122
|
+
|
|
123
|
+
const calls: { entityId: EntityId; components: any }[] = [];
|
|
124
|
+
|
|
125
|
+
world.hook([A, { optional: wildcardRel }], {
|
|
126
|
+
on_set: (entityId, ...components) => {
|
|
127
|
+
calls.push({ entityId, components });
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Create entity with only A
|
|
132
|
+
const entity = world.spawn().with(A, 42).build();
|
|
133
|
+
world.sync();
|
|
134
|
+
|
|
135
|
+
expect(calls.length).toBe(1);
|
|
136
|
+
expect(calls[0]!.components).toEqual([42, undefined]);
|
|
137
|
+
|
|
138
|
+
// Add a relation
|
|
139
|
+
world.set(entity, concreteRel, { value: "hello" });
|
|
140
|
+
world.sync();
|
|
141
|
+
|
|
142
|
+
expect(calls.length).toBe(2);
|
|
143
|
+
expect(calls[1]!.components[0]).toBe(42);
|
|
144
|
+
expect(calls[1]!.components[1]).toEqual({ value: [[target, { value: "hello" }]] });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should trigger on_set with multiple wildcard relations", () => {
|
|
148
|
+
const world = new World();
|
|
149
|
+
const A = component<number>();
|
|
150
|
+
const RelData1 = component<{ x: number }>();
|
|
151
|
+
const RelData2 = component<{ y: number }>();
|
|
152
|
+
const target1 = world.new();
|
|
153
|
+
const target2 = world.new();
|
|
154
|
+
const wildcardRel1 = relation(RelData1, "*");
|
|
155
|
+
const wildcardRel2 = relation(RelData2, "*");
|
|
156
|
+
|
|
157
|
+
const calls: { entityId: EntityId; components: any }[] = [];
|
|
158
|
+
|
|
159
|
+
world.hook([A, wildcardRel1, wildcardRel2], {
|
|
160
|
+
on_set: (entityId, ...components) => {
|
|
161
|
+
calls.push({ entityId, components });
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const entity = world
|
|
166
|
+
.spawn()
|
|
167
|
+
.with(A, 42)
|
|
168
|
+
.with(relation(RelData1, target1), { x: 10 })
|
|
169
|
+
.with(relation(RelData2, target2), { y: 20 })
|
|
170
|
+
.build();
|
|
171
|
+
world.sync();
|
|
172
|
+
|
|
173
|
+
expect(calls.length).toBe(1);
|
|
174
|
+
expect(calls[0]!.entityId).toBe(entity);
|
|
175
|
+
expect(calls[0]!.components[0]).toBe(42);
|
|
176
|
+
expect(calls[0]!.components[1]).toEqual([[target1, { x: 10 }]]);
|
|
177
|
+
expect(calls[0]!.components[2]).toEqual([[target2, { y: 20 }]]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should not match unrelated relation components with wildcard", () => {
|
|
181
|
+
const world = new World();
|
|
182
|
+
const A = component<number>();
|
|
183
|
+
const RelData1 = component<{ x: number }>();
|
|
184
|
+
const RelData2 = component<{ y: number }>();
|
|
185
|
+
const target = world.new();
|
|
186
|
+
const wildcardRel1 = relation(RelData1, "*");
|
|
187
|
+
|
|
188
|
+
const calls: any[] = [];
|
|
189
|
+
|
|
190
|
+
world.hook([A, wildcardRel1], {
|
|
191
|
+
on_set: (entityId, ...components) => {
|
|
192
|
+
calls.push({ entityId, components });
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Create entity with A and a different relation type
|
|
197
|
+
const entity = world.spawn().with(A, 42).with(relation(RelData2, target), { y: 20 }).build();
|
|
198
|
+
world.sync();
|
|
199
|
+
|
|
200
|
+
// Should not trigger because RelData2 doesn't match RelData1's wildcard
|
|
201
|
+
expect(calls.length).toBe(0);
|
|
202
|
+
expect(world.has(entity, A)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should trigger on_set when only wildcard relation specified as single required component", () => {
|
|
206
|
+
const world = new World();
|
|
207
|
+
const RelData = component<{ value: string }>();
|
|
208
|
+
const target1 = world.new();
|
|
209
|
+
const target2 = world.new();
|
|
210
|
+
const wildcardRel = relation(RelData, "*");
|
|
211
|
+
|
|
212
|
+
const setCalls: { entityId: EntityId; relations: [EntityId, { value: string }][] }[] = [];
|
|
213
|
+
|
|
214
|
+
world.hook([wildcardRel], {
|
|
215
|
+
on_set: (entityId, relations) => {
|
|
216
|
+
setCalls.push({ entityId, relations });
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Create entity with a matching relation
|
|
221
|
+
const entity = world.spawn().with(relation(RelData, target1), { value: "first" }).build();
|
|
222
|
+
world.sync();
|
|
223
|
+
|
|
224
|
+
expect(setCalls.length).toBe(1);
|
|
225
|
+
expect(setCalls[0]!.entityId).toBe(entity);
|
|
226
|
+
expect(setCalls[0]!.relations).toEqual([[target1, { value: "first" }]]);
|
|
227
|
+
|
|
228
|
+
// Add another matching relation - should trigger again
|
|
229
|
+
world.set(entity, relation(RelData, target2), { value: "second" });
|
|
230
|
+
world.sync();
|
|
231
|
+
|
|
232
|
+
expect(setCalls.length).toBe(2);
|
|
233
|
+
expect(setCalls[1]!.entityId).toBe(entity);
|
|
234
|
+
const relations = setCalls[1]!.relations;
|
|
235
|
+
expect(relations.length).toBe(2);
|
|
236
|
+
expect(relations).toContainEqual([target1, { value: "first" }]);
|
|
237
|
+
expect(relations).toContainEqual([target2, { value: "second" }]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should trigger on_remove when only wildcard relation specified and last relation removed", () => {
|
|
241
|
+
const world = new World();
|
|
242
|
+
const RelData = component<{ value: string }>();
|
|
243
|
+
const target1 = world.new();
|
|
244
|
+
const target2 = world.new();
|
|
245
|
+
const wildcardRel = relation(RelData, "*");
|
|
246
|
+
|
|
247
|
+
const removeCalls: { entityId: EntityId; relations: [EntityId, { value: string }][] }[] = [];
|
|
248
|
+
|
|
249
|
+
world.hook([wildcardRel], {
|
|
250
|
+
on_remove: (entityId, relations) => {
|
|
251
|
+
removeCalls.push({ entityId, relations });
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Create entity with two matching relations
|
|
256
|
+
const entity = world
|
|
257
|
+
.spawn()
|
|
258
|
+
.with(relation(RelData, target1), { value: "first" })
|
|
259
|
+
.with(relation(RelData, target2), { value: "second" })
|
|
260
|
+
.build();
|
|
261
|
+
world.sync();
|
|
262
|
+
|
|
263
|
+
// Remove one relation - should NOT trigger on_remove (still has other matching relations)
|
|
264
|
+
world.remove(entity, relation(RelData, target1));
|
|
265
|
+
world.sync();
|
|
266
|
+
|
|
267
|
+
expect(removeCalls.length).toBe(0);
|
|
268
|
+
expect(world.has(entity, relation(RelData, target2))).toBe(true);
|
|
269
|
+
|
|
270
|
+
// Remove the last matching relation - should trigger on_remove now
|
|
271
|
+
world.remove(entity, relation(RelData, target2));
|
|
272
|
+
world.sync();
|
|
273
|
+
|
|
274
|
+
expect(removeCalls.length).toBe(1);
|
|
275
|
+
expect(removeCalls[0]!.entityId).toBe(entity);
|
|
276
|
+
// The callback receives array format with the removed relation
|
|
277
|
+
expect(removeCalls[0]!.relations).toEqual([[target2, { value: "second" }]]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("should trigger on_init when only wildcard relation specified for existing matching entities", () => {
|
|
281
|
+
const world = new World();
|
|
282
|
+
const RelData = component<{ value: string }>();
|
|
283
|
+
const target = world.new();
|
|
284
|
+
const wildcardRel = relation(RelData, "*");
|
|
285
|
+
|
|
286
|
+
// Create entity with a matching relation before hook
|
|
287
|
+
const entity = world.spawn().with(relation(RelData, target), { value: "existing" }).build();
|
|
288
|
+
world.sync();
|
|
289
|
+
|
|
290
|
+
const initCalls: { entityId: EntityId; relations: [EntityId, { value: string }][] }[] = [];
|
|
291
|
+
|
|
292
|
+
// Register hook - should trigger on_init for existing entity
|
|
293
|
+
world.hook([wildcardRel], {
|
|
294
|
+
on_init: (entityId, relations) => {
|
|
295
|
+
initCalls.push({ entityId, relations });
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(initCalls.length).toBe(1);
|
|
300
|
+
expect(initCalls[0]!.entityId).toBe(entity);
|
|
301
|
+
expect(initCalls[0]!.relations).toEqual([[target, { value: "existing" }]]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should not trigger when only wildcard relation specified and entity has no matching relations", () => {
|
|
305
|
+
const world = new World();
|
|
306
|
+
const RelData = component<{ value: string }>();
|
|
307
|
+
const OtherData = component<{ other: number }>();
|
|
308
|
+
const target = world.new();
|
|
309
|
+
const wildcardRel = relation(RelData, "*");
|
|
310
|
+
|
|
311
|
+
const setCalls: any[] = [];
|
|
312
|
+
|
|
313
|
+
world.hook([wildcardRel], {
|
|
314
|
+
on_set: (entityId, relations) => {
|
|
315
|
+
setCalls.push({ entityId, relations });
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Create entity with no relations
|
|
320
|
+
const entity1 = world.spawn().build();
|
|
321
|
+
world.sync();
|
|
322
|
+
|
|
323
|
+
// Create entity with a different relation type
|
|
324
|
+
const entity2 = world.spawn().with(relation(OtherData, target), { other: 42 }).build();
|
|
325
|
+
world.sync();
|
|
326
|
+
|
|
327
|
+
// Neither should trigger the hook
|
|
328
|
+
expect(setCalls.length).toBe(0);
|
|
329
|
+
expect(world.exists(entity1)).toBe(true);
|
|
330
|
+
expect(world.exists(entity2)).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
});
|