@codehz/ecs 0.8.1 → 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 +9 -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
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { component, type EntityId } from "../../entity";
3
+ import type { SyncDebugStats } from "../../types";
3
4
  import { World } from "../../world/world";
4
5
 
5
6
  describe("World - Multi-Component Hooks", () => {
@@ -11,6 +12,9 @@ describe("World - Multi-Component Hooks", () => {
11
12
  const setCalls: { entityId: EntityId; value: number }[] = [];
12
13
  const removeCalls: { entityId: EntityId; value: number }[] = [];
13
14
 
15
+ const collectedStats: SyncDebugStats[] = [];
16
+ using _collector = world.createDebugStatsCollector((stats) => collectedStats.push(stats));
17
+
14
18
  // First create an entity before registering the hook (for on_init test)
15
19
  const existingEntity = world.spawn().with(A, 100).build();
16
20
  world.sync();
@@ -64,6 +68,15 @@ describe("World - Multi-Component Hooks", () => {
64
68
  expect(removeCalls.length).toBe(2);
65
69
  expect(removeCalls[1]!.entityId).toBe(existingEntity);
66
70
  expect(removeCalls[1]!.value).toBe(100);
71
+
72
+ // Cross-verify with the new debug stats collector
73
+ const lastStats = collectedStats[collectedStats.length - 1];
74
+ expect(lastStats).toBeDefined();
75
+ expect(lastStats!.hooks.total).toBeGreaterThanOrEqual(1);
76
+ // Note: hooksExecuted counts individual invokeHook calls. The exact number
77
+ // can be lower than the sum of manual arrays depending on event paths.
78
+ // We only assert that at least some hook activity was recorded.
79
+ expect(lastStats!.activity.hooksExecuted).toBeGreaterThanOrEqual(1);
67
80
  });
68
81
 
69
82
  it("should throw error when hook has no required components (only optional)", () => {
@@ -499,4 +512,35 @@ describe("World - Multi-Component Hooks", () => {
499
512
  expect(world.has(entity1, A)).toBe(true);
500
513
  expect(world.has(entity2, A)).toBe(true);
501
514
  });
515
+
516
+ it("should support callback-style registration for multi-component hooks (set/remove)", () => {
517
+ const world = new World();
518
+ const A = component<number>();
519
+ const B = component<string>();
520
+
521
+ const calls: { event: string; entityId: EntityId; components: any[] }[] = [];
522
+
523
+ // Register callback-style hook *before* any entities to ensure runtime set/remove
524
+ // go through invokeHook's callback branch (init replay for existing bypasses invokeHook).
525
+ const unhook = world.hook([A, B], (event, entityId, a, b) => {
526
+ calls.push({ event, entityId, components: [a, b] });
527
+ });
528
+
529
+ // New entity after registration → "set" via normal trigger path + invokeHook(callback)
530
+ const e = world.spawn().with(A, 42).with(B, "hi").build();
531
+ world.sync();
532
+ expect(calls.some((c) => c.event === "set" && c.entityId === e)).toBe(true);
533
+
534
+ // Update also triggers set
535
+ world.set(e, A, 100);
536
+ world.sync();
537
+ expect(calls.some((c) => c.event === "set" && c.components[0] === 100)).toBe(true);
538
+
539
+ // Remove required component → "remove" via invokeHook(callback)
540
+ world.remove(e, A);
541
+ world.sync();
542
+ expect(calls.some((c) => c.event === "remove" && c.entityId === e)).toBe(true);
543
+
544
+ unhook();
545
+ });
502
546
  });
@@ -80,4 +80,21 @@ describe("World serialization", () => {
80
80
  const c = restored.new();
81
81
  expect(c).toBeGreaterThanOrEqual(b + 1);
82
82
  });
83
+
84
+ it("should serialize and deserialize component-relations", () => {
85
+ const world = new World();
86
+ const A = component<string>("A");
87
+ const B = component<number>("B");
88
+ const relAB = relation(A, B); // component-relation
89
+
90
+ const e = world.new();
91
+ world.set(e, relAB, "linked-via-comp");
92
+ world.sync();
93
+
94
+ const snapshot = world.serialize();
95
+ const restored = new World(snapshot);
96
+
97
+ expect(restored.has(e, relAB)).toBe(true);
98
+ expect(restored.get(e, relAB)).toBe("linked-via-comp");
99
+ });
83
100
  });
@@ -370,4 +370,131 @@ describe("Wildcard-Relation Hooks", () => {
370
370
  // It should still correctly report the actual removed relation
371
371
  expect(reportedRelations).toContainEqual([target, { value: "hello" }]);
372
372
  });
373
+
374
+ it("should correctly evaluate 'had before' for wildcard required component when removing a different required component", () => {
375
+ const world = new World();
376
+ const A = component<number>();
377
+ const RelData = component<{ value: string }>();
378
+ const target = world.new();
379
+ const wildcardRel = relation(RelData, "*");
380
+
381
+ const removeCalls: { entityId: EntityId; components: any }[] = [];
382
+
383
+ // Hook requires both A (regular) and wildcard relation
384
+ world.hook([A, wildcardRel], {
385
+ on_remove: (entityId, ...components) => {
386
+ removeCalls.push({ entityId, components });
387
+ },
388
+ });
389
+
390
+ const entity = world.spawn().with(A, 42).with(relation(RelData, target), { value: "hello" }).build();
391
+ world.sync();
392
+
393
+ // Remove the non-wildcard required component A.
394
+ // This exercises the path in entityHadAllComponentsBefore where for the wildcard
395
+ // required component, anyComponentMatches(removed, wildcard) is false (only A was removed),
396
+ // so we fall through to the isWildcard getOptional branch.
397
+ world.remove(entity, A);
398
+ world.sync();
399
+
400
+ expect(removeCalls.length).toBe(1);
401
+ expect(removeCalls[0]!.entityId).toBe(entity);
402
+ expect(removeCalls[0]!.components[0]).toBe(42);
403
+ expect(Array.isArray(removeCalls[0]!.components[1])).toBe(true);
404
+ });
405
+
406
+ it("should reconstruct optional wildcard relation data during on_remove of required component", () => {
407
+ const world = new World();
408
+ const A = component<number>();
409
+ const RelData = component<{ value: string }>();
410
+ const target = world.new();
411
+ const wildcardRel = relation(RelData, "*");
412
+
413
+ const removeCalls: { entityId: EntityId; components: any }[] = [];
414
+
415
+ // Required A + optional wildcard relation
416
+ world.hook([A, { optional: wildcardRel }], {
417
+ on_remove: (entityId, ...components) => {
418
+ removeCalls.push({ entityId, components });
419
+ },
420
+ });
421
+
422
+ const entity = world.spawn().with(A, 99).with(relation(RelData, target), { value: "secret" }).build();
423
+ world.sync();
424
+
425
+ // Removing A triggers on_remove for the hook (required lost).
426
+ // The collection for the optional wildcard must go through reconstructWildcardWithRemoved
427
+ // (the if isWildcardRelationId(optionalId) branch in collectMultiHookComponentsWithRemoved).
428
+ world.remove(entity, A);
429
+ world.sync();
430
+
431
+ expect(removeCalls.length).toBe(1);
432
+ expect(removeCalls[0]!.entityId).toBe(entity);
433
+ expect(removeCalls[0]!.components[0]).toBe(99);
434
+ // optional wildcard should have been reconstructed from the (now removed) data
435
+ const opt = removeCalls[0]!.components[1];
436
+ expect(opt).toBeDefined();
437
+ expect(Array.isArray(opt.value)).toBe(true);
438
+ expect(opt.value).toContainEqual([target, { value: "secret" }]);
439
+ });
440
+
441
+ it("should trigger on_remove via deletion fast-path for required wildcard relation hook", () => {
442
+ const world = new World();
443
+ const RelData = component<{ value: string }>();
444
+ const target = world.new();
445
+ const wildcardRel = relation(RelData, "*");
446
+
447
+ const removeCalls: { entityId: EntityId; relations: [EntityId, { value: string }][] }[] = [];
448
+
449
+ world.hook([wildcardRel], {
450
+ on_remove: (entityId, relations) => {
451
+ removeCalls.push({ entityId, relations });
452
+ },
453
+ });
454
+
455
+ const entity = world.spawn().with(relation(RelData, target), { value: "to-be-deleted" }).build();
456
+ world.sync();
457
+
458
+ // Directly delete the entity that owns the wildcard relation data.
459
+ // This exercises triggerRemoveHooksForEntityDeletion + collectComponentsFromRemoved
460
+ // + collectWildcardFromRemoved (required wildcard case).
461
+ world.delete(entity);
462
+ world.sync();
463
+
464
+ expect(removeCalls.length).toBe(1);
465
+ expect(removeCalls[0]!.entityId).toBe(entity);
466
+ expect(removeCalls[0]!.relations).toContainEqual([target, { value: "to-be-deleted" }]);
467
+ });
468
+
469
+ it("should trigger on_remove via deletion fast-path for optional wildcard in multi-hook", () => {
470
+ const world = new World();
471
+ const A = component<number>();
472
+ const RelData = component<{ value: string }>();
473
+ const target = world.new();
474
+ const wildcardRel = relation(RelData, "*");
475
+
476
+ const removeCalls: { entityId: EntityId; components: any }[] = [];
477
+
478
+ world.hook([A, { optional: wildcardRel }], {
479
+ on_remove: (entityId, ...components) => {
480
+ removeCalls.push({ entityId, components });
481
+ },
482
+ });
483
+
484
+ const entity = world.spawn().with(A, 123).with(relation(RelData, target), { value: "gone-soon" }).build();
485
+ world.sync();
486
+
487
+ // Delete the entity: fast path should use collectComponentsFromRemoved which handles
488
+ // optional wildcard via collectWildcardFromRemoved and returns { value: [...] } or undefined.
489
+ world.delete(entity);
490
+ world.sync();
491
+
492
+ expect(removeCalls.length).toBe(1);
493
+ expect(removeCalls[0]!.entityId).toBe(entity);
494
+ expect(removeCalls[0]!.components[0]).toBe(123);
495
+ const opt = removeCalls[0]!.components[1];
496
+ expect(opt).toBeDefined();
497
+ expect(Array.isArray(opt.value)).toBe(true);
498
+ expect(opt.value).toContainEqual([target, { value: "gone-soon" }]);
499
+ });
373
500
  });
