@codehz/ecs 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.en.md +26 -3
  2. package/README.md +28 -3
  3. package/dist/builder.d.mts +296 -46
  4. package/dist/index.d.mts +2 -2
  5. package/dist/index.mjs +2 -2
  6. package/dist/testing.d.mts +1 -1
  7. package/dist/testing.mjs +1 -1
  8. package/dist/world.mjs +452 -179
  9. package/dist/world.mjs.map +1 -1
  10. package/examples/debug-observability.ts +92 -0
  11. package/examples/inventory-system-relations.ts +1 -1
  12. package/examples/parent-child-hierarchy.ts +18 -38
  13. package/package.json +1 -1
  14. package/skills/ecs/SKILL.md +4 -4
  15. package/src/__tests__/component/singleton.test.ts +40 -1
  16. package/src/__tests__/core/archetype.test.ts +155 -13
  17. package/src/__tests__/core/bitset.test.ts +12 -0
  18. package/src/__tests__/entity/entity.test.ts +33 -0
  19. package/src/__tests__/entity/id-system.test.ts +40 -0
  20. package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
  21. package/src/__tests__/perf/serialization.perf.test.ts +242 -0
  22. package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
  23. package/src/__tests__/query/caching.test.ts +62 -0
  24. package/src/__tests__/query/filter.test.ts +16 -22
  25. package/src/__tests__/query/perf.test.ts +3 -5
  26. package/src/__tests__/relations/hierarchy.test.ts +208 -0
  27. package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
  28. package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
  29. package/src/__tests__/serialization/bounds.test.ts +134 -1
  30. package/src/__tests__/world/commands.test.ts +337 -0
  31. package/src/__tests__/world/debug-stats.test.ts +206 -0
  32. package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
  33. package/src/__tests__/world/serialize.test.ts +17 -0
  34. package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
  35. package/src/archetype/archetype.ts +96 -46
  36. package/src/archetype/helpers.ts +7 -29
  37. package/src/archetype/store.ts +35 -20
  38. package/src/commands/buffer.ts +5 -2
  39. package/src/commands/changeset.ts +0 -31
  40. package/src/component/registry.ts +64 -63
  41. package/src/entity/index.ts +6 -3
  42. package/src/index.ts +13 -0
  43. package/src/query/filter.ts +4 -10
  44. package/src/query/query.ts +12 -12
  45. package/src/storage/serialization.ts +29 -2
  46. package/src/types/index.ts +71 -0
  47. package/src/world/commands.ts +44 -56
  48. package/src/world/hooks.ts +8 -0
  49. package/src/world/serialization.ts +32 -18
  50. package/src/world/world.ts +387 -20
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Example: Using the Debug Stats Collector for development & leak detection.
3
+ *
4
+ * Run with:
5
+ * bun run examples/debug-observability.ts
6
+ */
7
+
8
+ import { component, relation, World } from "../src";
9
+
10
+ type Position = { x: number; y: number };
11
+ type Velocity = { x: number; y: number };
12
+
13
+ // Components
14
+ const Position = component<Position>();
15
+ const Velocity = component<Velocity>();
16
+ const ChildOf = component<void>({ exclusive: true, sparse: true });
17
+
18
+ const world = new World();
19
+
20
+ // Create a debug stats collector
21
+ const collector = world.createDebugStatsCollector((stats) => {
22
+ console.log("=== Debug Stats ===");
23
+ console.log(`Sync time: ${(stats.timestamps.syncEnd - stats.timestamps.syncStart).toFixed(3)}ms`);
24
+ console.log(`Command buffer iterations: ${stats.commandIterations}`);
25
+ console.log(`Entities: ${stats.entities.total} (freelist: ${stats.entities.freelistSize})`);
26
+ console.log(`Archetypes: ${stats.archetypes.total} (empty: ${stats.archetypes.empty})`);
27
+ console.log(`Queries (cached/registered): ${stats.queries.cached}/${stats.queries.registered}`);
28
+ console.log(`Hooks: ${stats.hooks.total}`);
29
+ console.log(`Indices:`, stats.indices);
30
+
31
+ const act = stats.activity;
32
+ console.log("Activity this sync:");
33
+ console.log(` Migrations: ${act.migrations}`);
34
+ console.log(` Hooks executed: ${act.hooksExecuted}`);
35
+ console.log(` Archetypes created: ${act.archetypesCreated}`);
36
+ console.log(` Archetypes removed: ${act.archetypesRemoved}`);
37
+ console.log("===================\n");
38
+ });
39
+
40
+ // Setup some entities
41
+ const parent = world.new();
42
+ world.set(parent, Position, { x: 0, y: 0 });
43
+
44
+ const children: any[] = [];
45
+ for (let i = 0; i < 5; i++) {
46
+ const child = world.new();
47
+ world.set(child, Position, { x: i * 10, y: 0 });
48
+ world.set(child, Velocity, { x: 1, y: 0.5 });
49
+ world.set(child, relation(ChildOf, parent));
50
+ children.push(child);
51
+ }
52
+
53
+ console.log("Initial sync (should create archetypes + relations)");
54
+ world.sync();
55
+
56
+ // Cause some migrations by adding/removing components
57
+ console.log("Adding/removing components to trigger migrations...");
58
+ for (const c of children) {
59
+ world.remove(c, Velocity);
60
+ }
61
+ world.sync();
62
+
63
+ world.set(children[0], Velocity, { x: 2, y: 0 });
64
+ world.sync();
65
+
66
+ // Add a hook to observe hook execution counting
67
+ world.hook([Position, Velocity], {
68
+ on_set: () => {},
69
+ });
70
+
71
+ console.log("Triggering hook + more structural changes");
72
+ world.set(children[1], Velocity, { x: 3, y: 1 });
73
+ world.sync();
74
+
75
+ // Clean up some entities to potentially remove archetypes
76
+ console.log("Deleting some children...");
77
+ world.delete(children[2]);
78
+ world.delete(children[3]);
79
+ world.sync();
80
+
81
+ // Dispose the collector when done
82
+ collector[Symbol.dispose]();
83
+
84
+ console.log("Collector disposed. Further syncs will not trigger callbacks.");
85
+ world.set(parent, Position, { x: 100, y: 100 });
86
+ world.sync();
87
+
88
+ console.log("Done. This demonstrates typical usage for spotting:");
89
+ console.log("- Unexpected archetype growth (fragmentation)");
90
+ console.log("- High migration or hook execution counts");
91
+ console.log("- Command buffer iteration spikes");
92
+ console.log("- Leaking entities or relations (watch entity/freelist numbers over time)");
@@ -9,7 +9,7 @@ const ItemName = component<ItemName>({ name: "ItemName" });
9
9
  const Stackable = component<Stackable>({ name: "Stackable" });
