@codehz/ecs 0.9.0 → 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.
@@ -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
+ }
@@ -0,0 +1,147 @@
1
+ import type { DebugStatsCollector, SyncDebugStats } from "../types";
2
+ import { debugHookExecutionCounter } from "./hooks";
3
+
4
+ /**
5
+ * Manages debug stats collectors and transient activity counters for World#sync().
6
+ *
7
+ * Extracted from World to shrink the main class while keeping the entire debug/observability
8
+ * path isolated, zero-cost when no collectors are active, and easy to test/maintain.
9
+ *
10
+ * Follows the same context/callback injection style as ArchetypeManager, CommandProcessorContext,
11
+ * and HooksContext to avoid tight coupling.
12
+ *
13
+ * All collectors receive the *exact same* stats object for a given sync (as before).
14
+ * Exceptions in user callbacks are swallowed (as before).
15
+ */
16
+ export class DebugStatsManager {
17
+ private readonly collectors = new Set<(stats: SyncDebugStats) => void>();
18
+
19
+ // Transient activity counters for the current armed sync (reset each time collectors are present)
20
+ private migrations = 0;
21
+ private archetypesCreated = 0;
22
+ private archetypesRemoved = 0;
23
+
24
+ /** Fast check used to arm timing + reset + counting in hot paths. */
25
+ hasActiveCollectors(): boolean {
26
+ return this.collectors.size > 0;
27
+ }
28
+
29
+ /**
30
+ * Registers a collector. Returns a disposable handle (supports `using`).
31
+ * Collection stops when the handle is disposed.
32
+ */
33
+ createCollector(callback: (stats: SyncDebugStats) => void): DebugStatsCollector {
34
+ this.collectors.add(callback);
35
+
36
+ return {
37
+ [Symbol.dispose]: () => {
38
+ this.collectors.delete(callback);
39
+ },
40
+ };
41
+ }
42
+
43
+ // ------------------------------------------------------------------
44
+ // Recording hooks (called from ArchetypeManager ctx and command apply paths)
45
+ // These are cheap no-ops when no collectors are active.
46
+ // ------------------------------------------------------------------
47
+
48
+ recordArchetypeCreated(): void {
49
+ if (this.hasActiveCollectors()) {
50
+ this.archetypesCreated++;
51
+ }
52
+ }
53
+
54
+ recordArchetypeRemoved(): void {
55
+ if (this.hasActiveCollectors()) {
56
+ this.archetypesRemoved++;
57
+ }
58
+ }
59
+
60
+ incrementMigrations(): void {
61
+ if (this.hasActiveCollectors()) {
62
+ this.migrations++;
63
+ }
64
+ }
65
+
66
+ /** Reset all activity counters + the shared hook execution counter. Called at start of an armed sync. */
67
+ resetActivity(): void {
68
+ this.migrations = 0;
69
+ this.archetypesCreated = 0;
70
+ this.archetypesRemoved = 0;
71
+ debugHookExecutionCounter.value = 0;
72
+ }
73
+
74
+ /**
75
+ * Build and deliver a SyncDebugStats payload to every active collector.
76
+ * World supplies the pre-computed snapshot numbers (keeps debug manager decoupled from
77
+ * internal World maps/registries while preserving exact original stats shape and values).
78
+ */
79
+ deliver(
80
+ timings: {
81
+ syncStart: number;
82
+ syncEnd: number;
83
+ commandBufferStart: number;
84
+ commandBufferEnd: number;
85
+ commandIterations: number;
86
+ },
87
+ data: {
88
+ entityCount: number;
89
+ freelistSize: number;
90
+ nextId: number;
91
+ archetypeCount: number;
92
+ emptyArchetypes: number;
93
+ archetypesByComponentSize: number;
94
+ cachedQueryCount: number;
95
+ registeredQueryCount: number;
96
+ hookCount: number;
97
+ entityReferencesSize: number;
98
+ entityToReferencingArchetypesSize: number;
99
+ },
100
+ ): void {
101
+ const stats: SyncDebugStats = {
102
+ timestamps: {
103
+ syncStart: timings.syncStart,
104
+ syncEnd: timings.syncEnd,
105
+ commandBufferStart: timings.commandBufferStart,
106
+ commandBufferEnd: timings.commandBufferEnd,
107
+ },
108
+ commandIterations: timings.commandIterations,
109
+
110
+ entities: {
111
+ total: data.entityCount,
112
+ freelistSize: data.freelistSize,
113
+ nextId: data.nextId,
114
+ },
115
+ archetypes: {
116
+ total: data.archetypeCount,
117
+ empty: data.emptyArchetypes,
118
+ },
119
+ queries: {
120
+ cached: data.cachedQueryCount,
121
+ registered: data.registeredQueryCount,
122
+ },
123
+ hooks: {
124
+ total: data.hookCount,
125
+ },
126
+ indices: {
127
+ entityReferences: data.entityReferencesSize,
128
+ entityToReferencingArchetypes: data.entityToReferencingArchetypesSize,
129
+ archetypesByComponent: data.archetypesByComponentSize,
130
+ },
131
+ activity: {
132
+ migrations: this.migrations,
133
+ hooksExecuted: debugHookExecutionCounter.value,
134
+ archetypesCreated: this.archetypesCreated,
135
+ archetypesRemoved: this.archetypesRemoved,
136
+ },
137
+ };
138
+
139
+ for (const cb of this.collectors) {
140
+ try {
141
+ cb(stats);
142
+ } catch {
143
+ // Intentionally ignore user callback errors (preserves original behavior)
144
+ }
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,88 @@
1
+ import type { ComponentId, EntityId } from "../entity";
2
+ import { getDetailedIdType } from "../entity";
3
+
4
+ /**
5
+ * Validation and overload-resolution helpers extracted from World.
6
+ *
7
+ * These were previously private methods on World. Moving them reduces line count
8
+ * in the core class with almost zero coupling (the only dep is a liveness predicate
9
+ * for assertEntityExists, supplied by the caller).
10
+ *
11
+ * Pure type checks (assert*TypeValid) and the resolve* helpers for set/remove
12
+ * overloads live here.
13
+ */
14
+
15
+ /**
16
+ * Assert that an entity (or component-entity) is alive in the world.
17
+ * The caller supplies the liveness check (World.exists or equivalent) to keep
18
+ * this module free of direct references to stores.
19
+ */
20
+ export function assertEntityExists(
21
+ entityId: EntityId,
22
+ label: "Entity" | "Component entity",
23
+ exists: (id: EntityId) => boolean,
24
+ ): void {
25
+ if (!exists(entityId)) {
26
+ throw new Error(`${label} ${entityId} does not exist`);
27
+ }
28
+ }
29
+
30
+ export function assertComponentTypeValid(componentType: EntityId): void {
31
+ const detailedType = getDetailedIdType(componentType);
32
+ if (detailedType.type === "invalid") {
33
+ throw new Error(`Invalid component type: ${componentType}`);
34
+ }
35
+ }
36
+
37
+ export function assertSetComponentTypeValid(componentType: EntityId): void {
38
+ const detailedType = getDetailedIdType(componentType);
39
+ if (detailedType.type === "invalid") {
40
+ throw new Error(`Invalid component type: ${componentType}`);
41
+ }
42
+ if (detailedType.type === "wildcard-relation") {
43
+ throw new Error(`Cannot directly add wildcard relation components: ${componentType}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the (entity, componentType, value) for a set() call.
49
+ */
50
+ export function resolveSetOperation(
51
+ entityId: EntityId | ComponentId,
52
+ componentTypeOrComponent?: EntityId | any,
53
+ maybeComponent?: any,
54
+ exists: (id: EntityId) => boolean = () => true, // default permissive for tests / internal
55
+ ): { entityId: EntityId; componentType: EntityId; component: any } {
56
+ const targetEntityId = entityId as EntityId;
57
+ const componentType = componentTypeOrComponent as EntityId;
58
+ assertEntityExists(targetEntityId, "Entity", exists);
59
+ assertSetComponentTypeValid(componentType);
60
+
61
+ return { entityId: targetEntityId, componentType, component: maybeComponent };
62
+ }
63
+
64
+ /**
65
+ * Resolve the (entity, componentType) for a remove() call, handling the
66
+ * singleton component overload (remove(componentId)).
67
+ */
68
+ export function resolveRemoveOperation<T>(
69
+ entityId: EntityId | ComponentId,
70
+ componentType?: EntityId<T>,
71
+ exists: (id: EntityId) => boolean = () => true,
72
+ ): { entityId: EntityId; componentType: EntityId } {
73
+ // Handle singleton component overload: remove(componentId)
74
+ if (componentType === undefined) {
75
+ const componentId = entityId as ComponentId<T>;
76
+ assertEntityExists(componentId, "Component entity", exists);
77
+ return { entityId: componentId, componentType: componentId };
78
+ }
79
+
80
+ const targetEntityId = entityId as EntityId;
81
+ assertEntityExists(targetEntityId, "Entity", exists);
82
+ assertComponentTypeValid(componentType);
83
+
84
+ return { entityId: targetEntityId, componentType };
85
+ }
86
+
87
+ // Re-export the type for callers that need it in signatures (ComponentId lives in entity)
88
+ export type { ComponentId } from "../entity";
@@ -0,0 +1,51 @@
1
+ import type { ComponentId } from "../entity";
2
+
3
+ export interface SingletonHandleOps<T> {
4
+ has(): boolean;
5
+ get(): T;
6
+ getOptional(): { value: T } | undefined;
7
+ remove(): void;
8
+ set(value: T | undefined): void;
9
+ }
10
+
11
+ /**
12
+ * Explicit handle for a singleton component (component-as-entity).
13
+ *
14
+ * This provides an explicit and concise API for singleton components without
15
+ * overloading `world.set()` semantics.
16
+ *
17
+ * @example
18
+ * const config = world.singleton(Config);
19
+ * config.set({ debug: true });
20
+ * world.sync();
21
+ * console.log(config.get());
22
+ */
23
+ export class SingletonHandle<T = void> {
24
+ readonly componentId: ComponentId<T>;
25
+ private readonly ops: SingletonHandleOps<T>;
26
+
27
+ constructor(componentId: ComponentId<T>, ops: SingletonHandleOps<T>) {
28
+ this.componentId = componentId;
29
+ this.ops = ops;
30
+ }
31
+
32
+ has(): boolean {
33
+ return this.ops.has();
34
+ }
35
+
36
+ get(): T {
37
+ return this.ops.get();
38
+ }
39
+
40
+ getOptional(): { value: T } | undefined {
41
+ return this.ops.getOptional();
42
+ }
43
+
44
+ remove(): void {
45
+ this.ops.remove();
46
+ }
47
+
48
+ set(...args: T extends void ? [] : [value: NoInfer<T>]): void {
49
+ this.ops.set(args[0] as T | undefined);
50
+ }
51
+ }