@codehz/ecs 0.8.2 → 0.10.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 (57) hide show
  1. package/README.en.md +26 -3
  2. package/README.md +41 -4
  3. package/dist/builder.d.mts +348 -83
  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 +1922 -1400
  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/examples/spatial-grid.ts +1 -1
  14. package/package.json +1 -1
  15. package/skills/ecs/SKILL.md +4 -4
  16. package/src/__tests__/component/singleton.test.ts +116 -35
  17. package/src/__tests__/core/archetype.test.ts +155 -13
  18. package/src/__tests__/core/bitset.test.ts +12 -0
  19. package/src/__tests__/entity/entity.test.ts +33 -0
  20. package/src/__tests__/entity/id-system.test.ts +40 -0
  21. package/src/__tests__/perf/comprehensive.perf.test.ts +6 -9
  22. package/src/__tests__/perf/serialization.perf.test.ts +242 -0
  23. package/src/__tests__/perf/{dontfragment-wildcard.perf.test.ts → sparse-wildcard.perf.test.ts} +13 -16
  24. package/src/__tests__/query/caching.test.ts +62 -0
  25. package/src/__tests__/query/filter.test.ts +16 -22
  26. package/src/__tests__/query/perf.test.ts +3 -5
  27. package/src/__tests__/relations/hierarchy.test.ts +208 -0
  28. package/src/__tests__/relations/{dont-fragment → sparse}/basic.test.ts +64 -69
  29. package/src/__tests__/relations/{dont-fragment → sparse}/query-notification.test.ts +17 -9
  30. package/src/__tests__/serialization/bounds.test.ts +133 -1
  31. package/src/__tests__/world/commands.test.ts +337 -0
  32. package/src/__tests__/world/component-management.test.ts +6 -5
  33. package/src/__tests__/world/debug-stats.test.ts +206 -0
  34. package/src/__tests__/world/multi-component-hooks.test.ts +44 -0
  35. package/src/__tests__/world/serialize.test.ts +17 -0
  36. package/src/__tests__/world/wildcard-relation-hooks.test.ts +127 -0
  37. package/src/archetype/archetype.ts +96 -46
  38. package/src/archetype/helpers.ts +7 -29
  39. package/src/archetype/store.ts +35 -20
  40. package/src/commands/buffer.ts +5 -2
  41. package/src/commands/changeset.ts +0 -31
  42. package/src/component/registry.ts +64 -63
  43. package/src/entity/index.ts +6 -3
  44. package/src/index.ts +15 -0
  45. package/src/query/filter.ts +4 -10
  46. package/src/query/query.ts +12 -12
  47. package/src/storage/serialization.ts +29 -2
  48. package/src/types/index.ts +71 -0
  49. package/src/world/archetype-manager.ts +283 -0
  50. package/src/world/command-executor.ts +258 -0
  51. package/src/world/commands.ts +44 -56
  52. package/src/world/debug-stats.ts +147 -0
  53. package/src/world/hooks.ts +8 -0
  54. package/src/world/operations.ts +88 -0
  55. package/src/world/serialization.ts +32 -18
  56. package/src/world/singleton.ts +51 -0
  57. package/src/world/world.ts +429 -457
@@ -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
  );
@@ -10,46 +10,49 @@ type RelationEntry =
10
10
  | { type: "multi"; targets: Map<EntityId, { relationType: EntityId<any>; data: any }> };
11
11
 
12
12
  /**
13
- * Interface for storing dontFragment relation data.
13
+ * Interface for the sparse side store used by components declared with `sparse: true`
14
+ * (or the legacy `dontFragment: true` alias).
14
15
  *
15
- * Storage is now primarily keyed by relation ComponentId (the "kind" of relation)
16
- * rather than by entity. This provides O(1) or near-O(1) answers for the hot
17
- * wildcard-related paths (hasRelationWithComponentId, wildcard materialization
18
- * during iteration, hook matching, etc.).
16
+ * Relation data for these components lives here instead of in archetype columns,
17
+ * preventing fragmentation for high-cardinality or frequently-changing relations.
19
18
  *
20
- * A lightweight reverse index (entity -> Set of base ComponentIds) is maintained
21
- * to efficiently support the infrequent "get all dontFragment data for this entity"
22
- * operations (removeEntity, dump, getEntity, serialization).
23
- *
24
- * The interface no longer leaks internal Map structures. Callers work with
25
- * semantic operations only.
19
+ * Storage is primarily keyed by base relation ComponentId. This enables efficient
20
+ * per-component lookups required by wildcard queries (relation(Comp, "*")) and
21
+ * archetype filtering, while still supporting full-entity enumeration when needed.
26
22
  */