10
10
  const Gold = component<Gold>({ name: "Gold" });
11
11
  const EquipmentSlot = component<EquipmentSlot>({ name: "EquipmentSlot" });
12
- const InInventory = component<void>({ name: "InInventory", dontFragment: true });
12
+ const InInventory = component<void>({ name: "InInventory", sparse: true });
13
13
 
14
14
  const world = new World();
15
15
 
@@ -1,5 +1,5 @@
1
1
  import { pipeline } from "@codehz/pipeline";
2
- import { World, component, relation, type EntityId, type Query } from "../src";
2
+ import { World, component, relation, type Query } from "../src";
3
3
 
4
4
  // Define component types
5
5
  type Transform = { x: number; y: number; rotation: number; scale: number };
@@ -13,7 +13,7 @@ const LocalTransform = component<Transform>({ name: "LocalTransform" });
13
13
  const WorldTransform = component<Transform>({ name: "WorldTransform" });
14
14
  const LinearVelocity = component<LinearVelocity>({ name: "LinearVelocity" });
15
15
  const AngularVelocity = component<AngularVelocity>({ name: "AngularVelocity" });
16
- const ChildOf = component<void>({ exclusive: true, dontFragment: true, name: "ChildOf" });
16
+ const ChildOf = component<void>({ exclusive: true, sparse: true, name: "ChildOf" });
17
17
 
18
18
  // Create the world
19
19
  const world = new World();
@@ -22,7 +22,6 @@ const world = new World();
22
22
  const movementQuery: Query = world.createQuery([LocalTransform, LinearVelocity]);
23
23
  const rotationQuery: Query = world.createQuery([LocalTransform, AngularVelocity]);
24
24
  const transformQuery: Query = world.createQuery([Name, LocalTransform, WorldTransform]);
