@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
@@ -0,0 +1,283 @@
1
+ import { Archetype } from "../archetype/archetype";
2
+ import type { SparseStore } from "../archetype/store";
3
+ import { normalizeComponentTypes } from "../component/type-utils";
4
+ import type { EntityId } from "../entity";
5
+ import {
6
+ getComponentIdFromRelationId,
7
+ getDetailedIdType,
8
+ isSparseRelation,
9
+ isSparseWildcard,
10
+ isWildcardRelationId,
11
+ } from "../entity";
12
+ import { matchesFilter } from "../query/filter";
13
+ import type { QueryRegistry } from "../query/registry";
14
+ import type { LifecycleHookEntry } from "../types";
15
+ import { getOrCompute } from "../utils/utils";
16
+ import { filterRegularComponentTypes } from "./commands";
17
+
18
+ /**
19
+ * Context provided to ArchetypeManager for notifying dependent systems
20
+ * (query caching and lifecycle hooks) without creating tight coupling or cycles.
21
+ * Follows the same callback/context injection pattern used by CommandProcessorContext,
22
+ * HooksContext, and WorldDeserializationContext.
23
+ */
24
+ export interface ArchetypeManagerContext {
25
+ queryRegistry: QueryRegistry;
26
+ hooks: Set<LifecycleHookEntry>;
27
+ /** Called only when debug collectors are active (mirrors original guard in World) */
28
+ recordArchetypeCreated?: () => void;
29
+ recordArchetypeRemoved?: () => void;
30
+ }
31
+
32
+ /**
33
+ * Encapsulates all archetype storage, indexing, creation, removal, and reverse
34
+ * referencing logic that was previously scattered as private methods + maps
35
+ * directly on the World class.
36
+ *
37
+ * Responsibilities:
38
+ * - Archetype memoization by signature
39
+ * - Component-type reverse index (archetypesByComponent)
40
+ * - Entity → current Archetype map
41
+ * - Reverse "who references this entity via component/relation" index
42
+ * - Creation + removal with proper notifications to QueryRegistry + hook matching
43
+ * - Cleanup of empty archetypes after entity cascades
44
+ *
45
+ * This extraction shrinks World while keeping the same behavior and hot-path characteristics.
46
+ */
47
+ export class ArchetypeManager {
48
+ // Public for performance (hot paths access these maps frequently).
49
+ // This intentionally breaks encapsulation a bit for speed, as requested.
50
+ readonly archetypes: Archetype[] = [];
51
+ readonly archetypeBySignature = new Map<string, Archetype>();
52
+ readonly entityToArchetype = new Map<EntityId, Archetype>();
53
+ readonly archetypesByComponent = new Map<EntityId<any>, Set<Archetype>>();
54
+ readonly entityToReferencingArchetypes = new Map<EntityId, Set<Archetype>>();
55
+
56
+ private readonly sparseStore: SparseStore;
57
+ private readonly ctx: ArchetypeManagerContext;
58
+
59
+ constructor(ctx: ArchetypeManagerContext, sparseStore: SparseStore) {
60
+ this.ctx = ctx;
61
+ this.sparseStore = sparseStore;
62
+ }
63
+
64
+ // ------------------------------------------------------------------
65
+ // Public / package-internal surface used by World and its close collaborators
66
+ // (commands.ts applyChangeset, serialization deserialization context, etc.)
67
+ // ------------------------------------------------------------------
68
+
69
+ /** Primary entry point — memoized archetype creation/lookup. */
70
+ ensureArchetype(componentTypes: Iterable<EntityId<any>>): Archetype {
71
+ const regularTypes = filterRegularComponentTypes(componentTypes);
72
+ const sortedTypes = normalizeComponentTypes(regularTypes);
73
+ const hashKey = this.createArchetypeSignature(sortedTypes);
74
+
75
+ return getOrCompute(this.archetypeBySignature, hashKey, () => this.createNewArchetype(sortedTypes));
76
+ }
77
+
78
+ getArchetypeForEntity(entityId: EntityId): Archetype | undefined {
79
+ return this.entityToArchetype.get(entityId);
80
+ }
81
+
82
+ setEntityToArchetype(entityId: EntityId, archetype: Archetype): void {
83
+ this.entityToArchetype.set(entityId, archetype);
84
+ }
85
+
86
+ // Query helpers (moved from World for cohesion)
87
+ getMatchingArchetypes(componentTypes: EntityId<any>[]): Archetype[] {
88
+ if (componentTypes.length === 0) {
89
+ return [...this.archetypes];
90
+ }
91
+
92
+ const regularComponents: EntityId<any>[] = [];
93
+ const wildcardRelations: { componentId: EntityId<any>; relationId: EntityId<any> }[] = [];
94
+
95
+ for (const componentType of componentTypes) {
96
+ if (isWildcardRelationId(componentType)) {
97
+ const componentId = getComponentIdFromRelationId(componentType);
98
+ if (componentId !== undefined) {
99
+ wildcardRelations.push({ componentId, relationId: componentType });
100
+ }
101
+ } else {
102
+ regularComponents.push(componentType);
103
+ }
104
+ }
105
+
106
+ let matchingArchetypes = this.getArchetypesWithComponents(regularComponents);
107
+
108
+ for (const { componentId, relationId } of wildcardRelations) {
109
+ const markerSet = this.archetypesByComponent.get(relationId);
110
+ const archetypesWithMarker = markerSet ? Array.from(markerSet) : [];
111
+ matchingArchetypes =
112
+ matchingArchetypes.length === 0
113
+ ? archetypesWithMarker
114
+ : matchingArchetypes.filter((a) => markerSet?.has(a) || a.hasRelationWithComponentId(componentId));
115
+ }
116
+
117
+ return matchingArchetypes;
118
+ }
119
+
120
+ getArchetypesWithComponents(componentTypes: EntityId<any>[]): Archetype[] {
121
+ if (componentTypes.length === 0) return [...this.archetypes];
122
+ if (componentTypes.length === 1) {
123
+ const set = this.archetypesByComponent.get(componentTypes[0]!);
124
+ return set ? Array.from(set) : [];
125
+ }
126
+
127
+ // Sort by Set size, intersect starting from the smallest
128
+ const sets = componentTypes
129
+ .map((type) => this.archetypesByComponent.get(type))
130
+ .filter((s): s is Set<Archetype> => s !== undefined && s.size > 0)
131
+ .sort((a, b) => a.size - b.size);
132
+
133
+ if (sets.length === 0) return [];
134
+ if (sets.length < componentTypes.length) return []; // One component has no matching archetypes
135
+
136
+ const smallest = sets[0]!;
137
+
138
+ // 2-component fast path
139
+ if (sets.length === 2) {
140
+ const other = sets[1]!;
141
+ return Array.from(smallest).filter((a) => other.has(a));
142
+ }
143
+
144
+ // Multi-component intersection
145
+ let result = new Set(smallest);
146
+ for (let i = 1; i < sets.length; i++) {
147
+ for (const item of result) {
148
+ if (!sets[i]!.has(item)) result.delete(item);
149
+ }
150
+ if (result.size === 0) return [];
151
+ }
152
+ return Array.from(result);
153
+ }
154
+
155
+ // ------------------------------------------------------------------
156
+ // Internal creation / removal (core of the original cluster)
157
+ // ------------------------------------------------------------------
158
+
159
+ private createArchetypeSignature(componentTypes: EntityId<any>[]): string {
160
+ return componentTypes.join(",");
161
+ }
162
+
163
+ /** Deduplicated version of the original pair of methods. */
164
+ private updateReferencingIndex(componentType: EntityId<any>, archetype: Archetype, isAdd: boolean): void {
165
+ const detailedType = getDetailedIdType(componentType);
166
+ let entityId: EntityId | undefined;
167
+
168
+ if (detailedType.type === "entity") {
169
+ entityId = componentType as EntityId;
170
+ } else if (detailedType.type === "entity-relation") {
171
+ entityId = detailedType.targetId;
172
+ }
173
+
174
+ if (entityId !== undefined) {
175
+ let refs = this.entityToReferencingArchetypes.get(entityId);
176
+ if (isAdd) {
177
+ if (!refs) {
178
+ refs = new Set();
179
+ this.entityToReferencingArchetypes.set(entityId, refs);
180
+ }
181
+ refs.add(archetype);
182
+ } else {
183
+ if (refs) {
184
+ refs.delete(archetype);
185
+ if (refs.size === 0) {
186
+ this.entityToReferencingArchetypes.delete(entityId);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ private createNewArchetype(componentTypes: EntityId<any>[]): Archetype {
194
+ const newArchetype = new Archetype(componentTypes, this.sparseStore);
195
+ this.archetypes.push(newArchetype);
196
+
197
+ this.ctx.recordArchetypeCreated?.();
198
+
199
+ for (const componentType of componentTypes) {
200
+ let archetypes = this.archetypesByComponent.get(componentType);
201
+ if (!archetypes) {
202
+ archetypes = new Set();
203
+ this.archetypesByComponent.set(componentType, archetypes);
204
+ }
205
+ archetypes.add(newArchetype);
206
+
207
+ // Update reverse index (deduped)
208
+ this.updateReferencingIndex(componentType, newArchetype, true);
209
+ }
210
+
211
+ this.ctx.queryRegistry.onNewArchetype(newArchetype);
212
+ this.updateArchetypeHookMatches(newArchetype);
213
+
214
+ return newArchetype;
215
+ }
216
+
217
+ private updateArchetypeHookMatches(archetype: Archetype): void {
218
+ for (const entry of this.ctx.hooks) {
219
+ if (this.archetypeMatchesHook(archetype, entry)) {
220
+ archetype.matchingMultiHooks.add(entry);
221
+ if (entry.matchedArchetypes) {
222
+ entry.matchedArchetypes.add(archetype);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ public archetypeMatchesHook(archetype: Archetype, entry: LifecycleHookEntry): boolean {
229
+ return (
230
+ entry.requiredComponents.every((c: EntityId<any>) => {
231
+ if (isWildcardRelationId(c)) {
232
+ if (isSparseWildcard(c)) return true;
233
+ const componentId = getComponentIdFromRelationId(c);
234
+ return componentId !== undefined && archetype.hasRelationWithComponentId(componentId);
235
+ }
236
+ return archetype.componentTypeSet.has(c) || isSparseRelation(c);
237
+ }) && matchesFilter(archetype, entry.filter)
238
+ );
239
+ }
240
+
241
+ /** Called during cascade deletion cleanup. */
242
+ cleanupArchetypesReferencingEntity(entityId: EntityId): void {
243
+ const refs = this.entityToReferencingArchetypes.get(entityId);
244
+ if (!refs) return;
245
+
246
+ for (const archetype of refs) {
247
+ if (archetype.getEntities().length === 0) {
248
+ this.removeArchetype(archetype);
249
+ }
250
+ }
251
+ // removeArchetype already cleans up the reverse index entries for the archetypes themselves
252
+ this.entityToReferencingArchetypes.delete(entityId);
253
+ }
254
+
255
+ private removeArchetype(archetype: Archetype): void {
256
+ const index = this.archetypes.indexOf(archetype);
257
+ if (index !== -1) {
258
+ // swap-and-pop: O(1) removal
259
+ const last = this.archetypes[this.archetypes.length - 1]!;
260
+ this.archetypes[index] = last;
261
+ this.archetypes.pop();
262
+ }
263
+
264
+ this.ctx.recordArchetypeRemoved?.();
265
+
266
+ this.archetypeBySignature.delete(this.createArchetypeSignature(archetype.componentTypes));
267
+
268
+ for (const componentType of archetype.componentTypes) {
269
+ const archetypes = this.archetypesByComponent.get(componentType);
270
+ if (archetypes) {
271
+ archetypes.delete(archetype);
272
+ if (archetypes.size === 0) {
273
+ this.archetypesByComponent.delete(componentType);
274
+ }
275
+ }
276
+
277
+ // Clean up reverse index (deduped)
278
+ this.updateReferencingIndex(componentType, archetype, false);
279
+ }
280
+
281
+ this.ctx.queryRegistry.onArchetypeRemoved(archetype);
282
+ }
283
+ }
@@ -0,0 +1,258 @@
1
+ import type { Archetype } from "../archetype/archetype";
2
+ import type { SparseStore } from "../archetype/store";
3
+ import type { Command } from "../commands/buffer";
4
+ import { ComponentChangeset } from "../commands/changeset";
5
+ import type { ComponentEntityStore } from "../component/entity-store";
6
+ import {
7
+ getComponentIdFromRelationId,
8
+ getTargetIdFromRelationId,
9
+ isEntityRelation,
10
+ isExclusiveComponent,
11
+ type EntityId,
12
+ } from "../entity";
13
+ import type { LifecycleHookEntry } from "../types";
14
+ import {
15
+ applyChangeset,
16
+ maybeRemoveWildcardMarker,
17
+ processCommands,
18
+ removeMatchingRelations,
19
+ type CommandProcessorContext,
20
+ } from "./commands";
21
+ import type { triggerLifecycleHooks } from "./hooks";
22
+ import { type HooksContext } from "./hooks";
23
+ import { trackEntityReference, untrackEntityReference, type EntityReferencesMap } from "./references";
24
+
25
+ /**
26
+ * Dependencies provided by World to the CommandExecutor.
27
+ * Keeps the executor decoupled while allowing efficient hot-path access
28
+ * (maps passed by reference where hot code already expects them).
29
+ */
30
+ export interface CommandExecutorContext {
31
+ /** For component-entity (singleton) fast path in execute */
32
+ componentEntities: ComponentEntityStore;
33
+
34
+ /** Reverse reference index for cascades and entity-valued components */
35
+ entityReferences: EntityReferencesMap;
36
+
37
+ /** For the no-hooks fast path guard */
38
+ hooks: Set<LifecycleHookEntry>;
39
+
40
+ /** Direct map access (hot path in applyChangeset and various places) */
41
+ entityToArchetype: Map<EntityId, Archetype>;
42
+
43
+ /** Archetype creation/lookup (passed through CommandProcessorContext) */
44
+ ensureArchetype: (componentTypes: Iterable<EntityId<any>>) => Archetype;
45
+
46
+ /** Sparse store (needed for CommandProcessorContext) */
47
+ sparseStore: SparseStore;
48
+
49
+ /** Factories for the HooksContext (used by triggerLifecycleHooks) */
50
+ has: (entityId: EntityId, componentType: EntityId<any>) => boolean;
51
+ get: <T>(entityId: EntityId, componentType: EntityId<T>) => T;
52
+ getOptional: <T>(entityId: EntityId, componentType: EntityId<T>) => { value: T } | undefined;
53
+
54
+ /** Destroy fast-path delegation (BFS + cascade logic stays in World) */
55
+ destroyEntityImmediate: (entityId: EntityId) => void;
56
+
57
+ /** Debug migration counter (now routed through DebugStatsManager) */
58
+ incrementMigrations: () => void;
59
+
60
+ /** Hook triggering (the function from hooks.ts) */
61
+ triggerLifecycleHooks: typeof triggerLifecycleHooks;
62
+
63
+ /** Remove hook fast path for full entity deletion (used by destroy paths in World) */
64
+ triggerRemoveHooksForEntityDeletion: (
65
+ entityId: EntityId,
66
+ removedComponents: Map<EntityId<any>, any>,
67
+ oldArchetype: Archetype,
68
+ ) => void;
69
+ }
70
+
71
+ /**
72
+ * Encapsulates the command execution pipeline, reusable changesets,
73
+ * and related orchestration that was previously private methods + fields on World.
74
+ *
75
+ * Responsibilities:
76
+ * - executeEntityCommands (routing for singletons / destroy / structural changes)
77
+ * - applyEntityCommands (changeset processing + exclusive relations + apply + refs + hooks)
78
+ * - removeComponentImmediate (used by cascade deletion)
79
+ * - updateEntityReferences (keeps the reverse index in sync)
80
+ *
81
+ * This extraction significantly reduces World line count while preserving
82
+ * every fast-path branch and allocation-avoidance characteristic.
83
+ */
84
+ export class CommandExecutor {
85
+ private readonly _changeset = new ComponentChangeset();
86
+ private readonly _removeChangeset = new ComponentChangeset();
87
+
88
+ private readonly _commandCtx: CommandProcessorContext;
89
+ private readonly _hooksCtx: HooksContext;
90
+
91
+ constructor(private readonly ctx: CommandExecutorContext) {
92
+ this._commandCtx = {
93
+ sparseStore: ctx.sparseStore,
94
+ ensureArchetype: ctx.ensureArchetype,
95
+ };
96
+
97
+ this._hooksCtx = {
98
+ multiHooks: ctx.hooks,
99
+ has: ctx.has,
100
+ get: ctx.get,
101
+ getOptional: ctx.getOptional,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Entry point used by the CommandBuffer.
107
+ * Routes to singleton handling, destroy fast path, or structural apply.
108
+ */
109
+ executeEntityCommands(entityId: EntityId, commands: Command[]): void {
110
+ this._changeset.clear();
111
+
112
+ // 1. Route: component entities use flat-map storage
113
+ if (this.ctx.componentEntities.exists(entityId)) {
114
+ this.ctx.componentEntities.executeCommands(entityId, commands);
115
+ return;
116
+ }
117
+
118
+ // 2. Route: destroy uses fast path (BFS/cascade stays in World)
119
+ if (commands.some((cmd) => cmd.type === "destroy")) {
120
+ this.ctx.destroyEntityImmediate(entityId);
121
+ return;
122
+ }
123
+
124
+ // 3. Apply structural changes
125
+ this.applyEntityCommands(entityId, commands);
126
+ }
127
+
128
+ private applyEntityCommands(entityId: EntityId, commands: Command[]): void {
129
+ const currentArchetype = this.ctx.entityToArchetype.get(entityId);
130
+ if (!currentArchetype) return;
131
+
132
+ const changeset = this._changeset;
133
+ processCommands(entityId, currentArchetype, commands, changeset, (eid, arch, compId) => {
134
+ if (isExclusiveComponent(compId)) {
135
+ removeMatchingRelations(eid, arch, compId, changeset);
136
+ }
137
+ });
138
+
139
+ const hasStructuralChange = changeset.removes.size > 0 || changeset.adds.size > 0;
140
+
141
+ if (this.ctx.hooks.size === 0) {
142
+ // Fast path: no hooks, skip removedComponents map allocation and hook triggering
143
+ const newArchetype = applyChangeset(
144
+ this._commandCtx,
145
+ entityId,
146
+ currentArchetype,
147
+ changeset,
148
+ this.ctx.entityToArchetype,
149
+ null,
150
+ );
151
+ if (hasStructuralChange) {
152
+ this.updateEntityReferences(entityId, changeset);
153
+ }
154
+ if (newArchetype !== currentArchetype) {
155
+ this.ctx.incrementMigrations();
156
+ }
157
+ return;
158
+ }
159
+
160
+ const removedComponents = new Map<EntityId<any>, any>();
161
+ const newArchetype = applyChangeset(
162
+ this._commandCtx,
163
+ entityId,
164
+ currentArchetype,
165
+ changeset,
166
+ this.ctx.entityToArchetype,
167
+ removedComponents,
168
+ );
169
+
170
+ if (hasStructuralChange) {
171
+ this.updateEntityReferences(entityId, changeset);
172
+ }
173
+
174
+ if (newArchetype !== currentArchetype) {
175
+ this.ctx.incrementMigrations();
176
+ }
177
+
178
+ this.ctx.triggerLifecycleHooks(
179
+ this._hooksCtx,
180
+ entityId,
181
+ changeset.adds,
182
+ removedComponents,
183
+ currentArchetype,
184
+ newArchetype,
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Immediate (non-buffered) component removal used during cascade deletion.
190
+ * Called from destroy* paths (which remain in World).
191
+ */
192
+ removeComponentImmediate(entityId: EntityId, componentType: EntityId<any>, targetEntityId: EntityId): void {
193
+ const sourceArchetype = this.ctx.entityToArchetype.get(entityId);
194
+ if (!sourceArchetype) return;
195
+
196
+ const changeset = this._removeChangeset;
197
+ changeset.clear();
198
+ changeset.delete(componentType);
199
+ maybeRemoveWildcardMarker(
200
+ entityId,
201
+ sourceArchetype,
202
+ componentType,
203
+ getComponentIdFromRelationId(componentType),
204
+ changeset,
205
+ );
206
+
207
+ const removedComponent = sourceArchetype.get(entityId, componentType);
208
+ const newArchetype = applyChangeset(
209
+ this._commandCtx,
210
+ entityId,
211
+ sourceArchetype,
212
+ changeset,
213
+ this.ctx.entityToArchetype,
214
+ null,
215
+ );
216
+ untrackEntityReference(this.ctx.entityReferences, entityId, componentType, targetEntityId);
217
+
218
+ this.ctx.triggerLifecycleHooks(
219
+ this._hooksCtx,
220
+ entityId,
221
+ new Map(),
222
+ new Map([[componentType, removedComponent]]),
223
+ sourceArchetype,
224
+ newArchetype,
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Keeps the entity reference reverse index in sync after structural changes.
230
+ * Called from apply paths.
231
+ */
232
+ updateEntityReferences(entityId: EntityId, changeset: ComponentChangeset): void {
233
+ for (const componentType of changeset.removes) {
234
+ if (isEntityRelation(componentType)) {
235
+ const targetId = getTargetIdFromRelationId(componentType)!;
236
+ untrackEntityReference(this.ctx.entityReferences, entityId, componentType, targetId);
237
+ } else if (componentType >= 1024) {
238
+ untrackEntityReference(this.ctx.entityReferences, entityId, componentType, componentType);
239
+ }
240
+ }
241
+
242
+ for (const [componentType] of changeset.adds) {
243
+ if (isEntityRelation(componentType)) {
244
+ const targetId = getTargetIdFromRelationId(componentType)!;
245
+ trackEntityReference(this.ctx.entityReferences, entityId, componentType, targetId);
246
+ } else if (componentType >= 1024) {
247
+ trackEntityReference(this.ctx.entityReferences, entityId, componentType, componentType);
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Exposed for any future direct needs (currently not required outside the executor).
254
+ */
255
+ getHooksContext(): HooksContext {
256
+ return this._hooksCtx;
257
+ }
258
+ }