27
- export interface DontFragmentStore {
28
- // High-frequency operations (used by get/set/getOptional and structural changes)
23
+ export interface SparseStore {
24
+ // High-frequency per-(entity, relation) operations (get/set/has/remove, structural changes)
29
25
  getValue(entityId: EntityId, relationType: EntityId<any>): any | undefined;
30
26
  setValue(entityId: EntityId, relationType: EntityId<any>, data: any): void;
31
27
  deleteValue(entityId: EntityId, relationType: EntityId<any>): boolean;
32
28
 
33
- // Wildcard / filtering hot paths (X-class priority)
29
+ // Hot paths for wildcard queries and archetype filtering (per-component lookups)
34
30
  hasAnyForComponent(componentId: EntityId<any>): boolean;
35
31
  getRelationsForComponent(entityId: EntityId, componentId: EntityId<any>): [target: EntityId, data: any][];
36
32
 
37
- // Low-frequency "get everything for entity" paths (Y-class, acceptable cost)
33
+ // Entity-wide enumeration paths (used for snapshots, serialization, forEach, and rare presence checks)
38
34
  getAllForEntity(entityId: EntityId): Array<[relationType: EntityId<any>, data: any]>;
39
35
  deleteEntity(entityId: EntityId): void;
36
+
37
+ /**
38
+ * @internal Bulk helper for serialization of many entities.
39
+ * Default implementation simply loops getAllForEntity; subclasses / future
40
+ * implementations can provide a more efficient fused walk.
41
+ */
42
+ getAllForEntities(entityIds: readonly EntityId[]): Map<EntityId, Array<[EntityId<any>, any]>>;
40
43
  }
41
44
 
42
45
  /**
43
- * Production implementation of DontFragmentStore.
46
+ * Production implementation of SparseStore.
44
47
  *
45
48
  * Internal layout (optimized):
46
49
  * - byComponent: baseComponentId → (entityId → RelationEntry)
47
50
  * RelationEntry uses a single-value form for the common exclusive case (1 target),
48
- * avoiding Map allocation entirely for the vast majority of dontFragment usage.
51
+ * avoiding Map allocation for the vast majority of usage.
49
52
  * - entityIndex: entityId → Set<baseComponentId>
50
53
  * Lightweight reverse index.
51
54
  */
52
- export class DontFragmentStoreImpl implements DontFragmentStore {
55
+ export class SparseStoreImpl implements SparseStore {
53
56
  /**
54
57
  * Primary storage, keyed by the base relation component ID.
55
58
  */
@@ -60,7 +63,8 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
60
63
 
61
64
  /**
62
65
  * Reverse index: which base component kinds an entity participates in.
63
- * Used only by the infrequent getAllForEntity / deleteEntity paths.
66
+ * Only required to support getAllForEntity and deleteEntity efficiently.
67
+ * The primary storage (byComponent) is deliberately not optimized for these operations.
64
68
  */
65
69
  private entityIndex = new Map<EntityId, Set<EntityId<any>>>();
66
70
 
@@ -87,7 +91,7 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
87
91
  setValue(entityId: EntityId, relationType: EntityId<any>, data: any): void {
88
92
  const componentId = getComponentIdFromRelationId(relationType);
89
93
  if (componentId === undefined) {
90
- throw new Error("setValue called with a non-relation type on DontFragmentStore");
94
+ throw new Error("setValue called with a non-relation type on SparseStore");
91
95
  }
92
96
 
93
97
  let entities = this.byComponent.get(componentId);
@@ -237,4 +241,15 @@ export class DontFragmentStoreImpl implements DontFragmentStore {
237
241
 
238
242
  this.entityIndex.delete(entityId);
239
243
  }
244
+
245
+ getAllForEntities(entityIds: readonly EntityId[]): Map<EntityId, Array<[EntityId<any>, any]>> {
246
+ const result = new Map<EntityId, Array<[EntityId<any>, any]>>();
247
+ for (const eid of entityIds) {
248
+ const data = this.getAllForEntity(eid);
249
+ if (data.length > 0) {
250
+ result.set(eid, data);
251
+ }
252
+ }
253
+ return result;
254
+ }
240
255
  }
@@ -55,9 +55,10 @@ export class CommandBuffer {
55
55
  }
56
56
 
57
57
  /**
58
- * Execute all commands and clear the buffer
58
+ * Execute all commands and clear the buffer.
59
+ * Returns the number of iterations performed (for debug stats).
59
60
  */
60
- execute(): void {
61
+ execute(): number {
61
62
  let iterations = 0;
62
63
 
63
64
  while (this.commands.length > 0) {
@@ -92,6 +93,8 @@ export class CommandBuffer {
92
93
  }
93
94
  entityCommands.clear();
94
95
  }
96
+
97
+ return iterations;
95
98
  }
96
99
 
97
100
  /**
@@ -70,35 +70,4 @@ export class ComponentChangeset {
70
70
 
71
71
  return existingComponents;
72
72
  }
73
-
74
- /**
75
- * Get the final component types after applying the changeset
76
- * @param existingComponentTypes - The current component types on the entity
77
- * @returns The final component types or undefined if no changes
78
- */
79
- getFinalComponentTypes(existingComponentTypes: EntityId<any>[]): EntityId<any>[] | undefined {
80
- const finalComponentTypes = new Set<EntityId<any>>(existingComponentTypes);
81
- let changed = false;
82
-
83
- // Apply removals
84
- for (const componentType of this.removes) {
85
- if (!finalComponentTypes.has(componentType)) {
86
- this.removes.delete(componentType);
87
- continue; // Component not present, skip
88
- }
89
- changed = true;
90
- finalComponentTypes.delete(componentType);
91
- }
92
-
93
- // Apply additions
94
- for (const componentType of this.adds.keys()) {
95
- if (finalComponentTypes.has(componentType)) {
96
- continue; // Component already present, skip
97
- }
98
- changed = true;
99
- finalComponentTypes.add(componentType);
100
- }
101
-
102
- return changed ? Array.from(finalComponentTypes) : undefined;
103
- }
104
73
  }