25
- const childQuery: Query = world.createQuery([relation(ChildOf, "*")]);
26
25
  const renderQuery: Query = world.createQuery([Name, WorldTransform]);
27
26
 
28
27
  function toRadians(degrees: number): number {
@@ -59,38 +58,9 @@ function formatTransform(transform: Transform): string {
59
58
  return `pos=(${transform.x.toFixed(2)}, ${transform.y.toFixed(2)}) rot=${transform.rotation.toFixed(1)}deg scale=${transform.scale.toFixed(2)}`;
60
59
  }
61
60
 
62
- function buildChildrenByParent(): Map<EntityId, EntityId[]> {
63
- const childrenByParent = new Map<EntityId, EntityId[]>();
64
-
65
- childQuery.forEach([relation(ChildOf, "*")], (child, parents) => {
66
- const parent = parents[0]?.[0];
67
- if (parent === undefined) return;
68
-
69
- const children = childrenByParent.get(parent) ?? [];
70
- children.push(child);
71
- childrenByParent.set(parent, children);
72
- });
73
-
74
- return childrenByParent;
75
- }
76
-
77
- function propagateChildren(
78
- parent: EntityId,
79
- parentWorld: Transform,
80
- childrenByParent: Map<EntityId, EntityId[]>,
81
- ): void {
82
- const children = childrenByParent.get(parent);
83
- if (!children) return;
84
-
85
- for (const child of children) {
86
- const name = world.get(child, Name);
87
- const local = world.get(child, LocalTransform);
88
- const worldTransform = world.get(child, WorldTransform);
89
- copyTransform(worldTransform, composeTransform(local, parentWorld));
90
- console.log(` Child ${name.value}: ${formatTransform(worldTransform)}`);
91
- propagateChildren(child, worldTransform, childrenByParent);
92
- }
93
- }
61
+ // NOTE: Before the relation/hierarchy companion tools, this required manually
62
+ // building a children map every frame + writing recursive propagation.
63
+ // Now we can use the built-in efficient helpers (getChildren + traverseDescendants).
94
64
 
95
65
  // Build game loop using pipeline
96
66
  // Pass execution order is determined by addition order; no need to manually manage dependencies
@@ -115,16 +85,26 @@ const gameLoop = pipeline<{ deltaTime: number }>()
115
85
  });
116
86
  })
117
87
  // Hierarchy pass - propagate parent transforms into world transforms
88
+ // (modernized with the new relation/hierarchy companion tools)
118
89
  .addPass(() => {
119
90
  console.log(`[HierarchyPass] Propagating world transforms`);
120
- const childrenByParent = buildChildrenByParent();
121
91
 
122
92
  transformQuery.forEach([Name, LocalTransform, WorldTransform], (entity, name, localTransform, worldTransform) => {
123
- if (world.has(entity, relation(ChildOf, "*"))) return;
93
+ if (world.has(entity, relation(ChildOf, "*"))) return; // skip non-roots
124
94
 
125
95
  copyTransform(worldTransform, composeTransform(localTransform));
126
96
  console.log(` Root ${name.value}: ${formatTransform(worldTransform)}`);
127
- propagateChildren(entity, worldTransform, childrenByParent);
97
+
98
+ // Use the efficient built-in traverser (replaces manual Map + recursion)
99
+ world.traverseDescendants(entity, ChildOf, (child, _depth, parent) => {
100
+ if (!parent) return;
101
+ const childName = world.get(child, Name);
102
+ const local = world.get(child, LocalTransform);
103
+ const childWorld = world.get(child, WorldTransform);
104
+ const parentWorldT = world.get(parent, WorldTransform);
105
+ copyTransform(childWorld, composeTransform(local, parentWorldT));
106
+ console.log(` Child ${childName.value}: ${formatTransform(childWorld)}`);
107
+ });
128
108
  });
129
109
  })
130
110
  // Render pass - render propagated world transforms
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ecs",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -34,7 +34,7 @@ The library uses **archetype storage + deferred command buffering**. All structu
34
34
  7. **NEVER** call `sync()` inside `forEach`, hooks, or while iterating query results.
35
35
  8. **NEVER** confuse `remove(entity, Component)` with `delete(entity)`.
36
36
  9. **MUST** use relation components (`relation(Comp, target)`) instead of storing `EntityId` in data when you need to reference other entities.