@@ -4,13 +4,14 @@ import {
4
4
  getComponentIdFromRelationId,
5
5
  getDetailedIdType,
6
6
  getIdType,
7
- isDontFragmentComponent,
7
+ isSparseComponent,
8
8
  isWildcardRelationId,
9
9
  } from "../entity";
10
+ import type { SerializedComponent, SerializedEntity, SerializedEntityId } from "../storage/serialization";
10
11
  import { isOptionalEntityId, type ComponentTuple, type ComponentType, type LifecycleHookEntry } from "../types";
11
12
  import { getOrCompute } from "../utils/utils";
12
13
  import { buildCacheKey, buildSingleComponent, getWildcardRelationDataSource, isRelationType } from "./helpers";
13
- import type { DontFragmentStore } from "./store";
14
+ import type { SparseStore } from "./store";
14
15
 
15
16
  /**
16
17
  * Special value to represent missing component data
@@ -50,11 +51,10 @@ export class Archetype {
50
51
  private entityToIndex: Map<EntityId, number> = new Map();
51
52
 
52
53
  /**
53
- * DontFragmentStore (keyed primarily by relation ComponentId).
54
- * Uses optimized RelationEntry (single/multi) for the common exclusive case.
54
+ * SparseStore used for relations declared with `sparse: true`.
55
55
  * See store.ts for implementation details.
56
56
  */
