@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.
Files changed (82) hide show
  1. package/{builder.d.mts → dist/builder.d.mts} +4 -2
  2. package/{world.mjs → dist/world.mjs} +9 -30
  3. package/dist/world.mjs.map +1 -0
  4. package/examples/advanced-scheduling.ts +96 -0
  5. package/examples/collision-detection.ts +229 -0
  6. package/examples/inventory-system-relations.ts +108 -0
  7. package/examples/parent-child-hierarchy.ts +206 -0
  8. package/examples/serialization.ts +337 -0
  9. package/examples/simple.ts +96 -0
  10. package/examples/spatial-grid.ts +276 -0
  11. package/examples/state-machine.ts +273 -0
  12. package/examples/tag-filtering.ts +266 -0
  13. package/package.json +58 -12
  14. package/src/__tests__/commands/buffer-limits.test.ts +72 -0
  15. package/src/__tests__/commands/buffer.test.ts +195 -0
  16. package/src/__tests__/component/singleton.test.ts +148 -0
  17. package/src/__tests__/core/archetype.test.ts +247 -0
  18. package/src/__tests__/core/bitset.test.ts +171 -0
  19. package/src/__tests__/core/changeset.test.ts +254 -0
  20. package/src/__tests__/core/multi-map.test.ts +74 -0
  21. package/src/__tests__/entity/component-registry.test.ts +66 -0
  22. package/src/__tests__/entity/entity.test.ts +520 -0
  23. package/src/__tests__/entity/id-manager.test.ts +157 -0
  24. package/src/__tests__/entity/id-system.test.ts +260 -0
  25. package/src/__tests__/perf/comprehensive.perf.test.ts +300 -0
  26. package/src/__tests__/perf/sync-hotpath.perf.test.ts +79 -0
  27. package/src/__tests__/query/basic.test.ts +341 -0
  28. package/src/__tests__/query/caching.test.ts +112 -0
  29. package/src/__tests__/query/filter.test.ts +111 -0
  30. package/src/__tests__/query/optional.test.ts +231 -0
  31. package/src/__tests__/query/perf.test.ts +99 -0
  32. package/src/__tests__/relations/dont-fragment/basic.test.ts +496 -0
  33. package/src/__tests__/relations/dont-fragment/query-notification.test.ts +125 -0
  34. package/src/__tests__/relations/wildcard.test.ts +179 -0
  35. package/src/__tests__/serialization/bounds.test.ts +237 -0
  36. package/src/__tests__/testing/assertions.test.ts +224 -0
  37. package/src/__tests__/testing/entity-builder.test.ts +84 -0
  38. package/src/__tests__/testing/snapshot.test.ts +150 -0
  39. package/src/__tests__/testing/world-fixture.test.ts +73 -0
  40. package/src/__tests__/world/component-hooks.test.ts +185 -0
  41. package/src/__tests__/world/component-management.test.ts +447 -0
  42. package/src/__tests__/world/entity-management.test.ts +86 -0
  43. package/src/__tests__/world/get-optional.test.ts +96 -0
  44. package/src/__tests__/world/multi-component-hooks.test.ts +502 -0
  45. package/src/__tests__/world/perf.test.ts +93 -0
  46. package/src/__tests__/world/query.test.ts +223 -0
  47. package/src/__tests__/world/serialize.test.ts +83 -0
  48. package/src/__tests__/world/wildcard-relation-hooks.test.ts +332 -0
  49. package/src/archetype/archetype.ts +472 -0
  50. package/src/archetype/helpers.ts +186 -0
  51. package/src/archetype/store.ts +33 -0
  52. package/src/commands/buffer.ts +110 -0
  53. package/src/commands/changeset.ts +104 -0
  54. package/src/component/entity-store.ts +223 -0
  55. package/src/component/registry.ts +657 -0
  56. package/src/component/type-utils.ts +9 -0
  57. package/src/entity/index.ts +63 -0
  58. package/src/entity/manager.ts +115 -0
  59. package/src/entity/relation.ts +319 -0
  60. package/src/entity/types.ts +135 -0
  61. package/src/index.ts +41 -0
  62. package/src/query/filter.ts +75 -0
  63. package/src/query/query.ts +313 -0
  64. package/src/query/registry.ts +101 -0
  65. package/src/storage/serialization.ts +130 -0
  66. package/src/testing/index.ts +634 -0
  67. package/src/types/index.ts +99 -0
  68. package/src/utils/bit-set.ts +133 -0
  69. package/src/utils/multi-map.ts +96 -0
  70. package/src/utils/utils.ts +19 -0
  71. package/src/world/builder.ts +100 -0
  72. package/src/world/commands.ts +378 -0
  73. package/src/world/hooks.ts +358 -0
  74. package/src/world/references.ts +38 -0
  75. package/src/world/serialization.ts +122 -0
  76. package/src/world/world.ts +1201 -0
  77. package/world.mjs.map +0 -1
  78. /package/{index.d.mts → dist/index.d.mts} +0 -0
  79. /package/{index.mjs → dist/index.mjs} +0 -0
  80. /package/{testing.d.mts → dist/testing.d.mts} +0 -0
  81. /package/{testing.mjs → dist/testing.mjs} +0 -0
  82. /package/{testing.mjs.map → dist/testing.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
+ });