37
- 10. **MUST** understand the three relation flags (`exclusive`, `cascadeDelete`, `dontFragment`) before using relations.
37
+ 10. **MUST** understand the three relation flags (`exclusive`, `cascadeDelete`, `sparse` / legacy `dontFragment`) before using relations.
38
38
 
39
39
  ---
40
40
 
@@ -205,7 +205,7 @@ Use `relation(Component, target)` to create entity-to-entity references.
205
205
  - When the target entity is deleted, **the entire referencing entity is deleted**.
206
206
  - This is transitive and powerful. Use deliberately.
207
207
 
208
- **`dontFragment: true`**
208
+ **`sparse: true`** (preferred; legacy key `dontFragment` is fully equivalent and supported)
209
209
 
210
210
  - Prevents archetype fragmentation when many different targets exist.
211
211
  - **Required** for relations with high cardinality or frequent target changes (e.g. `ChildOf` with thousands of children, AI targeting, inventory).
@@ -214,8 +214,8 @@ Use `relation(Component, target)` to create entity-to-entity references.
214
214
 
215
215
  ```ts
216
216
  const ChildOf = component({ exclusive: true, cascadeDelete: true });
217
- const Targeting = component({ exclusive: true, dontFragment: true });
218
- const InventorySlot = component({ dontFragment: true });
217
+ const Targeting = component({ exclusive: true, sparse: true });
218
+ const InventorySlot = component({ sparse: true });
219
219
  ```
220
220
 
221
221
  ### 8. Referencing Other Entities — Never Store Raw EntityId
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { component } from "../../entity";
2
+ import { ComponentEntityStore } from "../../component/entity-store";
3
+ import { component, createEntityId, relation, type EntityId } from "../../entity";
3
4
  import { World } from "../../world/world";
4
5
 
5
6
  describe("World - Singleton Component", () => {
@@ -145,4 +146,42 @@ describe("World - Singleton Component", () => {
145
146
  expect(world1.has(GlobalConfigId)).toBe(world2.has(GlobalConfigId, GlobalConfigId));
146
147
  expect(world1.has(GlobalConfigId)).toBe(false);
147
148
  });
149
+
150
+ it("should cover ComponentEntityStore hasWildcard, getWildcard, wildcard delete paths", () => {
151
+ const store = new ComponentEntityStore();
152
+ const compE = GlobalConfigId as EntityId; // reuse as component entity id (valid in range)
153
+ const target1 = createEntityId(1024);
154
+ const target2 = createEntityId(1025);
155
+ const relComp = relation(GlobalConfigId, target1); // entity-relation on the comp entity
156
+ const relComp2 = relation(GlobalConfigId, target2);
157
+ const wildcard = relation(GlobalConfigId, "*");
158
+
159
+ // Setup via internal? Use executeCommands to populate (simulates)
160
+ store.executeCommands(compE, [
161
+ { type: "set", componentType: relComp, component: { dist: 1 } } as any,
162
+ { type: "set", componentType: relComp2, component: { dist: 2 } } as any,
163
+ ]);
164
+
165
+ // hasWildcard
166
+ expect(store.hasWildcard(compE, GlobalConfigId as any)).toBe(true);
167
+ expect(store.hasWildcard(compE, GameStateId as any)).toBe(false);
168
+ expect(store.hasWildcard(createEntityId(9999), GlobalConfigId as any)).toBe(false); // no data
169
+
170
+ // getWildcard
171
+ const w1 = store.getWildcard(compE, wildcard as any);
172
+ expect(w1.length).toBe(2);
173
+
174
+ // wildcard delete via executeCommands
175
+ store.executeCommands(compE, [{ type: "delete", componentType: wildcard } as any]);
176
+ const afterDel = store.getWildcard(compE, wildcard as any);
177
+ expect(afterDel.length).toBe(0);
178
+
179
+ // also test get on non exist throws
180
+ expect(() => store.get(compE, createEntityId(5000) as any)).toThrow();
181
+ expect(store.getOptional(compE, createEntityId(5000) as any)).toBeUndefined();
182
+
183
+ // clear
184
+ store.clear(compE);
185
+ expect(store.has(compE, relComp)).toBe(false);
186
+ });
148
187
  });
