@forgeax/engine-state 0.1.2

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 +275 -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 +406 -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 +48 -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 +105 -0
  44. package/src/transition-system.ts +153 -0
@@ -0,0 +1,23 @@
1
+ import type { StateToken } from './define-state';
2
+ /**
3
+ * Resource key for the current state value of a token.
4
+ *
5
+ * The {@link StateTokenVariant} is stored; `getState(world, token)` reads
6
+ * this Resource and decodes the index to a variant string.
7
+ */
8
+ export declare function stateResourceKey(token: StateToken): string;
9
+ /**
10
+ * Resource key for the pending next-state transition request.
11
+ *
12
+ * Written by `setNextState` / `setNextStateForce` (M2); consumed by
13
+ * `transitionStatesSystem` (M3).
14
+ */
15
+ export declare function nextStateResourceKey(token: StateToken): string;
16
+ /**
17
+ * Resource key for the previous-frame state value.
18
+ *
19
+ * Written by `transitionStatesSystem` before flipping `State`; read by
20
+ * `getPreviousState(world, token)` (M2).
21
+ */
22
+ export declare function previousStateResourceKey(token: StateToken): string;
23
+ //# sourceMappingURL=resources.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resources.d.ts","sourceRoot":"","sources":["../src/resources.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAMjD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAE1D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAE9D;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAElE"}
@@ -0,0 +1,38 @@
1
+ import { defineComponent, type EntityHandle, type World } from '@forgeax/engine-ecs';
2
+ import type { StateToken, StateTokenVariant } from './define-state';
3
+ /** Fixed label-to-value map for the ScopedTo `mode` enum field. */
4
+ export declare const SCOPED_MODE_VALUE: {
5
+ readonly exit: 0;
6
+ readonly enter: 1;
7
+ };
8
+ /** Resolve the world-local ScopedTo token for a state. */
9
+ export declare function getScopedComponent(token: StateToken): ReturnType<typeof defineComponent>;
10
+ export declare function despawnOnExit<T extends StateToken>(world: World, entity: EntityHandle, token: T, variant: StateTokenVariant<T>): void;
11
+ /**
12
+ * Mark `entity` to be despawned when `token` enters `variant`.
13
+ *
14
+ * Adds a `__scopedTo__<token.name>` component with mode=enter
15
+ * and value=<variant index>. When transitionStatesSystem detects
16
+ * the token transitions into `variant`, it despawns the entity.
17
+ *
18
+ * Throws if `entity` already carries this token's ScopedTo component
19
+ * (ECS default exclusive=false fail-fast).
20
+ */
21
+ export declare function despawnOnEnter<T extends StateToken>(world: World, entity: EntityHandle, token: T, variant: StateTokenVariant<T>): void;
22
+ /**
23
+ * Pre-register scoped components for all state tokens in the global registry.
24
+ * Called by registerStatesPlugin during boot; idempotent.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare function registerScopedComponents(): void;
29
+ /**
30
+ * Count entities carrying `token`'s ScopedTo component, grouped by the variant
31
+ * index they are scoped to (irrespective of exit/enter mode). Returns an array
32
+ * aligned to `token.variants` (index i = count for `token.variants[i]`).
33
+ *
34
+ * Used by the `state get <name>` CLI inspector to report per-variant scoped
35
+ * entity counts (requirements AC-15). Reflection only — does not mutate World.
36
+ */
37
+ export declare function countScopedEntitiesByVariant(world: World, token: StateToken): number[];
38
+ //# sourceMappingURL=scoped-component.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scoped-component.d.ts","sourceRoot":"","sources":["../src/scoped-component.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,eAAe,EAAE,KAAK,YAAY,EAAE,KAAK,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACrF,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAKpE,mEAAmE;AACnE,eAAO,MAAM,iBAAiB;;;CAAiC,CAAC;AAuBhE,0DAA0D;AAC1D,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAExF;AAuCD,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAChD,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B,IAAI,CAON;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,UAAU,EACjD,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B,IAAI,CAON;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAI/C;AAED;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,EAAE,CAUtF"}
@@ -0,0 +1,48 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+ import type { StateToken, StateTokenVariant } from './define-state';
3
+ import type { StateError } from './errors';
4
+ /**
5
+ * Request a state transition for `token` to `variant` at the next frame.
6
+ *
7
+ * `variant` is narrowed to the token's variant union: a misspelled variant is
8
+ * a compile-time error (`StateTokenVariant<T>`), not just a runtime
9
+ * `invalid-variant` Result.
10
+ */
11
+ export declare function setNextState<T extends StateToken>(world: World, token: T, variant: StateTokenVariant<T>): {
12
+ ok: true;
13
+ value: undefined;
14
+ } | {
15
+ ok: false;
16
+ error: StateError;
17
+ };
18
+ /**
19
+ * Like {@link setNextState} but with `force=true`.
20
+ */
21
+ export declare function setNextStateForce<T extends StateToken>(world: World, token: T, variant: StateTokenVariant<T>): {
22
+ ok: true;
23
+ value: undefined;
24
+ } | {
25
+ ok: false;
26
+ error: StateError;
27
+ };
28
+ /**
29
+ * Read the current state value for `token`.
30
+ */
31
+ export declare function getState(world: World, token: StateToken): {
32
+ ok: true;
33
+ value: string;
34
+ } | {
35
+ ok: false;
36
+ error: StateError;
37
+ };
38
+ /**
39
+ * Read the previous-frame state value for `token`.
40
+ */
41
+ export declare function getPreviousState(world: World, token: StateToken): {
42
+ ok: true;
43
+ value: string;
44
+ } | {
45
+ ok: false;
46
+ error: StateError;
47
+ };
48
+ //# sourceMappingURL=set-next-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"set-next-state.d.ts","sourceRoot":"","sources":["../src/set-next-state.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAa3C;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,UAAU,EAC/C,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAEnE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,UAAU,EACpD,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAEnE;AAiBD;;GAEG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,UAAU,GAChB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAWhE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,UAAU,GAChB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAWhE"}
@@ -0,0 +1,3 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+ export declare function transitionStatesSystem(world: World): void;
3
+ //# sourceMappingURL=transition-system.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transition-system.d.ts","sourceRoot":"","sources":["../src/transition-system.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAA2B,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAwD1E,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CA0EzD"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@forgeax/engine-state",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Single-world typed-state machine with state-scoped entity lifecycle.",
9
+ "bin": {
10
+ "forgeax-engine-remote-state": "./dist/cli-state.mjs"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "main": "./dist/index.mjs",
20
+ "types": "./dist/index.d.ts",
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dependencies": {
28
+ "@forgeax/engine-ecs": "0.1.2",
29
+ "@forgeax/engine-plugin": "0.1.2",
30
+ "@forgeax/engine-scene": "0.1.2",
31
+ "@forgeax/engine-types": "0.1.2"
32
+ },
33
+ "forgeax": {
34
+ "metrics": {
35
+ "bundle-size": {
36
+ "enabled": true,
37
+ "path": "dist/index.mjs",
38
+ "compression": "gzip"
39
+ },
40
+ "fps": {
41
+ "enabled": false,
42
+ "reason": "library package, no runtime canvas; fps reported by hello-level-switch app"
43
+ },
44
+ "bench": {
45
+ "enabled": false,
46
+ "reason": "bench focus stays on engine-math; state correctness verified by unit tests"
47
+ },
48
+ "gate": {
49
+ "enabled": false,
50
+ "reason": "no package-level binary gate; smoke gate covered by hello-level-switch app"
51
+ },
52
+ "spike-report": {
53
+ "enabled": false,
54
+ "reason": "not a spike package"
55
+ }
56
+ }
57
+ },
58
+ "scripts": {
59
+ "build": "tsup",
60
+ "test": "vitest run"
61
+ }
62
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // @forgeax/engine-state/src/cli-state - forgeax-engine-remote-state plugin bin
3
+ // (feat-20260616-engine-state-and-state-scoped-entities M6 / m6w2).
4
+ //
5
+ // Two subcommands:
6
+ // state list — iterates getRegisteredTokens() and prints name + variants + current state
7
+ // state get <name> — calls getState and prints the variant string
8
+ //
9
+ // Discovery: kubectl 4th-path. The base bin `forgeax-engine-console` finds
10
+ // this binary on PATH via the `forgeax-engine-remote-` prefix scan and
11
+ // forwards stdio + exit code (see packages/console/src/discoverPlugins.ts).
12
+ //
13
+ // The `connect` field in DispatchOptions is optional — this CLI plugin reads
14
+ // from a directly supplied World reference (not through JSON-RPC over WS)
15
+ // because state introspection is a local ECS operation, not a remote inspector
16
+ // call. When the bin is invoked via PATH discovery from the base console CLI,
17
+ // stdin forwarding is the standard plugin channel; the World reference is
18
+ // provided by `world` in the script context injected via `vm.runInContext`.
19
+ //
20
+ // Decision anchors:
21
+ // - M6 spec: console state list iterates getRegisteredTokens()
22
+ // - M6 spec: console state get <name> calls getState and prints variant string
23
+ // - plan-strategy: cli-state reflects the state registry (M2) + current state (M2)
24
+ // - Console plugin pattern: argv-based dispatch with stdoutWrite/stderrWrite
25
+
26
+ import type { World } from '@forgeax/engine-ecs';
27
+ import type { StateToken } from './define-state';
28
+ import { getRegisteredTokens } from './define-state';
29
+ import { countScopedEntitiesByVariant } from './scoped-component';
30
+ import { getPreviousState, getState } from './set-next-state';
31
+
32
+ // ─── Dispatch options (test-injectable) ─────────────────────────────────────
33
+
34
+ export interface DispatchOptions {
35
+ readonly argv: readonly string[];
36
+ readonly stdoutWrite: (line: string) => void;
37
+ readonly stderrWrite: (line: string) => void;
38
+ readonly world: World;
39
+ }
40
+
41
+ // ─── Help renderer ──────────────────────────────────────────────────────────
42
+
43
+ function helpBody(): string {
44
+ return [
45
+ 'forgeax-engine-remote-state - inspect forgeax state machines',
46
+ '',
47
+ 'Usage:',
48
+ ' forgeax-engine-remote-state <subcommand> [args]',
49
+ '',
50
+ 'Subcommands:',
51
+ ' list list all registered tokens: name, current, previous, default, variants',
52
+ ' get <name> print one token: current, previous, default, variants + per-variant scoped entity counts',
53
+ '',
54
+ 'Flags:',
55
+ ' --help, -h show this help and exit 0',
56
+ '',
57
+ 'Examples:',
58
+ ' forgeax-engine-remote-state list',
59
+ ' forgeax-engine-remote-state get LevelId',
60
+ '',
61
+ ].join('\n');
62
+ }
63
+
64
+ function subcommandHelp(subcommand: string): string {
65
+ if (subcommand === 'list') {
66
+ return [
67
+ 'forgeax-engine-remote-state list - list all registered state tokens',
68
+ '',
69
+ 'Usage:',
70
+ ' forgeax-engine-remote-state list',
71
+ '',
72
+ 'Output format: one line per token:',
73
+ ' <tokenName>: <current> (previous: <previous>, default: <default>, variants: <v1>, <v2>, ...)',
74
+ '',
75
+ 'Examples:',
76
+ ' forgeax-engine-remote-state list',
77
+ '',
78
+ ].join('\n');
79
+ }
80
+ if (subcommand === 'get') {
81
+ return [
82
+ 'forgeax-engine-remote-state get - inspect one state token',
83
+ '',
84
+ 'Usage:',
85
+ ' forgeax-engine-remote-state get <tokenName>',
86
+ '',
87
+ 'Output: current / previous / default variant, the full variants list,',
88
+ 'and the count of ScopedTo entities currently scoped to each variant.',
89
+ '',
90
+ 'Examples:',
91
+ ' forgeax-engine-remote-state get LevelId',
92
+ ' # current: main-menu',
93
+ ' # previous: main-menu',
94
+ ' # default: main-menu',
95
+ ' # variants: main-menu (0), tutorial (0), street-a (0)',
96
+ '',
97
+ ].join('\n');
98
+ }
99
+ return helpBody();
100
+ }
101
+
102
+ // ─── Subcommand impl ────────────────────────────────────────────────────────
103
+
104
+ function runList(world: World, stdout: (line: string) => void): void {
105
+ const tokens = getRegisteredTokens();
106
+ if (tokens.size === 0) {
107
+ stdout('(no state tokens registered)');
108
+ return;
109
+ }
110
+ for (const [, token] of tokens) {
111
+ const current = getCurrentStateString(world, token);
112
+ const previous = getPreviousStateString(world, token);
113
+ const variantsStr = (token.variants as readonly string[]).join(', ');
114
+ stdout(
115
+ `${token.name}: ${current} (previous: ${previous}, default: ${token.defaultValue}, variants: ${variantsStr})`,
116
+ );
117
+ }
118
+ }
119
+
120
+ function runGet(
121
+ world: World,
122
+ tokenName: string,
123
+ stdout: (line: string) => void,
124
+ stderr: (line: string) => void,
125
+ ): number {
126
+ const tokens = getRegisteredTokens();
127
+ const token = tokens.get(tokenName);
128
+ if (token === undefined) {
129
+ stderr(
130
+ [
131
+ `forgeax: unknown state token "${tokenName}"`,
132
+ ` expected: one of ${[...tokens.keys()].map((k) => `"${k}"`).join(', ') || '(none registered)'}`,
133
+ " hint: use 'forgeax-engine-remote-state list' to see registered tokens",
134
+ ].join('\n'),
135
+ );
136
+ return 1;
137
+ }
138
+
139
+ const result = getState(world, token);
140
+ if (!result.ok) {
141
+ stderr(
142
+ [
143
+ `forgeax: ${result.error.code}`,
144
+ ` expected: ${result.error.expected}`,
145
+ ` hint: ${result.error.hint}`,
146
+ ].join('\n'),
147
+ );
148
+ return 1;
149
+ }
150
+
151
+ const previous = getPreviousStateString(world, token);
152
+ const counts = countScopedEntitiesByVariant(world, token);
153
+ const variantsLine = (token.variants as readonly string[])
154
+ .map((v, i) => `${v} (${counts[i] ?? 0})`)
155
+ .join(', ');
156
+
157
+ stdout(`current: ${result.value}`);
158
+ stdout(`previous: ${previous}`);
159
+ stdout(`default: ${token.defaultValue}`);
160
+ stdout(`variants: ${variantsLine}`);
161
+ return 0;
162
+ }
163
+
164
+ function getCurrentStateString(world: World, token: StateToken): string {
165
+ const result = getState(world, token);
166
+ if (result.ok) return result.value;
167
+ return '<error>';
168
+ }
169
+
170
+ function getPreviousStateString(world: World, token: StateToken): string {
171
+ const result = getPreviousState(world, token);
172
+ if (result.ok) return result.value;
173
+ return '<error>';
174
+ }
175
+
176
+ // ─── Dispatch (test-injectable) ─────────────────────────────────────────────
177
+
178
+ export async function dispatch(opts: DispatchOptions): Promise<number> {
179
+ const { argv, stdoutWrite, stderrWrite, world } = opts;
180
+ // argv[0] = node, argv[1] = script path; subcommand starts at argv[2].
181
+ const args = argv.slice(2);
182
+ const subcommand = args[0];
183
+
184
+ if (subcommand === undefined) {
185
+ stderrWrite('forgeax: expected subcommand (list or get); use --help for usage');
186
+ return 1;
187
+ }
188
+
189
+ if (subcommand === '--help' || subcommand === '-h') {
190
+ stdoutWrite(helpBody());
191
+ return 0;
192
+ }
193
+
194
+ if (subcommand === 'list') {
195
+ const listArgs = args.slice(1);
196
+ if (listArgs[0] === '--help' || listArgs[0] === '-h') {
197
+ stdoutWrite(subcommandHelp('list'));
198
+ return 0;
199
+ }
200
+ runList(world, stdoutWrite);
201
+ return 0;
202
+ }
203
+
204
+ if (subcommand === 'get') {
205
+ const getArgs = args.slice(1);
206
+ if (getArgs[0] === '--help' || getArgs[0] === '-h') {
207
+ stdoutWrite(subcommandHelp('get'));
208
+ return 0;
209
+ }
210
+ const tokenName = getArgs[0];
211
+ if (tokenName === undefined) {
212
+ stderrWrite(
213
+ [
214
+ 'forgeax: state get requires a <tokenName> positional argument',
215
+ ' expected: forgeax-engine-remote-state get <tokenName>',
216
+ " hint: use 'forgeax-engine-remote-state list' to see registered tokens",
217
+ ].join('\n'),
218
+ );
219
+ return 1;
220
+ }
221
+ return runGet(world, tokenName, stdoutWrite, stderrWrite);
222
+ }
223
+
224
+ stderrWrite(
225
+ [
226
+ `forgeax: unknown subcommand "${subcommand}"`,
227
+ ' expected: list or get',
228
+ " hint: run 'forgeax-engine-remote-state --help' for usage",
229
+ ].join('\n'),
230
+ );
231
+ return 1;
232
+ }
@@ -0,0 +1,60 @@
1
+ // @forgeax/engine-state -- run conditions (feat-20260618 M3 / w25)
2
+ //
3
+ // inState(token, variant) returns a (world: World) => boolean predicate
4
+ // suitable for SystemDescriptor.runIf. The predicate reads the state token's
5
+ // current value from the World resource store.
6
+ //
7
+ // Decision anchors:
8
+ // - requirements section "inState factory" -- single factory, no and/or/not
9
+ // combiners (OOS-7); returns (world) => boolean
10
+ // - research Finding 3 -- reuse stateResourceKey + hasResource + getResource<number>
11
+ // - plan-strategy D-8 -- runIf receives World, inState returns the predicate
12
+
13
+ import type { World } from '@forgeax/engine-ecs';
14
+ import type { StateToken } from './define-state';
15
+ import { stateResourceKey } from './resources';
16
+
17
+ /**
18
+ * Create a run-condition predicate that passes when the state machine
19
+ * identified by `token` is in the given `variant`.
20
+ *
21
+ * The predicate reads the current state index from the World resource store
22
+ * (keyed by {@link stateResourceKey}) and compares it against the variant's
23
+ * index in `token.nameToIdx`.
24
+ *
25
+ * If the state resource has not been inserted yet (state not activated), the
26
+ * predicate returns `false` -- the system is skipped silently.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const GameState = defineState('GameState', ['Menu', 'Playing', 'Paused'] as const);
31
+ *
32
+ * const S = defineSystem({
33
+ * name: 'gameplay',
34
+ * queries: [{ with: [Transform] }],
35
+ * runIf: inState(GameState, 'Playing'),
36
+ * fn: (world, queryResults, commands) => { ... },
37
+ * });
38
+ * ```
39
+ *
40
+ * @param token - The state token returned by {@link defineState}.
41
+ * @param variant - The variant name to check against (must be a member of token.variants).
42
+ * @returns A predicate `(world: World) => boolean` suitable for `SystemDescriptor.runIf`.
43
+ */
44
+ export function inState(token: StateToken, variant: string): (world: World) => boolean {
45
+ const key = stateResourceKey(token);
46
+ const expectedIdx = token.nameToIdx.get(variant);
47
+ // If the variant string is not in the token's vocabulary, the predicate
48
+ // always returns false -- the system will never run. This is a programmer
49
+ // error (typo in variant name) but we don't throw at definition time
50
+ // because the predicate is a closure evaluated each frame; a throw here
51
+ // would kill the entire schedule.
52
+ if (expectedIdx === undefined) {
53
+ return (_world: World) => false;
54
+ }
55
+ return (world: World) => {
56
+ if (!world.hasResource(key)) return false;
57
+ const currentIdx = world.getResource<number>(key);
58
+ return currentIdx === expectedIdx;
59
+ };
60
+ }
@@ -0,0 +1,158 @@
1
+ // @forgeax/engine-state -- defineState + StateToken SSOT (feat-20260616 M1 / m1w2)
2
+ //
3
+ // Module-level state registration: defineState(name, variants as const) returns a
4
+ // branded StateToken. The token holds the variants vocabulary (name <-> idx lookup)
5
+ // and is used both as a compile-time type witness and as a runtime key for Resource
6
+ // registration.
7
+ //
8
+ // Decision anchors:
9
+ // - plan-strategy D-1: variants vocabulary lives in StateToken, not in ECS types
10
+ // - plan-strategy D-4: defineState throws on programmer errors (duplicate name / empty variants)
11
+ // - plan-strategy sec 8.1: as const + readonly tuple for compile-time variant narrowing
12
+ // - defineState registers schemas at module level; active state plugins project them into Worlds
13
+
14
+ import { throwStateError } from './errors';
15
+
16
+ /**
17
+ * Opaque branded type for type-level state-machine tokens.
18
+ *
19
+ * Use {@link defineState} to create a token; never construct manually.
20
+ * The {@link __forgeaxState} brand prevents plain-object assignment and
21
+ * enables TypeScript narrowing of variant literal types.
22
+ *
23
+ * @typeParam Name - The string literal name of the state machine.
24
+ * @typeParam V - The union of variant string literals derived from the const tuple.
25
+ */
26
+ export interface StateToken<Name extends string = string, V extends string = string> {
27
+ /** Brand -- prevents structural compatibility with plain objects. */
28
+ readonly __forgeaxState: typeof FORGEAX_STATE_BRAND;
29
+ /** The user-supplied state-machine name. */
30
+ readonly name: Name;
31
+ /** The ordered, read-only variants tuple. */
32
+ readonly variants: readonly V[];
33
+ /** Fast lookup: variant string -> its zero-based index in `variants`. */
34
+ readonly nameToIdx: ReadonlyMap<V, number>;
35
+ /** Convenience: `variants[0]`, the default / initial state value. */
36
+ readonly defaultValue: V;
37
+ }
38
+
39
+ /**
40
+ * Brand symbol for {@link StateToken}. Declared (not runtime-initialised) so
41
+ * the token interface carries nominal identity without a runtime allocation.
42
+ */
43
+ declare const FORGEAX_STATE_BRAND: unique symbol;
44
+
45
+ /**
46
+ * Extract the variant union from a {@link StateToken}.
47
+ *
48
+ * ```ts
49
+ * const L = defineState('LevelId', ['menu', 'game'] as const);
50
+ * type LevelVariant = StateTokenVariant<typeof L>;
51
+ * // ^? 'menu' | 'game'
52
+ * ```
53
+ */
54
+ export type StateTokenVariant<T extends StateToken> =
55
+ T extends StateToken<infer _Name, infer V> ? V : never;
56
+
57
+ /**
58
+ * Extract the name literal from a {@link StateToken}.
59
+ */
60
+ export type StateTokenName<T extends StateToken> =
61
+ T extends StateToken<infer Name, infer _V> ? Name : never;
62
+
63
+ /**
64
+ * Global registry of all state tokens, keyed by token name.
65
+ *
66
+ * `defineState` writes here; active state plugins project it into Worlds;
67
+ * cli-state reflection also reads it.
68
+ */
69
+ const STATE_REGISTRY = new Map<string, StateToken>();
70
+ const STATE_DEFINED_LISTENERS = new Set<(token: StateToken) => void>();
71
+
72
+ /**
73
+ * Internal: get the read-only snapshot of all registered tokens.
74
+ * Exported for M2 registerStatesPlugin and M6 cli-state.
75
+ */
76
+ export function getRegisteredTokens(): ReadonlyMap<string, StateToken> {
77
+ return STATE_REGISTRY;
78
+ }
79
+
80
+ /** @internal Subscribe one active World adapter to future module-level tokens. */
81
+ export function onStateDefined(listener: (token: StateToken) => void): () => void {
82
+ STATE_DEFINED_LISTENERS.add(listener);
83
+ return () => STATE_DEFINED_LISTENERS.delete(listener);
84
+ }
85
+
86
+ /**
87
+ * Define a typed state machine.
88
+ *
89
+ * Must be called at module level. The `as const` assertion on the variants
90
+ * array enables TypeScript to infer the exact literal tuple type, giving
91
+ * compile-time narrowing on variant parameters (e.g. `setNextState(world,
92
+ * token, 'misspell')` is a type error).
93
+ *
94
+ * @param name - Unique state-machine identifier (e.g. `'LevelId'`).
95
+ * @param variants - Readonly tuple of variant string literals. Must be non-empty.
96
+ * @returns A branded {@link StateToken} for use with `setNextState`, `getState`, etc.
97
+ * @throws StateError if `name` is already registered or `variants` is empty.
98
+ *
99
+ * ```ts
100
+ * export const LevelId = defineState('LevelId', ['main-menu', 'tutorial', 'street-a'] as const);
101
+ * // LevelId.variants -> readonly ['main-menu', 'tutorial', 'street-a']
102
+ * // LevelId.nameToIdx.get('tutorial') -> 1
103
+ * // LevelId.defaultValue -> 'main-menu'
104
+ * ```
105
+ */
106
+ export function defineState<Name extends string, const Variants extends readonly string[]>(
107
+ name: Name,
108
+ variants: Variants,
109
+ ): StateToken<Name, Variants[number]> {
110
+ if (STATE_REGISTRY.has(name)) {
111
+ throwStateError(
112
+ 'state-already-defined',
113
+ 'Each StateToken name must be registered exactly once at module level',
114
+ `State "${name}" is already defined. Use the existing token.`,
115
+ { code: 'state-already-defined', name, firstDefinedAt: undefined },
116
+ );
117
+ }
118
+
119
+ if (variants.length === 0) {
120
+ throwStateError(
121
+ 'state-default-required',
122
+ 'defineState requires at least one variant (non-empty array)',
123
+ `State "${name}" was defined with an empty variants array. Provide at least one variant, e.g. defineState("${name}", ["default"] as const).`,
124
+ { code: 'state-default-required', name },
125
+ );
126
+ }
127
+
128
+ // Check for duplicate variants within the array
129
+ const seen = new Set<string>();
130
+ for (const v of variants) {
131
+ if (seen.has(v)) {
132
+ throwStateError(
133
+ 'state-default-required',
134
+ 'Variants must be unique within a state token',
135
+ `State "${name}" has duplicate variant "${v}". Each variant must appear exactly once.`,
136
+ { code: 'state-default-required', name },
137
+ );
138
+ }
139
+ seen.add(v);
140
+ }
141
+
142
+ const nameToIdx = new Map<Variants[number], number>();
143
+ for (let i = 0; i < variants.length; i++) {
144
+ nameToIdx.set(variants[i] as Variants[number], i);
145
+ }
146
+
147
+ const token = {
148
+ __forgeaxState: undefined as unknown as typeof FORGEAX_STATE_BRAND,
149
+ name,
150
+ variants,
151
+ nameToIdx,
152
+ defaultValue: variants[0] as Variants[number],
153
+ } as StateToken<Name, Variants[number]>;
154
+
155
+ STATE_REGISTRY.set(name, token as unknown as StateToken);
156
+ for (const listener of STATE_DEFINED_LISTENERS) listener(token as unknown as StateToken);
157
+ return token;
158
+ }