57
- private dontFragmentRelations: DontFragmentStore;
57
+ private sparseRelations: SparseStore;
58
58
 
59
59
  /**
60
60
  * Multi-hooks that match this archetype
@@ -66,10 +66,10 @@ export class Archetype {
66
66
  */
67
67
  private componentDataSourcesCache: Map<string, (any[] | EntityId<any>[] | undefined)[]> = new Map();
68
68
 
69
- constructor(componentTypes: EntityId<any>[], dontFragmentRelations: DontFragmentStore) {
69
+ constructor(componentTypes: EntityId<any>[], sparseStore: SparseStore) {
70
70
  this.componentTypes = normalizeComponentTypes(componentTypes);
71
71
  this.componentTypeSet = new Set(this.componentTypes);
72
- this.dontFragmentRelations = dontFragmentRelations;
72
+ this.sparseRelations = sparseStore;
73
73
 
74
74
  for (const componentType of this.componentTypes) {
75
75
  this.componentData.set(componentType, []);
@@ -107,17 +107,17 @@ export class Archetype {
107
107
  this.getComponentData(componentType).push(!componentData.has(componentType) ? MISSING_COMPONENT : data);
108
108
  }
109
109
 
110
- // Add dontFragment relations separately
111
- this.addDontFragmentRelations(entityId, componentData);
110
+ // Add sparse-stored relations separately
111
+ this.addSparseRelations(entityId, componentData);
112
112
  }
113
113
 
114
- private addDontFragmentRelations(entityId: EntityId, componentData: Map<EntityId<any>, any>): void {
114
+ private addSparseRelations(entityId: EntityId, componentData: Map<EntityId<any>, any>): void {
115
115
  for (const [componentType, data] of componentData) {
116
116
  if (this.componentTypeSet.has(componentType)) continue;
117
117
 
118
118
  const detailedType = getDetailedIdType(componentType);
119
- if (isRelationType(detailedType) && isDontFragmentComponent(detailedType.componentId!)) {
120
- this.dontFragmentRelations.setValue(entityId, componentType, data);
119
+ if (isRelationType(detailedType) && isSparseComponent(detailedType.componentId!)) {
120
+ this.sparseRelations.setValue(entityId, componentType, data);
121
121
  }
122
122
  }
123
123
  }
@@ -134,9 +134,9 @@ export class Archetype {
134
134
  entityData.set(componentType, data === MISSING_COMPONENT ? undefined : data);
135
135
  }
136
136
 
137
- // Add dontFragment relations
138
- const dontFragmentTuples = this.dontFragmentRelations.getAllForEntity(entityId);
139
- for (const [componentType, data] of dontFragmentTuples) {
137
+ // Add sparse-stored relations
138
+ const sparseTuples = this.sparseRelations.getAllForEntity(entityId);
139
+ for (const [componentType, data] of sparseTuples) {
140
140
  entityData.set(componentType, data);
141
141
  }
142
142
 
@@ -144,13 +144,11 @@ export class Archetype {
144
144
  }
145
145
 
146
146
  /**
147
- * Returns all dontFragment relations for the given entity as an array of tuples.
148
- * This is a compatibility adapter during the store refactor.
149
- *
150
- * Prefer the new DontFragmentStore methods when possible.
147
+ * Returns all sparse-stored relations for the given entity.
148
+ * Internal helper used by command processing and tests.
151
149
  */
152
- getEntityDontFragmentRelations(entityId: EntityId): Map<EntityId<any>, any> | undefined {
153
- const tuples = this.dontFragmentRelations.getAllForEntity(entityId);
150
+ getEntitySparseRelations(entityId: EntityId): Map<EntityId<any>, any> | undefined {
151
+ const tuples = this.sparseRelations.getAllForEntity(entityId);
154
152
  if (tuples.length === 0) return undefined;
155
153
 
156
154
  const map = new Map<EntityId<any>, any>();
@@ -169,8 +167,8 @@ export class Archetype {
169
167
  components.set(componentType, data === MISSING_COMPONENT ? undefined : data);
170
168
  }
171
169
 
172
- const dontFragmentTuples = this.dontFragmentRelations.getAllForEntity(entity);
173
- for (const [componentType, data] of dontFragmentTuples) {
170
+ const sparseTuples = this.sparseRelations.getAllForEntity(entity);
171
+ for (const [componentType, data] of sparseTuples) {
174
172
  components.set(componentType, data);
175
173
  }
176
174
 
@@ -178,6 +176,61 @@ export class Archetype {
178
176
  });
179
177
  }
180
178
 
179
+ /**
180
+ * @internal Serialization fast-path.
181
+ *
182
+ * Appends SerializedEntity records directly from the archetype's column storage
183
+ * (componentData arrays) plus sparse relations, avoiding per-entity Map
184
+ * allocation and repeated Array.from(entries()).
185
+ *
186
+ * Component type IDs should be pre-encoded by the caller (once per archetype)
187
+ * and passed in `encodedComponentTypes` (same order and length as this.componentTypes).
188
+ *
189
+ * The provided `encode` function should be the cached variant for best performance
190
+ * on entity IDs and any sparse relation type IDs.
191
+ *
192
+ * `sparseByEntity` is an optional pre-fetched map from a bulk
193
+ * `SparseStore.getAllForEntities` call (further reduces per-entity calls).
194
+ */
195
+ appendSerializedEntities(
196
+ out: SerializedEntity[],
197
+ encode: (id: EntityId<any>) => SerializedEntityId,
198
+ encodedComponentTypes: SerializedEntityId[],
199
+ sparseByEntity?: Map<EntityId, Array<[EntityId<any>, any]>>,
200
+ ): void {
201
+ if (encodedComponentTypes.length !== this.componentTypes.length) {
202
+ throw new Error("encodedComponentTypes length must match archetype componentTypes");
203
+ }
204
+
205
+ for (let i = 0; i < this.entities.length; i++) {
206
+ const entity = this.entities[i]!;
207
+
208
+ const components: SerializedComponent[] = [];
209
+ // Regular (non-sparse) components from column arrays
210
+ for (let c = 0; c < this.componentTypes.length; c++) {
211
+ const data = this.getComponentData(this.componentTypes[c]!)[i];
212
+ components.push({
213
+ type: encodedComponentTypes[c]!,
214
+ value: data === MISSING_COMPONENT ? undefined : data,
215
+ });
216
+ }
217
+
218
+ // Append any sparse relations for this entity (usually small or zero)
219
+ const sparseTuples = sparseByEntity?.get(entity) ?? this.sparseRelations.getAllForEntity(entity);
220
+ for (const [componentType, data] of sparseTuples) {
221
+ components.push({
222
+ type: encode(componentType),
223
+ value: data,
224
+ });
225
+ }
226
+
227
+ out.push({
228
+ id: encode(entity),
229
+ components,
230
+ });
231
+ }
232
+ }
233
+
181
234
  removeEntity(entityId: EntityId): Map<EntityId<any>, any> | undefined {
182
235
  const index = this.entityToIndex.get(entityId);
183
236
  if (index === undefined) return undefined;
@@ -188,12 +241,12 @@ export class Archetype {
188
241
  removedData.set(componentType, this.getComponentData(componentType)[index]);
189
242
  }
190
243
 
191
- // Include dontFragment relations
192
- const dontFragmentTuples = this.dontFragmentRelations.getAllForEntity(entityId);
193
- for (const [componentType, data] of dontFragmentTuples) {
244
+ // Include sparse relations
245
+ const sparseTuples = this.sparseRelations.getAllForEntity(entityId);
246
+ for (const [componentType, data] of sparseTuples) {
194
247
  removedData.set(componentType, data);
195
248
  }
196
- this.dontFragmentRelations.deleteEntity(entityId);
249
+ this.sparseRelations.deleteEntity(entityId);
197
250
 
198
251
  this.entityToIndex.delete(entityId);
199
252
 
@@ -257,9 +310,9 @@ export class Archetype {
257
310
  }
258
311
  }
259
312
 
260
- // Check dontFragment relations (now uses the efficient per-component path)
313
+ // Check sparse relations (now uses the efficient per-component path)
261
314
  if (componentId !== undefined) {
262
- const matches = this.dontFragmentRelations.getRelationsForComponent(entityId, componentId);
315
+ const matches = this.sparseRelations.getRelationsForComponent(entityId, componentId);
263
316
  for (const m of matches) relations.push(m);
264
317
  }
265
318
 
@@ -275,14 +328,11 @@ export class Archetype {
275
328
  return data as T;
276
329
  }
277
330
 
278
- const value = this.dontFragmentRelations.getValue(entityId, componentType);
279
- if (
280
- value !== undefined ||
281
- this.dontFragmentRelations.getAllForEntity(entityId).some(([t]) => t === componentType)
282
- ) {
331
+ const value = this.sparseRelations.getValue(entityId, componentType);
332
+ if (value !== undefined || this.sparseRelations.getAllForEntity(entityId).some(([t]) => t === componentType)) {
283
333
  // Note: the extra check above handles the (rare) case where `undefined` is a legitimate stored value.
284
334
  // For the common case we just return whatever getValue gave us.
285
- return this.dontFragmentRelations.getValue(entityId, componentType);
335
+ return this.sparseRelations.getValue(entityId, componentType);
286
336
  }
287
337
 
288
338
  throw new Error(`Component type ${componentType} not found for entity ${entityId}`);
@@ -300,14 +350,14 @@ export class Archetype {
300
350
  return { value: data as T };
301
351
  }
302
352
 
303
- const value = this.dontFragmentRelations.getValue(entityId, componentType);
353
+ const value = this.sparseRelations.getValue(entityId, componentType);
304
354
  // We use getAllForEntity only as a presence check when the value itself might be undefined.
305
355
  if (value !== undefined) {
306
356
  return { value };
307
357
  }
308
- const all = this.dontFragmentRelations.getAllForEntity(entityId);
358
+ const all = this.sparseRelations.getAllForEntity(entityId);
309
359
  if (all.some(([t]) => t === componentType)) {
310
- return { value: this.dontFragmentRelations.getValue(entityId, componentType) };
360
+ return { value: this.sparseRelations.getValue(entityId, componentType) };
311
361
  }
312
362
  return undefined;
313
363
  }
@@ -324,8 +374,8 @@ export class Archetype {
324
374
  }
325
375
 
326
376
  const detailedType = getDetailedIdType(componentType);
327
- if (isRelationType(detailedType) && isDontFragmentComponent(detailedType.componentId!)) {
328
- this.dontFragmentRelations.setValue(entityId, componentType, data);
377
+ if (isRelationType(detailedType) && isSparseComponent(detailedType.componentId!)) {
378
+ this.sparseRelations.setValue(entityId, componentType, data);
329
379
  return;
330
380
  }
331
381
 
@@ -387,7 +437,7 @@ export class Archetype {
387
437
  entityIndex,
388
438
  entityId,
389
439
  (type) => this.getComponentData(type),
390
- this.dontFragmentRelations,
440
+ this.sparseRelations,
391
441
  ),
392
442
  ) as ComponentTuple<T>;
393
443
  }
@@ -443,9 +493,9 @@ export class Archetype {
443
493
  components.set(componentType, data === MISSING_COMPONENT ? undefined : data);
444
494
  }
445
495
 
446
- // Append dontFragment relations (Y-class path, acceptable cost)
447
- const dontFragmentTuples = this.dontFragmentRelations.getAllForEntity(entity);
448
- for (const [componentType, data] of dontFragmentTuples) {
496
+ // Append sparse relations (entity-wide enumeration; acceptable cost for forEach)
497
+ const sparseTuples = this.sparseRelations.getAllForEntity(entity);
498
+ for (const [componentType, data] of sparseTuples) {
449
499
  components.set(componentType, data);
450
500
  }
451
501
 
@@ -462,11 +512,11 @@ export class Archetype {
462
512
  }
463
513
  }
464
514
 
465
- // Check dontFragment relations only for entities that actually belong to *this* archetype.
515
+ // Check sparse relations only for entities that actually belong to *this* archetype.
466
516
  // We must not use the global hasAnyForComponent here, otherwise unrelated archetypes
467
517
  // can be incorrectly pulled into wildcard queries when any entity in the world has the relation.
468
518
  for (const entityId of this.entities) {
469
- const rels = this.dontFragmentRelations.getRelationsForComponent(entityId, componentId);
519
+ const rels = this.sparseRelations.getRelationsForComponent(entityId, componentId);
470
520
  if (rels.length > 0) {
471
521
  return true;
472
522
  }
@@ -8,7 +8,7 @@ import {
8
8
  } from "../entity";
9
9
  import { isOptionalEntityId, type ComponentType } from "../types";
10
10
  import { MISSING_COMPONENT } from "./archetype";
11
- import type { DontFragmentStore } from "./store";
11
+ import type { SparseStore } from "./store";
12
12
 
13
13
  type DetailedIdType = ReturnType<typeof getDetailedIdType>;
14
14
 
@@ -66,28 +66,6 @@ export function matchesRelationComponentId(componentType: EntityId<any>, compone
66
66
  return isRelationType(detailedType) && detailedType.componentId === componentId;
67
67
  }
68
68
 
69
- /**
70
- * Find all relations in dontFragment data that match a component ID.
71
- *
72
- * @deprecated Prefer calling `DontFragmentStore.getRelationsForComponent` directly.
73
- * This helper is kept temporarily for any remaining call sites during the refactor.
74
- */
75
- export function findMatchingDontFragmentRelations(
76
- dontFragmentData: Map<EntityId<any>, any> | undefined,
77
- componentId: EntityId<any>,
78
- relations: [EntityId<unknown>, any][] = [],
79
- ): [EntityId<unknown>, any][] {
80
- if (!dontFragmentData) return relations;
81
-
82
- for (const [relType, data] of dontFragmentData) {
83
- const relDetailed = getDetailedIdType(relType);
84
- if (isRelationType(relDetailed) && relDetailed.componentId === componentId) {
85
- relations.push([relDetailed.targetId, data]);
86
- }
87
- }
88
- return relations;
89
- }
90
-
91
69
  /**
92
70
  * Build cache key for component types
93
71
  */
@@ -109,13 +87,13 @@ export function getWildcardRelationDataSource(
109
87
 
110
88
  /**
111
89
  * Build wildcard relation value from matching relations.
112
- * Now receives the DontFragmentStore directly for efficient per-component lookups.
90
+ * Receives the SparseStore directly for efficient per-component lookups.
113
91
  */
114
92
  export function buildWildcardRelationValue(
115
93
  wildcardRelationType: WildcardRelationId<any>,
116
94
  matchingRelations: EntityId<any>[] | undefined,
117
95
  getDataAtIndex: (relType: EntityId<any>) => any,
118
- dontFragmentStore: DontFragmentStore,
96
+ sparseStore: SparseStore,
119
97
  entityId: EntityId,
120
98
  optional: boolean,
121
99
  ): any {
@@ -129,9 +107,9 @@ export function buildWildcardRelationValue(
129
107
  relations.push([targetId, data === MISSING_COMPONENT ? undefined : data]);
130
108
  }
131
109
 
132
- // Add dontFragment relations using the efficient store API (key win for X-class workload)
110
+ // Add sparse relations using the efficient store API (critical for wildcard query performance)
133
111
  if (targetComponentId !== undefined) {
134
- const dfMatches = dontFragmentStore.getRelationsForComponent(entityId, targetComponentId);
112
+ const dfMatches = sparseStore.getRelationsForComponent(entityId, targetComponentId);
135
113
  for (const m of dfMatches) {
136
114
  relations.push(m);
137
115
  }
@@ -173,7 +151,7 @@ export function buildSingleComponent(
173
151
  entityIndex: number,
174
152
  entityId: EntityId,
175
153
  getComponentData: (type: EntityId<any>) => any[],
176
- dontFragmentRelations: DontFragmentStore,
154
+ sparseRelations: SparseStore,
177
155
  ): any {
178
156
  const optional = isOptionalEntityId(compType);
179
157
  const actualType = optional ? compType.optional : compType;
@@ -183,7 +161,7 @@ export function buildSingleComponent(
183
161
  actualType as WildcardRelationId<any>,
184
162
  dataSource as EntityId<any>[] | undefined,
185
163
  (relType) => getComponentData(relType)[entityIndex],
186
- dontFragmentRelations,
164
+ sparseRelations,
187
165
  entityId,
188
166
  optional,
189
167
  );