@forgeax/engine-state 0.0.0-dev.8d955ade1c79

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +87 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/cli-state.d.ts +10 -0
  5. package/dist/cli-state.d.ts.map +1 -0
  6. package/dist/cli-state.mjs +273 -0
  7. package/dist/cli-state.mjs.map +1 -0
  8. package/dist/conditions.d.ts +31 -0
  9. package/dist/conditions.d.ts.map +1 -0
  10. package/dist/define-state.d.ts +71 -0
  11. package/dist/define-state.d.ts.map +1 -0
  12. package/dist/errors.d.ts +70 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.mjs +402 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/on-enter-on-exit.d.ts +65 -0
  19. package/dist/on-enter-on-exit.d.ts.map +1 -0
  20. package/dist/plugin-factory.d.ts +9 -0
  21. package/dist/plugin-factory.d.ts.map +1 -0
  22. package/dist/register-plugin.d.ts +27 -0
  23. package/dist/register-plugin.d.ts.map +1 -0
  24. package/dist/resources.d.ts +23 -0
  25. package/dist/resources.d.ts.map +1 -0
  26. package/dist/scoped-component.d.ts +38 -0
  27. package/dist/scoped-component.d.ts.map +1 -0
  28. package/dist/set-next-state.d.ts +25 -0
  29. package/dist/set-next-state.d.ts.map +1 -0
  30. package/dist/transition-system.d.ts +3 -0
  31. package/dist/transition-system.d.ts.map +1 -0
  32. package/package.json +62 -0
  33. package/src/cli-state.ts +232 -0
  34. package/src/conditions.ts +60 -0
  35. package/src/define-state.ts +158 -0
  36. package/src/errors.ts +147 -0
  37. package/src/index.ts +42 -0
  38. package/src/on-enter-on-exit.ts +156 -0
  39. package/src/plugin-factory.ts +19 -0
  40. package/src/register-plugin.ts +114 -0
  41. package/src/resources.ts +45 -0
  42. package/src/scoped-component.ts +159 -0
  43. package/src/set-next-state.ts +101 -0
  44. package/src/transition-system.ts +153 -0
