@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
@@ -97,3 +97,74 @@ export interface LifecycleHookEntry {
97
97
  /** Archetypes that match this hook, used for precise cleanup on unsubscription */
98
98
  matchedArchetypes?: Set<any>;
99
99
  }
100
+
101
+ /**
102
+ * Statistics payload delivered to callbacks registered via `World.createDebugStatsCollector`.
103
+ *
104
+ * All structural counts are snapshots taken after the sync that triggered delivery.
105
+ * `activity` always reflects work performed during that specific sync.
106
+ *
107
+ * Timestamps are raw `performance.now()` values suitable for `performance.measure`.
108
+ */
109
+ export interface SyncDebugStats {
110
+ readonly timestamps: {
111
+ readonly syncStart: number;
112
+ readonly syncEnd: number;
113
+ readonly commandBufferStart: number;
114
+ readonly commandBufferEnd: number;
115
+ };
116
+
117
+ /** Number of iterations the internal command buffer loop performed during this sync. */
118
+ readonly commandIterations: number;
119
+
120
+ readonly entities: {
121
+ readonly total: number;
122
+ readonly freelistSize: number;
123
+ readonly nextId: number;
124
+ };
125
+
126
+ readonly archetypes: {
127
+ readonly total: number;
128
+ readonly empty: number;
129
+ };
130
+
131
+ readonly queries: {
132
+ readonly cached: number;
133
+ readonly registered: number;
134
+ };
135
+
136
+ readonly hooks: {
137
+ readonly total: number;
138
+ };
139
+
140
+ /** Sizes of stable internal reverse indices (conservative set). */
141
+ readonly indices: {
142
+ readonly entityReferences: number;
143
+ readonly entityToReferencingArchetypes: number;
144
+ readonly archetypesByComponent: number;
145
+ };
146
+
147
+ /**
148
+ * Activity that occurred as a direct result of this sync.
149
+ * All fields are always present (never optional).
150
+ */
151
+ readonly activity: {
152
+ /** Number of entities that performed an archetype migration (hasArchetypeStructuralChange was true). */
153
+ readonly migrations: number;
154
+ /** Total number of individual hook callback invocations (invokeHook calls). */
155
+ readonly hooksExecuted: number;
156
+ /** Number of new archetypes created during this sync. */
157
+ readonly archetypesCreated: number;
158
+ /** Number of archetypes removed during this sync. */
159
+ readonly archetypesRemoved: number;
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Handle returned by `World.createDebugStatsCollector`.
165
+ * The object itself carries no data — its only responsibility is lifetime management.
166
+ * Use with `using` or call `[Symbol.dispose]()` when you no longer need collection.
167
+ */
168
+ export interface DebugStatsCollector {
169
+ [Symbol.dispose](): void;
170
+ }
@@ -1,14 +1,14 @@
1
1
  import type { Archetype } from "../archetype/archetype";
2
- import type { DontFragmentStore } from "../archetype/store";
2
+ import type { SparseStore } from "../archetype/store";
3
3
  import type { Command } from "../commands/buffer";
4
4
  import type { ComponentChangeset } from "../commands/changeset";
5
5
  import { normalizeComponentTypes } from "../component/type-utils";
6
6
  import {
7
7
  getComponentIdFromRelationId,
8
8
  getComponentMerge,
9
- isDontFragmentComponent,
10
- isDontFragmentRelation,
11
- isDontFragmentWildcard,
9
+ isSparseComponent,
10
+ isSparseRelation,
11
+ isSparseWildcard,
12
12
  isWildcardRelationId,
13
13
  relation,
14
14
  type ComponentId,
@@ -16,7 +16,7 @@ import {
16
16
  } from "../entity";
17
17
 
18
18
  export interface CommandProcessorContext {
19
- dontFragmentStore: DontFragmentStore;
19
+ sparseStore: SparseStore;
20
20
  ensureArchetype: (componentTypes: Iterable<EntityId<any>>) => Archetype;
21
21
  }
22
22
 
@@ -59,8 +59,8 @@ function processSetCommand(
59
59
  // Handle exclusive relations by removing existing relations with the same base component
60
60
  handleExclusiveRelation(entityId, currentArchetype, componentId);
61
61
 
62
- // For dontFragment relations, ensure wildcard marker is in archetype signature
63
- if (isDontFragmentComponent(componentId)) {
62
+ // For sparse relations, ensure wildcard marker is in archetype signature
63
+ if (isSparseComponent(componentId)) {
64
64
  const wildcardMarker = relation(componentId, "*");
65
65
  // Add wildcard marker to changeset if not already in archetype
66
66
  if (!currentArchetype.componentTypeSet.has(wildcardMarker)) {
@@ -111,10 +111,10 @@ export function removeMatchingRelations(
111
111
  }
112
112
  }
113
113
 
114
- // Check dontFragment relations stored on entity
115
- const dontFragmentData = archetype.getEntityDontFragmentRelations(entityId);
116
- if (dontFragmentData) {
117
- for (const componentType of dontFragmentData.keys()) {
114
+ // Check sparse relations stored on entity
115
+ const sparseData = archetype.getEntitySparseRelations(entityId);
116
+ if (sparseData) {
117
+ for (const componentType of sparseData.keys()) {
118
118
  if (getComponentIdFromRelationId(componentType) === baseComponentId) {
119
119
  changeset.delete(componentType);
120
120
  }
@@ -130,8 +130,8 @@ function removeWildcardRelations(
130
130
  ): void {
131
131
  removeMatchingRelations(entityId, currentArchetype, baseComponentId, changeset);
132
132
 
133
- // If removing dontFragment relations, also remove the wildcard marker
134
- if (isDontFragmentComponent(baseComponentId)) {
133
+ // If removing sparse relations, also remove the wildcard marker
134
+ if (isSparseComponent(baseComponentId)) {
135
135
  changeset.delete(relation(baseComponentId, "*"));
136
136
  }
137
137
  }
@@ -143,7 +143,7 @@ export function maybeRemoveWildcardMarker(
143
143
  componentId: ComponentId<any> | undefined,
144
144
  changeset: ComponentChangeset,
145
145
  ): void {
146
- if (componentId === undefined || !isDontFragmentComponent(componentId)) {
146
+ if (componentId === undefined || !isSparseComponent(componentId)) {
147
147
  return;
148
148
  }
149
149
 
@@ -160,9 +160,9 @@ export function maybeRemoveWildcardMarker(
160
160
  }
161
161
  }
162
162
 
163
- const dontFragmentData = archetype.getEntityDontFragmentRelations(entityId);
164
- if (dontFragmentData) {
165
- for (const otherComponentType of dontFragmentData.keys()) {
163
+ const sparseData = archetype.getEntitySparseRelations(entityId);
164
+ if (sparseData) {
165
+ for (const otherComponentType of sparseData.keys()) {
166
166
  if (otherComponentType === removedComponentType) continue;
167
167
  if (changeset.removes.has(otherComponentType)) continue;
168
168
 
@@ -173,7 +173,7 @@ export function maybeRemoveWildcardMarker(
173
173
  }
174
174
 
175
175
  // Also check if this changeset itself is adding another relation of the same kind
176
- // (common in exclusive dontFragment flips: remove old target + add new target in one batch)
176
+ // (common in exclusive sparse flips: remove old target + add new target in one batch)
177
177
  for (const addedType of changeset.adds.keys()) {
178
178
  if (addedType === removedComponentType) continue;
179
179
  if (getComponentIdFromRelationId(addedType) === componentId) {
@@ -189,7 +189,7 @@ function hasEntityComponent(archetype: Archetype, entityId: EntityId, componentT
189
189
  return true;
190
190
  }
191
191
 
192
- return archetype.getEntityDontFragmentRelations(entityId)?.has(componentType) ?? false;
192
+ return archetype.getEntitySparseRelations(entityId)?.has(componentType) ?? false;
193
193
  }
194
194
 
195
195
  function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetype, entityId: EntityId): void {
@@ -210,13 +210,13 @@ function pruneMissingRemovals(changeset: ComponentChangeset, archetype: Archetyp
210
210
 
211
211
  function hasArchetypeStructuralChange(changeset: ComponentChangeset, currentArchetype: Archetype): boolean {
212
212
  for (const componentType of changeset.removes) {
213
- if (!isDontFragmentRelation(componentType) && currentArchetype.componentTypeSet.has(componentType)) {
213
+ if (!isSparseRelation(componentType) && currentArchetype.componentTypeSet.has(componentType)) {
214
214
  return true;
215
215
  }
216
216
  }
217
217
 
218
218
  for (const componentType of changeset.adds.keys()) {
219
- if (!isDontFragmentRelation(componentType) && !currentArchetype.componentTypeSet.has(componentType)) {
219
+ if (!isSparseRelation(componentType) && !currentArchetype.componentTypeSet.has(componentType)) {
220
220
  return true;
221
221
  }
222
222
  }
@@ -228,13 +228,13 @@ function buildFinalRegularComponentTypes(currentArchetype: Archetype, changeset:
228
228
  const finalRegularTypes = new Set(currentArchetype.componentTypes);
229
229
 
230
230
  for (const componentType of changeset.removes) {
231
- if (!isDontFragmentRelation(componentType)) {
231
+ if (!isSparseRelation(componentType)) {
232
232
  finalRegularTypes.delete(componentType);
233
233
  }
234
234
  }
235
235
 
236
236
  for (const [componentType] of changeset.adds) {
237
- if (!isDontFragmentRelation(componentType)) {
237
+ if (!isSparseRelation(componentType)) {
238
238
  finalRegularTypes.add(componentType);
239
239
  }
240
240
  }
@@ -269,16 +269,16 @@ export function applyChangeset(
269
269
  return newArchetype;
270
270
  }
271
271
 
272
- // No archetype move needed: only component payload updates and/or dontFragment relation updates.
272
+ // No archetype move needed: only component payload updates and/or sparse relation updates.
273
273
  if (removedComponents !== null) {
274
- applyDontFragmentChanges(ctx.dontFragmentStore, entityId, changeset, removedComponents);
274
+ applySparseChanges(ctx.sparseStore, entityId, changeset, removedComponents);
275
275
  } else {
276
- applyDontFragmentChangesNoHooks(ctx.dontFragmentStore, entityId, changeset);
276
+ applySparseChangesNoHooks(ctx.sparseStore, entityId, changeset);
277
277
  }
278
278
 
279
279
  // Direct update for regular components in archetype
280
280
  for (const [componentType, component] of changeset.adds) {
281
- if (isDontFragmentRelation(componentType)) {
281
+ if (isSparseRelation(componentType)) {
282
282
  continue;
283
283
  }
284
284
  currentArchetype.set(entityId, componentType, component);
@@ -287,52 +287,40 @@ export function applyChangeset(
287
287
  return currentArchetype;
288
288
  }
289
289
 
290
- /**
291
- * No-hooks variant of applyDontFragmentChanges that skips tracking removed component data.
292
- *
293
- * Rewritten for the new DontFragmentStore interface (ComponentId-primary storage).
294
- */
295
- function applyDontFragmentChanges(
296
- dontFragmentRelations: DontFragmentStore,
290
+ function applySparseChanges(
291
+ sparseStore: SparseStore,
297
292
  entityId: EntityId,
298
293
  changeset: ComponentChangeset,
299
294
  removedComponents: Map<EntityId<any>, any>,
300
295
  ): void {
301
296
  for (const componentType of changeset.removes) {
302
- if (isDontFragmentRelation(componentType)) {
303
- const removedValue = dontFragmentRelations.getValue(entityId, componentType);
297
+ if (isSparseRelation(componentType)) {
298
+ const removedValue = sparseStore.getValue(entityId, componentType);
304
299
  // Record for hooks if we are actually removing something
305
- if (
306
- removedValue !== undefined ||
307
- dontFragmentRelations.getAllForEntity(entityId).some(([t]) => t === componentType)
308
- ) {
300
+ if (removedValue !== undefined || sparseStore.getAllForEntity(entityId).some(([t]) => t === componentType)) {
309
301
  removedComponents.set(componentType, removedValue);
310
302
  }
311
- dontFragmentRelations.deleteValue(entityId, componentType);
303
+ sparseStore.deleteValue(entityId, componentType);
312
304
  }
313
305
  }
314
306
 
315
307
  for (const [componentType, component] of changeset.adds) {
316
- if (isDontFragmentRelation(componentType)) {
317
- dontFragmentRelations.setValue(entityId, componentType, component);
308
+ if (isSparseRelation(componentType)) {
309
+ sparseStore.setValue(entityId, componentType, component);
318
310
  }
319
311
  }
320
312
  }
321
313
 
322
- function applyDontFragmentChangesNoHooks(
323
- dontFragmentRelations: DontFragmentStore,
324
- entityId: EntityId,
325
- changeset: ComponentChangeset,
326
- ): void {
314
+ function applySparseChangesNoHooks(sparseStore: SparseStore, entityId: EntityId, changeset: ComponentChangeset): void {
327
315
  for (const componentType of changeset.removes) {
328
- if (isDontFragmentRelation(componentType)) {
329
- dontFragmentRelations.deleteValue(entityId, componentType);
316
+ if (isSparseRelation(componentType)) {
317
+ sparseStore.deleteValue(entityId, componentType);
330
318
  }
331
319
  }
332
320
 
333
321
  for (const [componentType, component] of changeset.adds) {
334
- if (isDontFragmentRelation(componentType)) {
335
- dontFragmentRelations.setValue(entityId, componentType, component);
322
+ if (isSparseRelation(componentType)) {
323
+ sparseStore.setValue(entityId, componentType, component);
336
324
  }
337
325
  }
338
326
  }
@@ -341,14 +329,14 @@ export function filterRegularComponentTypes(componentTypes: Iterable<EntityId<an
341
329
  const regularTypes: EntityId<any>[] = [];
342
330
 
343
331
  for (const componentType of componentTypes) {
344
- // Keep wildcard markers for dontFragment components (they mark the archetype)
345
- if (isDontFragmentWildcard(componentType)) {
332
+ // Keep wildcard markers for sparse components (they mark the archetype)
333
+ if (isSparseWildcard(componentType)) {
346
334
  regularTypes.push(componentType);
347
335
  continue;
348
336
  }
349
337
 
350
- // Skip specific dontFragment relations from archetype signature
351
- if (isDontFragmentRelation(componentType)) {
338
+ // Skip specific sparse relations from archetype signature
339
+ if (isSparseRelation(componentType)) {
352
340
  continue;
353
341
  }
354
342
 
@@ -7,6 +7,12 @@ import {
7
7
  } from "../entity";
8
8
  import { isOptionalEntityId, type ComponentType, type LifecycleHookEntry } from "../types";
9
9
 
10
+ /**
11
+ * Debug-only counter incremented on every invokeHook call when armed.
12
+ * World reads and resets this during armed syncs.
13
+ */
14
+ export const debugHookExecutionCounter = { value: 0 };
15
+
10
16
  /**
11
17
  * Unified hook invocation: prefers entry.callback (callback style) over hook.on_* (object style).
12
18
  */
@@ -16,6 +22,8 @@ function invokeHook(
16
22
  entityId: EntityId,
17
23
  components: any[],
18
24
  ): void {
25
+ debugHookExecutionCounter.value++;
26
+
19
27
  if (entry.callback) {
20
28
  entry.callback(event as any, entityId, ...components);
21
29
  return;
@@ -3,7 +3,7 @@ import type { ComponentEntityStore } from "../component/entity-store";
3
3
  import { getDetailedIdType, type EntityId, type EntityIdManager } from "../entity";
4
4
  import {
5
5
  decodeSerializedId,
6
- encodeEntityId,
6
+ encodeEntityIdCached,
7
7
  type SerializedComponent,
8
8
  type SerializedEntity,
9
9
  type SerializedWorld,
@@ -18,29 +18,25 @@ export function serializeWorld(
18
18
  componentEntities: ComponentEntityStore,
19
19
  entityIdManager: EntityIdManager,
20
20
  ): SerializedWorld {
21
+ // ID cache turns repeated encode work (especially component type IDs) into O(#unique IDs)
22
+ const idCache = new Map<any, any>();
23
+
21
24
  const entities: SerializedEntity[] = [];
22
25
 
23
26
  for (const archetype of archetypes) {
24
- const dumpedEntities = archetype.dump();
25
- for (const { entity, components } of dumpedEntities) {
26
- entities.push({
27
- id: encodeEntityId(entity),
28
- components: Array.from(components.entries()).map(([rawType, value]) => ({
29
- type: encodeEntityId(rawType),
30
- value: value === MISSING_COMPONENT ? undefined : value,
31
- })),
32
- });
33
- }
27
+ // Pre-encode this archetype's component type IDs exactly once (big win when many entities share the archetype)
28
+ const encodedComponentTypes = archetype.componentTypes.map((t) => encodeEntityIdCached(t, idCache));
29
+
30
+ // The append method will use the bulk helper internally when a pre-fetched map is supplied.
31
+ // For now we rely on the per-entity fallback inside the archetype (already much cheaper than old dump path).
32
+ archetype.appendSerializedEntities(entities, (id) => encodeEntityIdCached(id, idCache), encodedComponentTypes);
34
33
  }
35
34
 
36
35
  const componentEntitiesArr: SerializedEntity[] = [];
37
36
  for (const [entityId, components] of componentEntities.entries()) {
38
37
  componentEntitiesArr.push({
39
- id: encodeEntityId(entityId),
40
- components: Array.from(components.entries()).map(([rawType, value]) => ({
41
- type: encodeEntityId(rawType),
42
- value: value === MISSING_COMPONENT ? undefined : value,
43
- })),
38
+ id: encodeEntityIdCached(entityId, idCache),
39
+ components: serializeComponentsFromMap(components, idCache),
44
40
  });
45
41
  }
46
42
 
@@ -52,6 +48,21 @@ export function serializeWorld(
52
48
  };
53
49
  }
54
50
 
51
+ /** Small helper to avoid duplicating the "Map → SerializedComponent[] with cache" pattern. */
52
+ function serializeComponentsFromMap(
53
+ components: Map<EntityId<any>, any>,
54
+ idCache: Map<any, any>,
55
+ ): SerializedComponent[] {
56
+ const result: SerializedComponent[] = [];
57
+ for (const [rawType, value] of components) {
58
+ result.push({
59
+ type: encodeEntityIdCached(rawType, idCache),
60
+ value: value === MISSING_COMPONENT ? undefined : value,
61
+ });
62
+ }
63
+ return result;
64
+ }
65
+
55
66
  /**
56
67
  * Context needed by `deserializeWorld` to populate world-internal state.
57
68
  * Defined as an interface to avoid a circular import between world.ts and this module.
@@ -96,14 +107,17 @@ export function deserializeWorld(ctx: WorldDeserializationContext, snapshot: Ser
96
107
  const componentsArray: SerializedComponent[] = entry.components || [];
97
108
 
98
109
  const componentMap = new Map<EntityId<any>, any>();
99
- const componentTypes: EntityId<any>[] = [];
100
110
 
101
111
  for (const componentEntry of componentsArray) {
102
112
  const componentType = decodeSerializedId(componentEntry.type);
103
113
  componentMap.set(componentType, componentEntry.value);
104
- componentTypes.push(componentType);
105
114
  }
106
115
 
116
+ // Build the list of component types from the map we just populated (no redundant push loop)
117
+ const componentTypes = Array.from(componentMap.keys());
118
+
119
+ // ensureArchetype is internally memoized (getOrCompute on signature), so repeated calls
120
+ // for the same component set are cheap after the first archetype is created.
107
121
  const archetype = ctx.ensureArchetype(componentTypes);
108
122
  archetype.addEntity(entityId, componentMap);
109
123
  ctx.setEntityToArchetype(entityId, archetype);