@codehz/ecs 0.7.1 → 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/{builder.d.mts → dist/builder.d.mts} +4 -2
- package/{world.mjs → dist/world.mjs} +9 -30
- package/dist/world.mjs.map +1 -0
- 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/world.mjs.map +0 -1
- /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
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, createEntityId, relation, type ComponentId, type EntityId } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World - Component Management", () => {
|
|
6
|
+
type Position = { x: number; y: number };
|
|
7
|
+
type Velocity = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
let positionComponent: ComponentId<Position>;
|
|
10
|
+
let velocityComponent: ComponentId<Velocity>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
positionComponent = component<Position>();
|
|
14
|
+
velocityComponent = component<Velocity>();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should add components to entities", () => {
|
|
18
|
+
const world = new World();
|
|
19
|
+
const entity = world.new();
|
|
20
|
+
const position: Position = { x: 10, y: 20 };
|
|
21
|
+
|
|
22
|
+
world.set(entity, positionComponent, position);
|
|
23
|
+
world.sync(); // Execute deferred commands
|
|
24
|
+
|
|
25
|
+
expect(world.has(entity, positionComponent)).toBe(true);
|
|
26
|
+
expect(world.get(entity, positionComponent)).toEqual(position);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should update existing components", () => {
|
|
30
|
+
const world = new World();
|
|
31
|
+
const entity = world.new();
|
|
32
|
+
const position1: Position = { x: 10, y: 20 };
|
|
33
|
+
const position2: Position = { x: 30, y: 40 };
|
|
34
|
+
|
|
35
|
+
world.set(entity, positionComponent, position1);
|
|
36
|
+
world.sync();
|
|
37
|
+
expect(world.get(entity, positionComponent)).toEqual(position1);
|
|
38
|
+
|
|
39
|
+
world.set(entity, positionComponent, position2);
|
|
40
|
+
world.sync();
|
|
41
|
+
expect(world.get(entity, positionComponent)).toEqual(position2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should keep last value for repeated set in one sync when merge is not configured", () => {
|
|
45
|
+
const world = new World();
|
|
46
|
+
const entity = world.new();
|
|
47
|
+
const position1: Position = { x: 10, y: 20 };
|
|
48
|
+
const position2: Position = { x: 30, y: 40 };
|
|
49
|
+
|
|
50
|
+
world.set(entity, positionComponent, position1);
|
|
51
|
+
world.set(entity, positionComponent, position2);
|
|
52
|
+
world.sync();
|
|
53
|
+
|
|
54
|
+
expect(world.get(entity, positionComponent)).toEqual(position2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should merge repeated sets in one sync for merge-enabled components", () => {
|
|
58
|
+
const world = new World();
|
|
59
|
+
const entity = world.new();
|
|
60
|
+
const Mailbox = component<string[]>({
|
|
61
|
+
merge: (prev, next) => [...prev, ...next],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
world.set(entity, Mailbox, ["A"]);
|
|
65
|
+
world.set(entity, Mailbox, ["B", "C"]);
|
|
66
|
+
world.sync();
|
|
67
|
+
|
|
68
|
+
expect(world.get(entity, Mailbox)).toEqual(["A", "B", "C"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should reset merge accumulation after remove in one sync", () => {
|
|
72
|
+
const world = new World();
|
|
73
|
+
const entity = world.new();
|
|
74
|
+
const Mailbox = component<string[]>({
|
|
75
|
+
merge: (prev, next) => [...prev, ...next],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
world.set(entity, Mailbox, ["A1"]);
|
|
79
|
+
world.set(entity, Mailbox, ["A2"]);
|
|
80
|
+
world.remove(entity, Mailbox);
|
|
81
|
+
world.set(entity, Mailbox, ["B1"]);
|
|
82
|
+
world.set(entity, Mailbox, ["B2"]);
|
|
83
|
+
world.sync();
|
|
84
|
+
|
|
85
|
+
expect(world.get(entity, Mailbox)).toEqual(["B1", "B2"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should merge relation sets by exact component type only", () => {
|
|
89
|
+
const world = new World();
|
|
90
|
+
const entity = world.new();
|
|
91
|
+
const target1 = world.new();
|
|
92
|
+
const target2 = world.new();
|
|
93
|
+
const MailRel = component<string[]>({
|
|
94
|
+
merge: (prev, next) => [...prev, ...next],
|
|
95
|
+
});
|
|
96
|
+
const rel1 = relation(MailRel, target1);
|
|
97
|
+
const rel2 = relation(MailRel, target2);
|
|
98
|
+
|
|
99
|
+
world.set(entity, rel1, ["T1-A"]);
|
|
100
|
+
world.set(entity, rel2, ["T2-A"]);
|
|
101
|
+
world.set(entity, rel1, ["T1-B"]);
|
|
102
|
+
world.set(entity, rel2, ["T2-B"]);
|
|
103
|
+
world.sync();
|
|
104
|
+
|
|
105
|
+
expect(world.get(entity, rel1)).toEqual(["T1-A", "T1-B"]);
|
|
106
|
+
expect(world.get(entity, rel2)).toEqual(["T2-A", "T2-B"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should apply merge for singleton(component entity) sets", () => {
|
|
110
|
+
const world = new World();
|
|
111
|
+
const Inbox = component<string[]>({
|
|
112
|
+
merge: (prev, next) => [...prev, ...next],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
world.set(Inbox, ["A"]);
|
|
116
|
+
world.set(Inbox, ["B"]);
|
|
117
|
+
world.sync();
|
|
118
|
+
expect(world.get(Inbox)).toEqual(["A", "B"]);
|
|
119
|
+
|
|
120
|
+
world.remove(Inbox);
|
|
121
|
+
world.set(Inbox, ["C"]);
|
|
122
|
+
world.set(Inbox, ["D"]);
|
|
123
|
+
world.sync();
|
|
124
|
+
expect(world.get(Inbox)).toEqual(["C", "D"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should remove components from entities", () => {
|
|
128
|
+
const world = new World();
|
|
129
|
+
const entity = world.new();
|
|
130
|
+
const position: Position = { x: 10, y: 20 };
|
|
131
|
+
|
|
132
|
+
world.set(entity, positionComponent, position);
|
|
133
|
+
world.sync();
|
|
134
|
+
expect(world.has(entity, positionComponent)).toBe(true);
|
|
135
|
+
|
|
136
|
+
world.remove(entity, positionComponent);
|
|
137
|
+
world.sync();
|
|
138
|
+
expect(world.has(entity, positionComponent)).toBe(false);
|
|
139
|
+
expect(() => world.get(entity, positionComponent)).toThrow(
|
|
140
|
+
/^Entity \d+ does not have component \d+\. Use has\(\) to check component existence before calling get\(\)\.$/,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should throw error when removing invalid component type", () => {
|
|
145
|
+
const world = new World();
|
|
146
|
+
const entity = world.new();
|
|
147
|
+
const invalidComponentType = 0 as EntityId<any>; // Invalid component ID
|
|
148
|
+
|
|
149
|
+
expect(() => world.remove(entity, invalidComponentType)).toThrow("Invalid component type: 0");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should allow removing wildcard relation components", () => {
|
|
153
|
+
const world = new World();
|
|
154
|
+
const entity = world.new();
|
|
155
|
+
const position: Position = { x: 10, y: 20 };
|
|
156
|
+
const targetEntity1 = world.new();
|
|
157
|
+
const targetEntity2 = world.new();
|
|
158
|
+
const relationId1 = relation(positionComponent, targetEntity1);
|
|
159
|
+
const relationId2 = relation(positionComponent, targetEntity2);
|
|
160
|
+
|
|
161
|
+
// Add multiple relation components with the same base component
|
|
162
|
+
world.set(entity, relationId1, position);
|
|
163
|
+
world.set(entity, relationId2, { x: 20, y: 30 });
|
|
164
|
+
world.sync();
|
|
165
|
+
expect(world.has(entity, relationId1)).toBe(true);
|
|
166
|
+
expect(world.has(entity, relationId2)).toBe(true);
|
|
167
|
+
|
|
168
|
+
// Remove using wildcard relation should remove all matching components
|
|
169
|
+
const wildcardRelation = relation(positionComponent, "*");
|
|
170
|
+
world.remove(entity, wildcardRelation);
|
|
171
|
+
world.sync();
|
|
172
|
+
expect(world.has(entity, relationId1)).toBe(false);
|
|
173
|
+
expect(world.has(entity, relationId2)).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should get wildcard relation components", () => {
|
|
177
|
+
const world = new World();
|
|
178
|
+
const entity = world.new();
|
|
179
|
+
const position: Position = { x: 10, y: 20 };
|
|
180
|
+
const targetEntity1 = world.new();
|
|
181
|
+
const targetEntity2 = world.new();
|
|
182
|
+
const relationId1 = relation(positionComponent, targetEntity1);
|
|
183
|
+
const relationId2 = relation(positionComponent, targetEntity2);
|
|
184
|
+
|
|
185
|
+
// Add multiple relation components with the same base component
|
|
186
|
+
world.set(entity, relationId1, position);
|
|
187
|
+
world.set(entity, relationId2, { x: 20, y: 30 });
|
|
188
|
+
world.sync();
|
|
189
|
+
|
|
190
|
+
// Get wildcard relations
|
|
191
|
+
const wildcardRelation = relation(positionComponent, "*");
|
|
192
|
+
const relations = world.get(entity, wildcardRelation);
|
|
193
|
+
expect(relations).toEqual([
|
|
194
|
+
[targetEntity2, { x: 20, y: 30 }],
|
|
195
|
+
[targetEntity1, { x: 10, y: 20 }],
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
// Test with entity not having components
|
|
199
|
+
const otherEntity = world.new();
|
|
200
|
+
const result = world.get(otherEntity, wildcardRelation);
|
|
201
|
+
expect(result).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should handle exclusive relations", () => {
|
|
205
|
+
const world = new World();
|
|
206
|
+
const entity = world.new();
|
|
207
|
+
const parent1 = world.new();
|
|
208
|
+
const parent2 = world.new();
|
|
209
|
+
|
|
210
|
+
// Create ChildOf component with exclusive option
|
|
211
|
+
const ChildOf = component({ exclusive: true });
|
|
212
|
+
|
|
213
|
+
const childOfParent1 = relation(ChildOf, parent1);
|
|
214
|
+
const childOfParent2 = relation(ChildOf, parent2);
|
|
215
|
+
|
|
216
|
+
// Add first relation
|
|
217
|
+
world.set(entity, childOfParent1);
|
|
218
|
+
world.sync();
|
|
219
|
+
expect(world.has(entity, childOfParent1)).toBe(true);
|
|
220
|
+
expect(world.has(entity, childOfParent2)).toBe(false);
|
|
221
|
+
|
|
222
|
+
// Add second relation - should replace the first
|
|
223
|
+
world.set(entity, childOfParent2);
|
|
224
|
+
world.sync();
|
|
225
|
+
expect(world.has(entity, childOfParent1)).toBe(false);
|
|
226
|
+
expect(world.has(entity, childOfParent2)).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should cascade delete referencing entities when cascade enabled", () => {
|
|
230
|
+
const world = new World();
|
|
231
|
+
const parent = world.new();
|
|
232
|
+
const child = world.new();
|
|
233
|
+
// Create ChildOf component with cascadeDelete option
|
|
234
|
+
const ChildOf = component({ cascadeDelete: true });
|
|
235
|
+
|
|
236
|
+
const childOfParent = relation(ChildOf, parent);
|
|
237
|
+
world.set(child, childOfParent);
|
|
238
|
+
world.sync();
|
|
239
|
+
|
|
240
|
+
world.delete(parent);
|
|
241
|
+
world.sync();
|
|
242
|
+
|
|
243
|
+
expect(world.exists(parent)).toBe(false);
|
|
244
|
+
expect(world.exists(child)).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should not cascade delete referencing entities when cascade disabled", () => {
|
|
248
|
+
const world = new World();
|
|
249
|
+
const parent = world.new();
|
|
250
|
+
const child = world.new();
|
|
251
|
+
const ChildOf = component();
|
|
252
|
+
|
|
253
|
+
const childOfParent = relation(ChildOf, parent);
|
|
254
|
+
world.set(child, childOfParent);
|
|
255
|
+
world.sync();
|
|
256
|
+
|
|
257
|
+
world.delete(parent);
|
|
258
|
+
world.sync();
|
|
259
|
+
|
|
260
|
+
expect(world.exists(parent)).toBe(false);
|
|
261
|
+
// child should still exist but without the relation
|
|
262
|
+
expect(world.exists(child)).toBe(true);
|
|
263
|
+
expect(world.has(child, childOfParent)).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should cascade delete transitively", () => {
|
|
267
|
+
const world = new World();
|
|
268
|
+
const a = world.new();
|
|
269
|
+
const b = world.new();
|
|
270
|
+
const c = world.new();
|
|
271
|
+
// Create ChildOf component with cascadeDelete option
|
|
272
|
+
const ChildOf = component({ cascadeDelete: true });
|
|
273
|
+
|
|
274
|
+
world.set(b, relation(ChildOf, a));
|
|
275
|
+
world.set(c, relation(ChildOf, b));
|
|
276
|
+
world.sync();
|
|
277
|
+
|
|
278
|
+
world.delete(a);
|
|
279
|
+
world.sync();
|
|
280
|
+
|
|
281
|
+
expect(world.exists(a)).toBe(false);
|
|
282
|
+
expect(world.exists(b)).toBe(false);
|
|
283
|
+
expect(world.exists(c)).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should handle cyclic cascade without infinite loop", () => {
|
|
287
|
+
const world = new World();
|
|
288
|
+
const a = world.new();
|
|
289
|
+
const b = world.new();
|
|
290
|
+
// Create ChildOf component with cascadeDelete option
|
|
291
|
+
const ChildOf = component({ cascadeDelete: true });
|
|
292
|
+
|
|
293
|
+
world.set(a, relation(ChildOf, b));
|
|
294
|
+
world.set(b, relation(ChildOf, a));
|
|
295
|
+
world.sync();
|
|
296
|
+
|
|
297
|
+
world.delete(a);
|
|
298
|
+
world.sync();
|
|
299
|
+
|
|
300
|
+
expect(world.exists(a)).toBe(false);
|
|
301
|
+
expect(world.exists(b)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should prevent archetype fragmentation with dontFragment relations", () => {
|
|
305
|
+
const world = new World();
|
|
306
|
+
const entity1 = world.new();
|
|
307
|
+
const entity2 = world.new();
|
|
308
|
+
const target1 = world.new();
|
|
309
|
+
const target2 = world.new();
|
|
310
|
+
|
|
311
|
+
// Create Follows component with dontFragment option
|
|
312
|
+
const Follows = component<{ strength: number }>({ dontFragment: true });
|
|
313
|
+
|
|
314
|
+
const followsTarget1 = relation(Follows, target1);
|
|
315
|
+
const followsTarget2 = relation(Follows, target2);
|
|
316
|
+
|
|
317
|
+
// Add different relations to different entities
|
|
318
|
+
world.set(entity1, followsTarget1, { strength: 1 });
|
|
319
|
+
world.set(entity2, followsTarget2, { strength: 2 });
|
|
320
|
+
world.sync();
|
|
321
|
+
|
|
322
|
+
// Both entities should exist and have their relations
|
|
323
|
+
expect(world.has(entity1, followsTarget1)).toBe(true);
|
|
324
|
+
expect(world.has(entity2, followsTarget2)).toBe(true);
|
|
325
|
+
|
|
326
|
+
// They should be in the same archetype despite having different relation targets
|
|
327
|
+
// (this is the key behavior of dontFragment)
|
|
328
|
+
const archetype1 = (world as any).entityToArchetype.get(entity1);
|
|
329
|
+
const archetype2 = (world as any).entityToArchetype.get(entity2);
|
|
330
|
+
expect(archetype1).toBe(archetype2);
|
|
331
|
+
|
|
332
|
+
// Verify the wildcard marker is present
|
|
333
|
+
const wildcardMarker = relation(Follows, "*");
|
|
334
|
+
expect(archetype1.componentTypes).toContain(wildcardMarker);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should support cascadeDelete and dontFragment simultaneously", () => {
|
|
338
|
+
const world = new World();
|
|
339
|
+
const parent = world.new();
|
|
340
|
+
const child1 = world.new();
|
|
341
|
+
const child2 = world.new();
|
|
342
|
+
|
|
343
|
+
// Create ChildOf component with both cascadeDelete and dontFragment options
|
|
344
|
+
const ChildOf = component<{ priority: number }>({ cascadeDelete: true, dontFragment: true });
|
|
345
|
+
|
|
346
|
+
const childOfParent1 = relation(ChildOf, parent);
|
|
347
|
+
const childOfParent2 = relation(ChildOf, parent);
|
|
348
|
+
|
|
349
|
+
// Add relations to children
|
|
350
|
+
world.set(child1, childOfParent1, { priority: 1 });
|
|
351
|
+
world.set(child2, childOfParent2, { priority: 2 });
|
|
352
|
+
world.sync();
|
|
353
|
+
|
|
354
|
+
// Verify relations exist
|
|
355
|
+
expect(world.has(child1, childOfParent1)).toBe(true);
|
|
356
|
+
expect(world.has(child2, childOfParent2)).toBe(true);
|
|
357
|
+
|
|
358
|
+
// Both children should be in the same archetype (dontFragment behavior)
|
|
359
|
+
const archetype1 = (world as any).entityToArchetype.get(child1);
|
|
360
|
+
const archetype2 = (world as any).entityToArchetype.get(child2);
|
|
361
|
+
expect(archetype1).toBe(archetype2);
|
|
362
|
+
|
|
363
|
+
// Delete parent - should cascade delete both children (cascadeDelete behavior)
|
|
364
|
+
world.delete(parent);
|
|
365
|
+
world.sync();
|
|
366
|
+
|
|
367
|
+
expect(world.exists(parent)).toBe(false);
|
|
368
|
+
expect(world.exists(child1)).toBe(false);
|
|
369
|
+
expect(world.exists(child2)).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("should handle multiple components", () => {
|
|
373
|
+
const world = new World();
|
|
374
|
+
const entity = world.new();
|
|
375
|
+
const position: Position = { x: 10, y: 20 };
|
|
376
|
+
const velocity: Velocity = { x: 1, y: 2 };
|
|
377
|
+
|
|
378
|
+
world.set(entity, positionComponent, position);
|
|
379
|
+
world.set(entity, velocityComponent, velocity);
|
|
380
|
+
world.sync();
|
|
381
|
+
|
|
382
|
+
expect(world.has(entity, positionComponent)).toBe(true);
|
|
383
|
+
expect(world.has(entity, velocityComponent)).toBe(true);
|
|
384
|
+
expect(world.get(entity, positionComponent)).toEqual(position);
|
|
385
|
+
expect(world.get(entity, velocityComponent)).toEqual(velocity);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should throw error when adding component to non-existent entity", () => {
|
|
389
|
+
const world = new World();
|
|
390
|
+
const fakeEntity = createEntityId(9999);
|
|
391
|
+
const position: Position = { x: 10, y: 20 };
|
|
392
|
+
|
|
393
|
+
expect(() => world.set(fakeEntity, positionComponent, position)).toThrow("Entity 9999 does not exist");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should throw error when adding invalid component type", () => {
|
|
397
|
+
const world = new World();
|
|
398
|
+
const entity = world.new();
|
|
399
|
+
const position: Position = { x: 10, y: 20 };
|
|
400
|
+
const invalidComponentType = 0 as EntityId<any>; // Invalid component ID
|
|
401
|
+
|
|
402
|
+
expect(() => world.set(entity, invalidComponentType, position)).toThrow("Invalid component type: 0");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("should throw error when adding wildcard relation component", () => {
|
|
406
|
+
const world = new World();
|
|
407
|
+
const entity = world.new();
|
|
408
|
+
const position: Position = { x: 10, y: 20 };
|
|
409
|
+
const wildcardRelation = relation(positionComponent, "*");
|
|
410
|
+
|
|
411
|
+
expect(() => world.set(entity, wildcardRelation, position)).toThrow(
|
|
412
|
+
"Cannot directly add wildcard relation components",
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("should throw error when getting component from non-existent entity", () => {
|
|
417
|
+
const world = new World();
|
|
418
|
+
const fakeEntity = createEntityId(9999);
|
|
419
|
+
|
|
420
|
+
expect(() => world.get(fakeEntity, positionComponent)).toThrow("Entity 9999 does not exist");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("should allow setting undefined as component data", () => {
|
|
424
|
+
const world = new World();
|
|
425
|
+
const entity = world.new();
|
|
426
|
+
|
|
427
|
+
const optionalPositionComponent = component<Position | undefined>();
|
|
428
|
+
|
|
429
|
+
// Add component with undefined data
|
|
430
|
+
world.set(entity, optionalPositionComponent, undefined);
|
|
431
|
+
world.sync();
|
|
432
|
+
|
|
433
|
+
expect(world.has(entity, optionalPositionComponent)).toBe(true);
|
|
434
|
+
expect(world.get(entity, optionalPositionComponent)).toBeUndefined();
|
|
435
|
+
|
|
436
|
+
// Update to a defined value
|
|
437
|
+
const position: Position = { x: 10, y: 20 };
|
|
438
|
+
world.set(entity, optionalPositionComponent, position);
|
|
439
|
+
world.sync();
|
|
440
|
+
expect(world.get(entity, optionalPositionComponent)).toEqual(position);
|
|
441
|
+
|
|
442
|
+
// Update back to undefined
|
|
443
|
+
world.set(entity, optionalPositionComponent, undefined);
|
|
444
|
+
world.sync();
|
|
445
|
+
expect(world.get(entity, optionalPositionComponent)).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { component, createEntityId, relation } from "../../entity";
|
|
3
|
+
import { World } from "../../world/world";
|
|
4
|
+
|
|
5
|
+
describe("World - Entity Management", () => {
|
|
6
|
+
it("should create entities", () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const entity1 = world.new();
|
|
9
|
+
const entity2 = world.new();
|
|
10
|
+
|
|
11
|
+
expect(world.exists(entity1)).toBe(true);
|
|
12
|
+
expect(world.exists(entity2)).toBe(true);
|
|
13
|
+
expect(entity1).not.toBe(entity2);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should destroy entities", () => {
|
|
17
|
+
const world = new World();
|
|
18
|
+
const entity = world.new();
|
|
19
|
+
expect(world.exists(entity)).toBe(true);
|
|
20
|
+
|
|
21
|
+
world.delete(entity);
|
|
22
|
+
world.sync();
|
|
23
|
+
expect(world.exists(entity)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle destroying non-existent entities gracefully", () => {
|
|
27
|
+
const world = new World();
|
|
28
|
+
const fakeEntity = createEntityId(9999);
|
|
29
|
+
expect(world.exists(fakeEntity)).toBe(false);
|
|
30
|
+
// Should not throw
|
|
31
|
+
world.delete(fakeEntity);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should support component id as entity id with fast path storage", () => {
|
|
35
|
+
const world = new World();
|
|
36
|
+
const Meta = component<{ tag: string }>("Meta");
|
|
37
|
+
const Payload = component<{ value: number }>("Payload");
|
|
38
|
+
|
|
39
|
+
expect(world.exists(Meta)).toBe(true);
|
|
40
|
+
|
|
41
|
+
world.set(Meta, Payload, { value: 42 });
|
|
42
|
+
world.sync();
|
|
43
|
+
|
|
44
|
+
expect(world.has(Meta, Payload)).toBe(true);
|
|
45
|
+
expect(world.get(Meta, Payload)).toEqual({ value: 42 });
|
|
46
|
+
|
|
47
|
+
const query = world.createQuery([Payload]);
|
|
48
|
+
expect(query.getEntities()).toEqual([]);
|
|
49
|
+
|
|
50
|
+
let hookCalls = 0;
|
|
51
|
+
world.hook([Payload], {
|
|
52
|
+
on_init: () => {
|
|
53
|
+
hookCalls++;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
world.set(Meta, Payload, { value: 43 });
|
|
58
|
+
world.sync();
|
|
59
|
+
expect(hookCalls).toBe(0);
|
|
60
|
+
|
|
61
|
+
world.delete(Meta);
|
|
62
|
+
world.sync();
|
|
63
|
+
expect(world.has(Meta, Payload)).toBe(false);
|
|
64
|
+
expect(world.getOptional(Meta, Payload)).toBeUndefined();
|
|
65
|
+
expect(world.exists(Meta)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should clear relation-entity data when target entity is deleted", () => {
|
|
69
|
+
const world = new World();
|
|
70
|
+
const Link = component("Link");
|
|
71
|
+
const Payload = component<{ value: number }>("Payload2");
|
|
72
|
+
|
|
73
|
+
const target = world.new();
|
|
74
|
+
const relationEntity = relation(Link, target);
|
|
75
|
+
|
|
76
|
+
world.set(relationEntity, Payload, { value: 9 });
|
|
77
|
+
world.sync();
|
|
78
|
+
expect(world.has(relationEntity, Payload)).toBe(true);
|
|
79
|
+
|
|
80
|
+
world.delete(target);
|
|
81
|
+
world.sync();
|
|
82
|
+
expect(world.has(relationEntity, Payload)).toBe(false);
|
|
83
|
+
expect(world.getOptional(relationEntity, Payload)).toBeUndefined();
|
|
84
|
+
expect(world.exists(relationEntity)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { EntityId } from "../../entity";
|
|
3
|
+
import { component, relation } from "../../entity";
|
|
4
|
+
import { World } from "../../world/world";
|
|
5
|
+
|
|
6
|
+
function expectType<T>(_value: T): void {}
|
|
7
|
+
|
|
8
|
+
describe("World.getOptional", () => {
|
|
9
|
+
it("should return { value: T } when component exists", () => {
|
|
10
|
+
const world = new World();
|
|
11
|
+
const PositionId = component<{ x: number; y: number }>();
|
|
12
|
+
const entity = world.new();
|
|
13
|
+
world.set(entity, PositionId, { x: 10, y: 20 });
|
|
14
|
+
world.sync();
|
|
15
|
+
|
|
16
|
+
const result = world.getOptional(entity, PositionId);
|
|
17
|
+
expect(result).toEqual({ value: { x: 10, y: 20 } });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return undefined when component does not exist", () => {
|
|
21
|
+
const world = new World();
|
|
22
|
+
const PositionId = component<{ x: number; y: number }>();
|
|
23
|
+
const VelocityId = component<{ x: number; y: number }>();
|
|
24
|
+
const entity = world.new();
|
|
25
|
+
world.set(entity, PositionId, { x: 10, y: 20 });
|
|
26
|
+
world.sync();
|
|
27
|
+
|
|
28
|
+
const result = world.getOptional(entity, VelocityId);
|
|
29
|
+
expect(result).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should distinguish between component value being undefined and component not existing", () => {
|
|
33
|
+
const world = new World();
|
|
34
|
+
const UndefinedComponent = component<undefined>();
|
|
35
|
+
const entity = world.new();
|
|
36
|
+
world.set(entity, UndefinedComponent, undefined);
|
|
37
|
+
world.sync();
|
|
38
|
+
|
|
39
|
+
// Exists with undefined value
|
|
40
|
+
expect(world.getOptional(entity, UndefinedComponent)).toEqual({ value: undefined });
|
|
41
|
+
|
|
42
|
+
// Not existing
|
|
43
|
+
const Other = component<number>();
|
|
44
|
+
expect(world.getOptional(entity, Other)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should throw error when entity does not exist", () => {
|
|
48
|
+
const world = new World();
|
|
49
|
+
const PositionId = component<{ x: number; y: number }>();
|
|
50
|
+
const entity = 1234 as any; // non-existent entity
|
|
51
|
+
|
|
52
|
+
expect(() => world.getOptional(entity, PositionId)).toThrow("Entity 1234 does not exist");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should return matching relations for wildcard relations", () => {
|
|
56
|
+
const world = new World();
|
|
57
|
+
const Rel = component<number>();
|
|
58
|
+
const target = world.new();
|
|
59
|
+
const entity = world.new();
|
|
60
|
+
world.set(entity, relation(Rel, target), 100);
|
|
61
|
+
world.sync();
|
|
62
|
+
|
|
63
|
+
const wildcard = relation(Rel, "*");
|
|
64
|
+
const result = world.getOptional(entity, wildcard);
|
|
65
|
+
expectType<{ value: [EntityId<unknown>, number][] } | undefined>(result);
|
|
66
|
+
expect(result).toEqual({ value: [[target, 100]] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should return undefined for wildcard relations with no matching relations", () => {
|
|
70
|
+
const world = new World();
|
|
71
|
+
const Rel = component<number>();
|
|
72
|
+
const entity = world.new();
|
|
73
|
+
world.sync();
|
|
74
|
+
|
|
75
|
+
const wildcard = relation(Rel, "*");
|
|
76
|
+
const result = world.getOptional(entity, wildcard);
|
|
77
|
+
expectType<{ value: [number, number][] } | undefined>(result);
|
|
78
|
+
expect(result).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should work with dontFragment relations", () => {
|
|
82
|
+
const world = new World();
|
|
83
|
+
const DFRel = component<number>({ dontFragment: true });
|
|
84
|
+
const target = world.new();
|
|
85
|
+
const entity = world.new();
|
|
86
|
+
world.set(entity, relation(DFRel, target), 42);
|
|
87
|
+
world.sync();
|
|
88
|
+
|
|
89
|
+
const relId = relation(DFRel, target);
|
|
90
|
+
expect(world.getOptional(entity, relId)).toEqual({ value: 42 });
|
|
91
|
+
|
|
92
|
+
const otherTarget = world.new();
|
|
93
|
+
const otherRelId = relation(DFRel, otherTarget);
|
|
94
|
+
expect(world.getOptional(entity, otherRelId)).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|