package/src/errors.ts ADDED
@@ -0,0 +1,147 @@
1
+ // @forgeax/engine-state -- error model SSOT (feat-20260616-engine-state-and-state-scoped-entities M1 / m1w4)
2
+ //
3
+ // Closed union StateErrorCode (4 members), discriminated detail union,
4
+ // and structured StateError carrying .code / .expected / .hint / .detail.
5
+ //
6
+ // Decision anchors:
7
+ // - plan-strategy D-4: 4-code order-locked closed union + discriminated detail
8
+ // - requirements sec 2.7: error code union, defineState throws (programmer error),
9
+ // setNextState / getState return Result.err (runtime AI user calls)
10
+ // - AGENTS.md Error model: structured errors with .expected / .hint / .detail,
11
+ // never throw for runtime paths; exhaustive switch without default
12
+
13
+ /** {@link state-already-defined} payload: carries the conflicting name and optional first-definition site. */
14
+ export interface StateAlreadyDefinedDetail {
15
+ readonly code: 'state-already-defined';
16
+ readonly name: string;
17
+ readonly firstDefinedAt: string | undefined;
18
+ }
19
+
20
+ /** {@link state-not-registered} payload: carries the token name that has no plugin registration. */
21
+ export interface StateNotRegisteredDetail {
22
+ readonly code: 'state-not-registered';
23
+ readonly name: string;
24
+ }
25
+
26
+ /** {@link invalid-variant} payload: carries the token name, the invalid variant string, and the valid variants list. */
27
+ export interface InvalidVariantDetail {
28
+ readonly code: 'invalid-variant';
29
+ readonly name: string;
30
+ readonly got: string;
31
+ readonly valid: readonly string[];
32
+ }
33
+
34
+ /** {@link state-default-required} payload: carries the token name whose variants array was empty. */
35
+ export interface StateDefaultRequiredDetail {
36
+ readonly code: 'state-default-required';
37
+ readonly name: string;
38
+ }
39
+
40
+ interface StateErrorDetailByCode {
41
+ 'state-already-defined': StateAlreadyDefinedDetail;
42
+ 'state-not-registered': StateNotRegisteredDetail;
43
+ 'invalid-variant': InvalidVariantDetail;
44
+ 'state-default-required': StateDefaultRequiredDetail;
45
+ }
46
+
47
+ /**
48
+ * Closed {@link StateErrorCode} union -- 4 members, order-locked.
49
+ * Exhaustive `switch (err.code)` needs no default fallback.
50
+ *
51
+ * | code | trigger |
52
+ * |:--|:--|
53
+ * | `'state-already-defined'` | `defineState()` called with a name already registered |
54
+ * | `'state-not-registered'` | `setNextState()` / `getState()` called before `registerStatesPlugin()` |
55
+ * | `'invalid-variant'` | `setNextState()` called with a variant string not in the token's variants tuple |
56
+ * | `'state-default-required'` | `defineState()` called with empty variants array |
57
+ */
58
+ export type StateErrorCode = keyof StateErrorDetailByCode;
59
+
60
+ /**
61
+ * Discriminated detail union for {@link StateError}, narrowed per
62
+ * `StateError.code`. AI users obtain the concrete shape via
63
+ * `switch (err.code)` without a fallback `as` cast.
64
+ */
65
+ export type StateErrorDetail = StateErrorDetailByCode[StateErrorCode];
66
+
67
+ /**
68
+ * Structured state-machine error -- four-field surface
69
+ * (`.code` / `.expected` / `.hint` / `.detail`).
70
+ *
71
+ * AI users consume the structured triple by fields, not by parsing `.message`.
72
+ */
73
+ type StateErrorVariant<C extends StateErrorCode> = {
74
+ readonly code: C;
75
+ readonly expected: string;
76
+ readonly hint: string;
77
+ readonly detail: StateErrorDetailByCode[C];
78
+ };
79
+
80
+ export type StateError = {
81
+ [C in StateErrorCode]: StateErrorVariant<C>;
82
+ }[StateErrorCode];
83
+
84
+ function makeError<C extends StateErrorCode>(
85
+ code: C,
86
+ expected: string,
87
+ hint: string,
88
+ detail: StateErrorDetailByCode[C],
89
+ ): StateErrorVariant<C> {
90
+ const error = {
91
+ code,
92
+ expected,
93
+ hint,
94
+ detail,
95
+ get message(): string {
96
+ return `[${code}] ${hint}`;
97
+ },
98
+ };
99
+ return error;
100
+ }
101
+
102
+ /** Convenience throw wrapper for programmer errors (defineState constructor phase). */
103
+ export function throwStateError<C extends StateErrorCode>(
104
+ code: C,
105
+ expected: string,
106
+ hint: string,
107
+ detail: StateErrorDetailByCode[C],
108
+ ): never {
109
+ throw makeError(code, expected, hint, detail);
110
+ }
111
+
112
+ export function stateAlreadyDefined(name: string, firstDefinedAt?: string): StateError {
113
+ return makeError(
114
+ 'state-already-defined',
115
+ 'Each StateToken name must be registered exactly once at module level',
116
+ `State "${name}" is already defined${firstDefinedAt ? ` (first defined at ${firstDefinedAt})` : ''}. Use the existing token.`,
117
+ { code: 'state-already-defined', name, firstDefinedAt },
118
+ );
119
+ }
120
+
121
+ export function stateNotRegistered(name: string): StateError {
122
+ return makeError(
123
+ 'state-not-registered',
124
+ 'registerStatesPlugin(world) must be called before using setNextState / getState',
125
+ `State "${name}" has not been registered via registerStatesPlugin. createApp auto-registers the plugin in both canvas and assemble forms.`,
126
+ { code: 'state-not-registered', name },
127
+ );
128
+ }
129
+
130
+ export function invalidVariant(name: string, got: string, valid: readonly string[]): StateError {
131
+ const validSnapshot = [...valid];
132
+ return makeError(
133
+ 'invalid-variant',
134
+ `Variant must be one of: ${validSnapshot.join(', ')}`,
135
+ `"${got}" is not a valid variant for state "${name}". Did you mean one of: ${validSnapshot.join(', ')}? Check for typos.`,
136
+ { code: 'invalid-variant', name, got, valid: validSnapshot },
137
+ );
138
+ }
139
+
140
+ export function stateDefaultRequired(name: string): StateError {
141
+ return makeError(
142
+ 'state-default-required',
143
+ 'defineState requires at least one variant (non-empty array)',
144
+ `State "${name}" was defined with an empty variants array. Provide at least one variant, e.g. defineState("${name}", ["default"] as const).`,
145
+ { code: 'state-default-required', name },
146
+ );
147
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // @forgeax/engine-state -- public barrel
2
+ //
3
+ // Single-entry surface for typed state definitions, transitions, lifecycle, and inspection.
4
+
5
+ export { inState } from './conditions';
6
+ export type {
7
+ StateToken,
8
+ StateTokenName,
9
+ StateTokenVariant,
10
+ } from './define-state';
11
+ export { defineState } from './define-state';
12
+ export type {
13
+ InvalidVariantDetail,
14
+ StateAlreadyDefinedDetail,
15
+ StateDefaultRequiredDetail,
16
+ StateError,
17
+ StateErrorCode,
18
+ StateErrorDetail,
19
+ StateNotRegisteredDetail,
20
+ } from './errors';
21
+ export type {
22
+ StateCallback,
23
+ UnsubscribeHandle,
24
+ } from './on-enter-on-exit';
25
+ export {
26
+ addOnEnter,
27
+ addOnExit,
28
+ OnEnter,
29
+ OnExit,
30
+ } from './on-enter-on-exit';
31
+ export { statePlugin } from './plugin-factory';
32
+ export { registerStatesPlugin, StateSet } from './register-plugin';
33
+ export {
34
+ despawnOnEnter,
35
+ despawnOnExit,
36
+ } from './scoped-component';
37
+ export {
38
+ getPreviousState,
39
+ getState,
40
+ setNextState,
41
+ setNextStateForce,
42
+ } from './set-next-state';
@@ -0,0 +1,156 @@
1
+ // @forgeax/engine-state -- OnEnter / OnExit callback registry (M4 / m4w2)
2
+ //
3
+ // OnEnter(token, value) and OnExit(token, value) return branded schedule-label
4
+ // strings (pattern: `${name}__OnEnter__${value}` / `${name}__OnExit__${value}`).
5
+ //
6
+ // addOnEnter(token, variant, fn) / addOnExit(token, variant, fn) push fn into a
7
+ // per-label callback registry and return an unsubscribe handle. The registry is
8
+ // consumed by transitionStatesSystem (m4w4) which dispatches callbacks during
9
+ // state transitions.
10
+ //
11
+ // These labels are NOT ECS schedule labels — they are state-package-internal
12
+ // dispatchers. There is no ECS sub-schedule (research F-5); all dispatch happens
13
+ // inside transitionStatesSystem itself (plan-strategy D-5).
14
+ //
15
+ // Decision anchors:
16
+ // - plan-strategy D-5: fn[] registry + transition body dispatch, zero ECS change
17
+ // - research F-5: ECS has no schedule label / sub-schedule API
18
+ // - requirements F-11: OnEnter/OnExit return schedule labels for user-facing API
19
+
20
+ import type { World } from '@forgeax/engine-ecs';
21
+ import type { StateToken, StateTokenVariant } from './define-state';
22
+
23
+ const ON_ENTER_LABEL_PREFIX = '__OnEnter__';
24
+ const ON_EXIT_LABEL_PREFIX = '__OnExit__';
25
+
26
+ /**
27
+ * Callback type for OnEnter / OnExit hooks.
28
+ *
29
+ * Receives the {@link World} for ECS interaction (e.g. spawning entities,
30
+ * reading Resources). Callbacks fire synchronously inside transitionStatesSystem.
31
+ */
32
+ export type StateCallback = (world: World) => void;
33
+
34
+ /**
35
+ * Unsubscribe handle returned by {@link addOnEnter} / {@link addOnExit}.
36
+ * Call to remove the callback from future dispatch.
37
+ */
38
+ export type UnsubscribeHandle = () => void;
39
+
40
+ /**
41
+ * Internal callback entry: pairs a function with its identity for removal.
42
+ * The `id` is a unique symbol used as the remove key.
43
+ */
44
+ interface CallbackEntry {
45
+ id: symbol;
46
+ fn: StateCallback;
47
+ }
48
+
49
+ /**
50
+ * Module-private callback registry.
51
+ *
52
+ * Key: `${tokenName}__OnEnter__${variant}` or `${tokenName}__OnExit__${variant}`
53
+ * Value: ordered array of callback entries (fired in registration order).
54
+ */
55
+ const _registry = new Map<string, CallbackEntry[]>();
56
+
57
+ function makeLabel(tokenName: string, prefix: string, variant: string): string {
58
+ return `${tokenName}${prefix}${variant}`;
59
+ }
60
+
61
+ /**
62
+ * Returns a branded schedule-label string for an OnEnter hook.
63
+ *
64
+ * The label is NOT an ECS schedule label. It is consumed internally by
65
+ * {@link addOnEnter} and dispatched by transitionStatesSystem during state
66
+ * transition (plan-strategy D-5).
67
+ *
68
+ * @param token - The StateToken defining the state machine.
69
+ * @param variant - The variant whose entry triggers the callback.
70
+ * @returns A unique label string usable with {@link addOnEnter}.
71
+ */
72
+ export function OnEnter<T extends StateToken>(token: T, variant: StateTokenVariant<T>): string {
73
+ return makeLabel(token.name, ON_ENTER_LABEL_PREFIX, variant);
74
+ }
75
+
76
+ /**
77
+ * Returns a branded schedule-label string for an OnExit hook.
78
+ *
79
+ * @param token - The StateToken defining the state machine.
80
+ * @param variant - The variant whose exit triggers the callback.
81
+ * @returns A unique label string usable with {@link addOnExit}.
82
+ */
83
+ export function OnExit<T extends StateToken>(token: T, variant: StateTokenVariant<T>): string {
84
+ return makeLabel(token.name, ON_EXIT_LABEL_PREFIX, variant);
85
+ }
86
+
87
+ /**
88
+ * Register a callback to fire when `token` transitions into `variant`.
89
+ *
90
+ * @param token - The state machine token.
91
+ * @param variant - The target variant that triggers this callback.
92
+ * @param fn - The callback to invoke (receives World parameter).
93
+ * @returns An unsubscribe handle. Call it to remove the callback from future dispatch.
94
+ */
95
+ export function addOnEnter<T extends StateToken>(
96
+ token: T,
97
+ variant: StateTokenVariant<T>,
98
+ fn: StateCallback,
99
+ ): UnsubscribeHandle {
100
+ const label = OnEnter(token, variant);
101
+ return _add(label, fn);
102
+ }
103
+
104
+ /**
105
+ * Register a callback to fire when `token` transitions away from `variant`.
106
+ *
107
+ * @param token - The state machine token.
108
+ * @param variant - The variant whose exit triggers this callback.
109
+ * @param fn - The callback to invoke (receives World parameter).
110
+ * @returns An unsubscribe handle. Call it to remove the callback from future dispatch.
111
+ */
112
+ export function addOnExit<T extends StateToken>(
113
+ token: T,
114
+ variant: StateTokenVariant<T>,
115
+ fn: StateCallback,
116
+ ): UnsubscribeHandle {
117
+ const label = OnExit(token, variant);
118
+ return _add(label, fn);
119
+ }
120
+
121
+ function _add(label: string, fn: StateCallback): UnsubscribeHandle {
122
+ let entries = _registry.get(label);
123
+ if (!entries) {
124
+ entries = [];
125
+ _registry.set(label, entries);
126
+ }
127
+
128
+ const id = Symbol();
129
+ entries.push({ id, fn });
130
+
131
+ return () => {
132
+ const list = _registry.get(label);
133
+ if (!list) return;
134
+ const idx = list.findIndex((e) => e.id === id);
135
+ if (idx !== -1) {
136
+ list.splice(idx, 1);
137
+ }
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Get all registered callbacks for a given label, in registration order.
143
+ *
144
+ * Returns the callbacks as an array of functions. An empty array means no
145
+ * callbacks are registered for this label. Consumed by transitionStatesSystem
146
+ * during dispatch (m4w4).
147
+ *
148
+ * @param label - An OnEnter or OnExit label string.
149
+ * @returns Array of callback functions (may be empty).
150
+ * @internal
151
+ */
152
+ export function getCallbacks(label: string): StateCallback[] {
153
+ const entries = _registry.get(label);
154
+ if (!entries) return [];
155
+ return entries.map((e) => e.fn);
156
+ }
@@ -0,0 +1,19 @@
1
+ import type { Plugin } from '@forgeax/engine-plugin';
2
+
3
+ import { registerStatesPlugin } from './register-plugin';
4
+
5
+ /**
6
+ * statePlugin -- registers the state-machine systems + per-token Resources.
7
+ *
8
+ * Installs the transition system and projects both current and later
9
+ * module-defined StateTokens into the injected World.
10
+ */
11
+ export function statePlugin(): Plugin {
12
+ return {
13
+ name: 'state',
14
+ inject: ['world'],
15
+ apply(ctx) {
16
+ ctx.effect(() => registerStatesPlugin(ctx.world), 'state/systems');
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,114 @@
1
+ import { Update } from '@forgeax/engine-ecs';
2
+ // @forgeax/engine-state -- registerStatesPlugin (M2 / m2w2, M3 / m3w4)
3
+ //
4
+ // Idempotent plugin that inserts per-token Resources (State / NextState /
5
+ // PreviousState), pre-registers ScopedTo components, and registers the
6
+ // transitionStatesSystem in the ECS schedule. Called automatically by
7
+ // createApp in both canvas and assemble forms.
8
+ //
9
+ // M3 / m3w4: stub replaced with transitionStatesSystem from transition-system.ts.
10
+ //
11
+ // Decision anchors:
12
+ // - requirements F-9: idempotent, canvas + assemble dual-form auto-wire
13
+ // - plan-strategy D-6: schedule anchors 'input-frame-start-scan' -> 'transitionStates' -> 'propagateTransforms'
14
+ // - plan-strategy D-4: insertResource initial values from token.defaultValue
15
+
16
+ import { defineSystem, defineSystemSet, type SystemHandle, type World } from '@forgeax/engine-ecs';
17
+ import { getRegisteredTokens, onStateDefined, type StateToken } from './define-state';
18
+ import { nextStateResourceKey, previousStateResourceKey, stateResourceKey } from './resources';
19
+ import { getScopedComponent, registerScopedComponents } from './scoped-component';
20
+ import { transitionStatesSystem } from './transition-system';
21
+
22
+ /** Schedule anchor: system name for the input frame-start scan (registered by {@link @forgeax/engine-input}). */
23
+ const FRAME_START_SCAN_SYSTEM_NAME = 'input-frame-start-scan' as const;
24
+
25
+ /** Schedule anchor: system name for the transform-propagation system (registered by {@link @forgeax/engine-runtime}). */
26
+ const PROPAGATE_TRANSFORMS_SYSTEM = 'propagateTransforms' as const;
27
+
28
+ const TRANSITION_STATES_SYSTEM_NAME = 'transitionStates';
29
+ export const StateSet = defineSystemSet({ name: 'state' });
30
+ const ACTIVE_STATE_RUNTIMES = new WeakSet<World>();
31
+
32
+ /**
33
+ * The `transitionStates` system token (M2 — full resource-ification, D-4).
34
+ *
35
+ * Module-level `defineSystem` with the real fn body — no closure, no
36
+ * placeholder. The fn reads `world` from its first parameter (the M1
37
+ * world-first signature) and delegates to {@link transitionStatesSystem}.
38
+ * Anchored `after: ['input-frame-start-scan']`, `before: ['propagateTransforms']`
39
+ * and labelled `'state'` (spec §6.2 label-anchor map).
40
+ */
41
+ export const TransitionStates: SystemHandle<readonly []> = defineSystem({
42
+ name: TRANSITION_STATES_SYSTEM_NAME,
43
+ queries: [],
44
+ after: [FRAME_START_SCAN_SYSTEM_NAME],
45
+ before: [PROPAGATE_TRANSFORMS_SYSTEM],
46
+ fn: transitionStatesSystem,
47
+ });
48
+
49
+ /**
50
+ * Register the state-machine plugin on a {@link World}.
51
+ *
52
+ * Side effects:
53
+ * 1. Pre-registers `__scopedTo__<name>` components for all known tokens.
54
+ * 2. For each globally registered {@link StateToken}: inserts three Resources
55
+ * ({@link State} = defaultValue index, {@link NextState} = undefined,
56
+ * {@link PreviousState} = defaultValue index).
57
+ * 3. Registers the {@link TransitionStates} system in the schedule.
58
+ *
59
+ * Tokens defined after registration are projected immediately. Repeated calls
60
+ * on the same World are no-ops; the first owner receives the sole disposer.
61
+ */
62
+ export function registerStatesPlugin(world: World): () => void {
63
+ if (ACTIVE_STATE_RUNTIMES.has(world)) return () => {};
64
+
65
+ const resourceKeys = new Set<string>();
66
+ const componentLeases = new Map<string, { dispose(): unknown }>();
67
+ const registerToken = (token: StateToken): void => {
68
+ registerScopedComponents();
69
+ const scopedComponent = getScopedComponent(token);
70
+ if (!componentLeases.has(token.name)) {
71
+ const lease = world.components.register(scopedComponent);
72
+ if (!lease.ok) throw lease.error;
73
+ componentLeases.set(token.name, lease.value);
74
+ }
75
+ const defaultValueIdx = token.nameToIdx.get(token.defaultValue);
76
+ if (defaultValueIdx === undefined) return;
77
+
78
+ const stateKey = stateResourceKey(token);
79
+ const nextKey = nextStateResourceKey(token);
80
+ const previousKey = previousStateResourceKey(token);
81
+ if (world.hasResource(stateKey)) return;
82
+ resourceKeys.add(stateKey);
83
+ resourceKeys.add(nextKey);
84
+ resourceKeys.add(previousKey);
85
+ world.insertResource(stateKey, defaultValueIdx);
86
+ world.insertResource(nextKey, undefined as { value: number; force: boolean } | undefined);
87
+ world.insertResource(previousKey, defaultValueIdx);
88
+ };
89
+
90
+ for (const token of getRegisteredTokens().values()) registerToken(token);
91
+ const installed = world.addSystems(Update, StateSet, [TransitionStates]);
92
+ if (!installed.ok) {
93
+ for (const key of resourceKeys) {
94
+ world.removeResource(key);
95
+ }
96
+ throw installed.error;
97
+ }
98
+ const unsubscribe = onStateDefined(registerToken);
99
+ ACTIVE_STATE_RUNTIMES.add(world);
100
+ let disposed = false;
101
+ return () => {
102
+ if (disposed) return;
103
+ disposed = true;
104
+ unsubscribe();
105
+ ACTIVE_STATE_RUNTIMES.delete(world);
106
+ world.removeSystem(Update, TRANSITION_STATES_SYSTEM_NAME);
107
+ for (const key of resourceKeys) {
108
+ world.removeResource(key);
109
+ }
110
+ for (const lease of componentLeases.values()) {
111
+ lease.dispose();
112
+ }
113
+ };
114
+ }
@@ -0,0 +1,45 @@
1
+ // @forgeax/engine-state -- Resource key constructors (feat-20260616 M1 / m1w2)
2
+ //
3
+ // Three Resource keys per StateToken: State (current value), NextState (pending
4
+ // transition request), PreviousState (cached last-frame value for OnExit / getPreviousState).
5
+ //
6
+ // Decision anchors:
7
+ // - plan-strategy D-1: Resources are per-token, keyed by __state__ prefix
8
+ // - plan-strategy D-4: keys are pure string constructors; Resource CRUD is in registerStatesPlugin M2
9
+ // - requirements F-4/F-5/F-6: State / NextState / PreviousState as Resources
10
+
11
+ import type { StateToken } from './define-state';
12
+
13
+ const STATE_PREFIX = '__state__';
14
+ const NEXT_STATE_PREFIX = '__nextState__';
15
+ const PREVIOUS_STATE_PREFIX = '__previousState__';
16
+
17
+ /**
18
+ * Resource key for the current state value of a token.
19
+ *
20
+ * The {@link StateTokenVariant} is stored; `getState(world, token)` reads
21
+ * this Resource and decodes the index to a variant string.
22
+ */
23
+ export function stateResourceKey(token: StateToken): string {
24
+ return `${STATE_PREFIX}${token.name}`;
25
+ }
26
+
27
+ /**
28
+ * Resource key for the pending next-state transition request.
29
+ *
30
+ * Written by `setNextState` / `setNextStateForce` (M2); consumed by
31
+ * `transitionStatesSystem` (M3).
32
+ */
33
+ export function nextStateResourceKey(token: StateToken): string {
34
+ return `${NEXT_STATE_PREFIX}${token.name}`;
35
+ }
36
+
37
+ /**
38
+ * Resource key for the previous-frame state value.
39
+ *
40
+ * Written by `transitionStatesSystem` before flipping `State`; read by
41
+ * `getPreviousState(world, token)` (M2).
42
+ */
43
+ export function previousStateResourceKey(token: StateToken): string {
44
+ return `${PREVIOUS_STATE_PREFIX}${token.name}`;
45
+ }
@@ -0,0 +1,159 @@
1
+ // @forgeax/engine-state -- ScopedTo components + despawnOnExit/Enter (M3 / m3w2)
2
+ //
3
+ // Per-token __scopedTo__<name> components: defineComponent with two enum fields
4
+ // (value = u32 variant index, mode = exit/enter enum). Components are lazily
5
+ // created on first use via getOrCreateScopedComponent.
6
+ //
7
+ // despawnOnExit / despawnOnEnter are free functions that add the corresponding
8
+ // ScopedTo component to an entity. On duplicate add the ECS returns
9
+ // ComponentAlreadyPresentError, which we throw.
10
+ //
11
+ // Decision anchors:
12
+ // - plan-strategy D-1: value field uses 'enum' (u32 index into token.variants)
13
+ // - requirements F-7/F-8: despawnOnExit/despawnOnEnter free functions
14
+ // - requirements AC-11: duplicate add fail-fast via ECS default exclusive=false
15
+ // - research F-2: 'enum' is already a ScalarFieldType
16
+
17
+ import { defineComponent, type EntityHandle, type World } from '@forgeax/engine-ecs';
18
+ import type { StateToken, StateTokenVariant } from './define-state';
19
+ import { getRegisteredTokens } from './define-state';
20
+
21
+ const SCOPED_COMPONENTS = new Map<string, ReturnType<typeof defineComponent>>();
22
+
23
+ /** Fixed label-to-value map for the ScopedTo `mode` enum field. */
24
+ export const SCOPED_MODE_VALUE = { exit: 0, enter: 1 } as const;
25
+
26
+ function getOrCreateScopedComponent(token: StateToken): ReturnType<typeof defineComponent> {
27
+ const existing = SCOPED_COMPONENTS.get(token.name);
28
+ if (existing) return existing;
29
+
30
+ // `value` is the token's variant index — derive its label→index map from
31
+ // `token.variants` so reflection (describeComponent) can name each variant;
32
+ // `mode` uses the fixed exit/enter map. Both attach labels to the enum field
33
+ // (Derive, don't Duplicate: the variant order IS the label source).
34
+ const valueLabels: Record<string, number> = {};
35
+ token.variants.forEach((v, i) => {
36
+ valueLabels[v] = i;
37
+ });
38
+
39
+ const comp = defineComponent(`__scopedTo__${token.name}`, {
40
+ value: { type: 'enum', default: 0, labels: valueLabels },
41
+ mode: { type: 'enum', default: SCOPED_MODE_VALUE.exit, labels: SCOPED_MODE_VALUE },
42
+ });
43
+ SCOPED_COMPONENTS.set(token.name, comp);
44
+ return comp;
45
+ }
46
+
47
+ /** Resolve the world-local ScopedTo token for a state. */
48
+ export function getScopedComponent(token: StateToken): ReturnType<typeof defineComponent> {
49
+ return getOrCreateScopedComponent(token);
50
+ }
51
+
52
+ function resolveVariantIndex(token: StateToken, variant: string): number {
53
+ const idx = token.nameToIdx.get(variant as never);
54
+ if (idx === undefined) {
55
+ throw new Error(
56
+ `Invalid variant "${variant}" for state "${token.name}". Valid: ${token.variants.join(', ')}`,
57
+ );
58
+ }
59
+ return idx;
60
+ }
61
+
62
+ /**
63
+ * Mark `entity` to be despawned when `token` leaves `variant`.
64
+ *
65
+ * Adds a `__scopedTo__<token.name>` component with mode=exit
66
+ * and value=<variant index>. When transitionStatesSystem detects
67
+ * the token transitions away from `variant`, it despawns the entity.
68
+ *
69
+ * Throws if `entity` already carries this token's ScopedTo component
70
+ * (ECS default exclusive=false fail-fast).
71
+ */
72
+ function addScopedComponent(
73
+ world: World,
74
+ entity: EntityHandle,
75
+ scoped: ReturnType<typeof defineComponent>,
76
+ value: number,
77
+ mode: number,
78
+ ): ReturnType<typeof world.addComponent> {
79
+ return world.addComponent(entity, {
80
+ component: scoped,
81
+ // generic ComponentSchema loses the concrete {value, mode} enum-field
82
+ // types; data is u32 at runtime.
83
+ data: { value, mode } as Record<string, unknown> as Parameters<
84
+ typeof world.addComponent
85
+ >[1]['data'],
86
+ });
87
+ }
88
+
89
+ export function despawnOnExit<T extends StateToken>(
90
+ world: World,
91
+ entity: EntityHandle,
92
+ token: T,
93
+ variant: StateTokenVariant<T>,
94
+ ): void {
95
+ const idx = resolveVariantIndex(token, variant);
96
+ const scoped = getOrCreateScopedComponent(token);
97
+ const result = addScopedComponent(world, entity, scoped, idx, SCOPED_MODE_VALUE.exit);
98
+ if (!result.ok) {
99
+ throw result.error;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Mark `entity` to be despawned when `token` enters `variant`.
105
+ *
106
+ * Adds a `__scopedTo__<token.name>` component with mode=enter
107
+ * and value=<variant index>. When transitionStatesSystem detects
108
+ * the token transitions into `variant`, it despawns the entity.
109
+ *
110
+ * Throws if `entity` already carries this token's ScopedTo component
111
+ * (ECS default exclusive=false fail-fast).
112
+ */
113
+ export function despawnOnEnter<T extends StateToken>(
114
+ world: World,
115
+ entity: EntityHandle,
116
+ token: T,
117
+ variant: StateTokenVariant<T>,
118
+ ): void {
119
+ const idx = resolveVariantIndex(token, variant);
120
+ const scoped = getOrCreateScopedComponent(token);
121
+ const result = addScopedComponent(world, entity, scoped, idx, SCOPED_MODE_VALUE.enter);
122
+ if (!result.ok) {
123
+ throw result.error;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Pre-register scoped components for all state tokens in the global registry.
129
+ * Called by registerStatesPlugin during boot; idempotent.
130
+ *
131
+ * @internal
132
+ */
133
+ export function registerScopedComponents(): void {
134
+ for (const token of getRegisteredTokens().values()) {
135
+ getOrCreateScopedComponent(token);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Count entities carrying `token`'s ScopedTo component, grouped by the variant
141
+ * index they are scoped to (irrespective of exit/enter mode). Returns an array
142
+ * aligned to `token.variants` (index i = count for `token.variants[i]`).
143
+ *
144
+ * Used by the `state get <name>` CLI inspector to report per-variant scoped
145
+ * entity counts (requirements AC-15). Reflection only — does not mutate World.
146
+ */
147
+ export function countScopedEntitiesByVariant(world: World, token: StateToken): number[] {
148
+ const counts = new Array<number>(token.variants.length).fill(0);
149
+ const scoped = getOrCreateScopedComponent(token);
150
+ const query = world.query({ read: [scoped] }).unwrap();
151
+ for (const row of query) {
152
+ const idx = row.get(scoped).value;
153
+ if (typeof idx !== 'number') continue;
154
+ if (idx >= 0 && idx < counts.length) counts[idx] = (counts[idx] ?? 0) + 1;
155
+ }
156
+ return counts;
157
+ }
158
+
159
+ // resolveScopedComponent removed — transitionStatesSystem uses the World-local catalog.