@@ -1,6 +1,17 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { Archetype } from "../../archetype/archetype";
3
- import { DontFragmentStoreImpl } from "../../archetype/store";
3
+ import {
4
+ buildCacheKey,
5
+ buildRegularComponentValue,
6
+ buildSingleComponent,
7
+ buildWildcardRelationValue,
8
+ findWildcardRelations,
9
+ getWildcardRelationDataSource,
10
+ hasWildcardRelation,
11
+ isRelationType,
12
+ matchesRelationComponentId,
13
+ } from "../../archetype/helpers";
14
+ import { SparseStoreImpl } from "../../archetype/store";
4
15
  import { component, createEntityId, relation, type EntityId } from "../../entity";
5
16
 
6
17
  describe("Archetype", () => {
@@ -10,25 +21,25 @@ describe("Archetype", () => {
10
21
  const positionComponent = component<Position>();
11
22
  const velocityComponent = component<Velocity>();
12
23
 
13
- // Helper function to create a real DontFragmentStore for testing.
24
+ // Helper function to create a real SparseStore for testing.
14
25
  // We use the production implementation because the interface is now fully semantic.
15
- const createDontFragmentRelations = () => new DontFragmentStoreImpl();
26
+ const createSparseStore = () => new SparseStoreImpl();
16
27
 
17
28
  it("should create an archetype with component types", () => {
18
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
29
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
19
30
  expect(archetype.componentTypes).toEqual([positionComponent, velocityComponent]);
20
31
  expect(archetype.size).toBe(0);
21
32
  });
22
33
 
23
34
  it("should match component types", () => {
24
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
35
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
25
36
  expect(archetype.matches([positionComponent, velocityComponent])).toBe(true);
26
37
  expect(archetype.matches([velocityComponent, positionComponent])).toBe(true); // Order doesn't matter
27
38
  expect(archetype.matches([positionComponent])).toBe(false);
28
39
  });
29
40
 
30
41
  it("should add and remove entities", () => {
31
- const archetype = new Archetype([positionComponent, velocityComponent], createDontFragmentRelations());
42
+ const archetype = new Archetype([positionComponent, velocityComponent], createSparseStore());
32
43
  const entity1 = createEntityId(1024);
33
44
  const entity2 = createEntityId(1025);
34
45
 
@@ -57,7 +68,7 @@ describe("Archetype", () => {
57
68
  });
58
69
 
59
70
  it("should get and set component data", () => {
60
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
71
+ const archetype = new Archetype([positionComponent], createSparseStore());
61
72
  const entity = createEntityId(1024);
62
73
  const initialPosition: Position = { x: 5, y: 5 };
63
74
 
@@ -83,7 +94,7 @@ describe("Archetype", () => {
83
94
  const entity = createEntityId(1024);
84
95
 
85
96
  // Archetype with multiple relations
86
- const archetype = new Archetype([relation1, relation2], createDontFragmentRelations());
97
+ const archetype = new Archetype([relation1, relation2], createSparseStore());
87
98
 
88
99
  // Add entity with relations to target1 and target2
89
100
  archetype.addEntity(
@@ -109,7 +120,7 @@ describe("Archetype", () => {
109
120
  });
110
121
 
111
122
  it("should iterate over entities", () => {
112
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
123
+ const archetype = new Archetype([positionComponent], createSparseStore());
113
124
  const entity1 = createEntityId(1024);
114
125
  const entity2 = createEntityId(1025);
115
126
 
@@ -125,7 +136,7 @@ describe("Archetype", () => {
125
136
  });
126
137
 
127
138
  it("should get component data arrays", () => {
128
- const archetype = new Archetype([positionComponent], createDontFragmentRelations());
139
+ const archetype = new Archetype([positionComponent], createSparseStore());
129
140
  const entity1 = createEntityId(1024);
130
141
  const entity2 = createEntityId(1025);
131
142
  const pos1: Position = { x: 1, y: 1 };
@@ -153,8 +164,8 @@ describe("Archetype", () => {
153
164
  const relation3 = relation(positionComponent, createEntityId(1029)); // For entity2
154
165
 
155
166
  // Archetype with multiple relations
156
- const archetype1 = new Archetype([relation1, relation2], createDontFragmentRelations());
157
- const archetype2 = new Archetype([relation3], createDontFragmentRelations());
167
+ const archetype1 = new Archetype([relation1, relation2], createSparseStore());
168
+ const archetype2 = new Archetype([relation3], createSparseStore());
158
169
 
159
170
  // Add entity1 with relations to target1 and target2
160
171
  archetype1.addEntity(
@@ -200,7 +211,7 @@ describe("Archetype", () => {
200
211
  const relation2 = relation(positionComponent, target2);
201
212
  const wildcardPositionRelation = relation(positionComponent, "*");
202
213
 
203
- const archetype = new Archetype([relation1, relation2], createDontFragmentRelations());
214
+ const archetype = new Archetype([relation1, relation2], createSparseStore());
204
215
 
205
216
  const entity1 = createEntityId(1024);
206
217
 
@@ -246,4 +257,135 @@ describe("Archetype", () => {
246
257
  [target1, { distance: 100 }], // Updated
247
258
  ]);
248
259
  });
260
+
261
+ it("should cover getEntity, dump, getEntitiesWithComponents, getEntityToIndexMap", () => {
262
+ const archetype = new Archetype([positionComponent], createSparseStore());
263
+ const entity = createEntityId(1024);
264
+ archetype.addEntity(entity, new Map([[positionComponent, { x: 1, y: 2 }]]));
265
+
266
+ // Cover getEntity
267
+ const data = archetype.getEntity(entity);
268
+ expect(data).toBeDefined();
269
+ expect(data!.get(positionComponent)).toEqual({ x: 1, y: 2 });
270
+
271
+ // Cover dump
272
+ const dumped = archetype.dump();
273
+ expect(dumped).toHaveLength(1);
274
+ expect(dumped[0]!.entity).toBe(entity);
275
+
276
+ // Cover getEntitiesWithComponents
277
+ const withComps = archetype.getEntitiesWithComponents([positionComponent]);
278
+ expect(withComps).toHaveLength(1);
279
+ expect(withComps[0]!.entity).toBe(entity);
280
+
281
+ // Cover getEntityToIndexMap
282
+ const map = archetype.getEntityToIndexMap();
283
+ expect(map.get(entity)).toBe(0);
284
+ });
285
+
286
+ it("should cover SparseStoreImpl extra methods (hasAnyForComponent, getAllForEntities, multi)", () => {
287
+ const store = new SparseStoreImpl();
288
+ const e1 = createEntityId(2000);
289
+ const e2 = createEntityId(2001);
290
+
291
+ // Actually create proper relations for a component
292
+ const targetC1 = createEntityId(4001);
293
+ const targetC2 = createEntityId(4002);
294
+ const r1 = relation(velocityComponent, targetC1);
295
+ const r2 = relation(velocityComponent, targetC2);
296
+ const baseComp = velocityComponent;
297
+
298
+ store.setValue(e1, r1, { v: 1 });
299
+ store.setValue(e1, r2, { v: 2 }); // promote to multi
300
+
301
+ // hasAnyForComponent
302
+ expect(store.hasAnyForComponent(baseComp)).toBe(true);
303
+ expect(store.hasAnyForComponent(positionComponent)).toBe(false);
304
+
305
+ // getAllForEntities bulk
306
+ const bulk = store.getAllForEntities([e1, e2, createEntityId(5000)]);
307
+ expect(bulk.has(e1)).toBe(true);
308
+ expect(bulk.get(e1)).toHaveLength(2);
309
+
310
+ // getAllForEntity on one without
311
+ expect(store.getAllForEntity(e2)).toEqual([]);
312
+
313
+ // deleteValue on multi
314
+ store.deleteValue(e1, r1);
315
+ expect(store.getValue(e1, r1)).toBeUndefined();
316
+ // still has the other
317
+ expect(store.getValue(e1, r2)).toEqual({ v: 2 });
318
+
319
+ // delete last demotes? after delete one left in multi, delete last
320
+ store.deleteValue(e1, r2);
321
+ expect(store.hasAnyForComponent(baseComp)).toBe(false);
322
+ });
323
+
324
+ it("should cover archetype helpers (find*, has*, build*, matchers, error paths)", () => {
325
+ const comps = new Map<EntityId, any>();
326
+ const relC = relation(positionComponent, createEntityId(5001));
327
+ const relD = relation(positionComponent, createEntityId(5002));
328
+ comps.set(relC, { d: 10 });
329
+ comps.set(relD, { d: 20 });
330
+ comps.set(positionComponent, { x: 1 }); // non-relation
331
+
332
+ // findWildcardRelations
333
+ const found = findWildcardRelations(comps, positionComponent);
334
+ expect(found).toHaveLength(2);
335
+
336
+ // hasWildcardRelation
337
+ expect(hasWildcardRelation(comps, positionComponent)).toBe(true);
338
+ expect(hasWildcardRelation(comps, velocityComponent)).toBe(false);
339
+
340
+ // matchesRelationComponentId
341
+ expect(matchesRelationComponentId(relC, positionComponent)).toBe(true);
342
+ expect(matchesRelationComponentId(positionComponent, positionComponent)).toBe(false);
343
+
344
+ // isRelationType
345
+ expect(
346
+ isRelationType({
347
+ type: "entity-relation",
348
+ componentId: positionComponent,
349
+ targetId: createEntityId(1024),
350
+ } as any),
351
+ ).toBe(true);
352
+ expect(isRelationType({ type: "component" } as any)).toBe(false);
353
+
354
+ // buildCacheKey
355
+ const key = buildCacheKey([positionComponent, velocityComponent]);
356
+ expect(typeof key).toBe("string");
357
+
358
+ // getWildcardRelationDataSource
359
+ const ds = getWildcardRelationDataSource([relC, relD], positionComponent, false);
360
+ expect(ds).toHaveLength(2);
361
+ const dsOpt = getWildcardRelationDataSource([], positionComponent, true);
362
+ expect(dsOpt).toBeUndefined();
363
+
364
+ // buildRegularComponentValue
365
+ expect(buildRegularComponentValue([{ x: 9 }], 0, false)).toEqual({ x: 9 });
366
+ expect(buildRegularComponentValue(undefined, 0, true)).toBeUndefined();
367
+ expect(() => buildRegularComponentValue(undefined, 0, false)).toThrow();
368
+
369
+ // buildWildcardRelationValue - optional empty -> undefined
370
+ const dfStore = createSparseStore();
371
+ const wildcard = relation(positionComponent, "*");
372
+ const valOpt = buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6000), true);
373
+ expect(valOpt).toBeUndefined();
374
+
375
+ // buildWildcardRelationValue - mandatory empty -> throws
376
+ expect(() => buildWildcardRelationValue(wildcard, [], () => null, dfStore, createEntityId(6001), false)).toThrow(
377
+ /No matching relations found/,
378
+ );
379
+
380
+ // buildSingleComponent regular path
381
+ const val = buildSingleComponent(
382
+ positionComponent,
383
+ [{ x: 42 }],
384
+ 0,
385
+ createEntityId(7000),
386
+ (t) => (t === positionComponent ? [{ x: 42 }] : []),
387
+ dfStore,
388
+ );
389
+ expect(val).toEqual({ x: 42 });
390
+ });
249
391
  });
@@ -168,4 +168,16 @@ describe("BitSet word boundary tests", () => {
168
168
  expect(bitset.has(24)).toBe(false);
169
169
  expect(bitset.has(26)).toBe(false);
170
170
  });
171
+
172
+ it("should expose length and handle edge has/set/clear on bounds", () => {
173
+ const bitset = new BitSet(10);
174
+ expect(bitset.length).toBe(10);
175
+ expect(bitset.has(-1)).toBe(false);
176
+ expect(bitset.has(10)).toBe(false);
177
+ bitset.set(-1); // no-op
178
+ bitset.set(10); // no-op
179
+ bitset.clear(-1);
180
+ bitset.clear(10);
181
+ expect(bitset.has(0)).toBe(false);
182
+ });
171
183
  });
@@ -22,6 +22,7 @@ import {
22
22
  isEntityId,
23
23
  isExclusiveComponent,
24
24
  isRelationId,
25
+ isSparseComponent,
25
26
  isWildcardRelationId,
26
27
  relation,
27
28
  } from "../../entity";
@@ -503,6 +504,38 @@ describe("Component Options", () => {
503
504
  expect(isDontFragmentComponent(combinedComp)).toBe(true);
504
505
  });
505
506
 
507
+ it("should support the new `sparse` option (preferred name) and treat it identically to the legacy `dontFragment`", () => {
508
+ const sparseComp = component({ sparse: true });
509
+ const normalComp = component();
510
+
511
+ // New primary predicates
512
+ expect(isSparseComponent(sparseComp)).toBe(true);
513
+ expect(isSparseComponent(normalComp)).toBe(false);
514
+
515
+ // Old aliases must still work (BC)
516
+ expect(isDontFragmentComponent(sparseComp)).toBe(true);
517
+ expect(isDontFragmentComponent(normalComp)).toBe(false);
518
+
519
+ const options = getComponentOptions(sparseComp);
520
+ expect(options.sparse).toBe(true);
521
+ // Full BC: old key is also populated
522
+ expect(options.dontFragment).toBe(true);
523
+ });
524
+
525
+ it("should treat `sparse` and `dontFragment` as equivalent (both spellings set the same flag)", () => {
526
+ const viaSparse = component({ sparse: true });
527
+ const viaLegacy = component({ dontFragment: true });
528
+ const viaBoth = component({ sparse: true, dontFragment: true });
529
+
530
+ for (const c of [viaSparse, viaLegacy, viaBoth]) {
531
+ expect(isSparseComponent(c)).toBe(true);
532
+ expect(isDontFragmentComponent(c)).toBe(true);
533
+ const opts = getComponentOptions(c);
534
+ expect(opts.sparse).toBe(true);
535
+ expect(opts.dontFragment).toBe(true);
536
+ }
537
+ });
538
+
506
539
  it("should store and retrieve component merge callback", () => {
507
540
  const merge = (prev: number[], next: number[]) => [...prev, ...next];
508
541
  const mailboxComp = component<number[]>({ merge });
@@ -6,12 +6,16 @@ import {
6
6
  createEntityId,
7
7
  decodeRelationId,
8
8
  ENTITY_ID_START,
9
+ getComponentIdFromRelationId,
9
10
  getDetailedIdType,
10
11
  getIdType,
12
+ getTargetIdFromRelationId,
11
13
  inspectEntityId,
12
14
  INVALID_COMPONENT_ID,
13
15
  isComponentId,
16
+ isComponentRelation,
14
17
  isEntityId,
18
+ isEntityRelation,
15
19
  isRelationId,
16
20
  isWildcardRelationId,
17
21
  relation,
@@ -257,4 +261,40 @@ describe("Entity ID System", () => {
257
261
  expect(decoded.type).toBe("entity");
258
262
  });
259
263
  });
264
+
265
+ describe("Error paths and additional relation utils", () => {
266
+ it("should throw on decodeRelationId for non-relation", () => {
267
+ expect(() => decodeRelationId(123 as any)).toThrow("ID is not a relation ID");
268
+ });
269
+
270
+ it("should throw on decodeRelationId for invalid component or target in relation", () => {
271
+ expect(() => decodeRelationId(-123456 as any)).toThrow();
272
+ });
273
+
274
+ it("should cover isEntityRelation and isComponentRelation", () => {
275
+ const entRel = relation(createComponentId(1), createEntityId(ENTITY_ID_START));
276
+ const compRel = relation(createComponentId(2), createComponentId(3));
277
+ const wild = relation(createComponentId(4), "*");
278
+ const plain = createEntityId(ENTITY_ID_START);
279
+
280
+ expect(isEntityRelation(entRel)).toBe(true);
281
+ expect(isEntityRelation(compRel)).toBe(false);
282
+ expect(isEntityRelation(wild)).toBe(false);
283
+ expect(isEntityRelation(plain)).toBe(false);
284
+
285
+ expect(isComponentRelation(compRel)).toBe(true);
286
+ expect(isComponentRelation(entRel)).toBe(false);
287
+ expect(isComponentRelation(wild)).toBe(false);
288
+ });
289
+
290
+ it("should cover getComponentIdFromRelationId and getTargetIdFromRelationId on valids and invalids", () => {
291
+ const r = relation(createComponentId(99), createEntityId(1024));
292
+ expect(getComponentIdFromRelationId(r)).toBe(createComponentId(99));
293
+ expect(getTargetIdFromRelationId(r)).toBe(createEntityId(1024));
294
+
295
+ expect(getComponentIdFromRelationId(123 as any)).toBeUndefined();
296
+ expect(getTargetIdFromRelationId(123 as any)).toBeUndefined();
297
+ expect(getComponentIdFromRelationId(-999999 as any)).toBeUndefined();
298
+ });
299
+ });
260
300
  });