@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.
- package/LICENSE +202 -0
- package/README.md +87 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli-state.d.ts +10 -0
- package/dist/cli-state.d.ts.map +1 -0
- package/dist/cli-state.mjs +275 -0
- package/dist/cli-state.mjs.map +1 -0
- package/dist/conditions.d.ts +31 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/define-state.d.ts +71 -0
- package/dist/define-state.d.ts.map +1 -0
- package/dist/errors.d.ts +70 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +406 -0
- package/dist/index.mjs.map +1 -0
- package/dist/on-enter-on-exit.d.ts +65 -0
- package/dist/on-enter-on-exit.d.ts.map +1 -0
- package/dist/plugin-factory.d.ts +9 -0
- package/dist/plugin-factory.d.ts.map +1 -0
- package/dist/register-plugin.d.ts +27 -0
- package/dist/register-plugin.d.ts.map +1 -0
- package/dist/resources.d.ts +23 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/scoped-component.d.ts +38 -0
- package/dist/scoped-component.d.ts.map +1 -0
- package/dist/set-next-state.d.ts +48 -0
- package/dist/set-next-state.d.ts.map +1 -0
- package/dist/transition-system.d.ts +3 -0
- package/dist/transition-system.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/cli-state.ts +232 -0
- package/src/conditions.ts +60 -0
- package/src/define-state.ts +158 -0
- package/src/errors.ts +147 -0
- package/src/index.ts +42 -0
- package/src/on-enter-on-exit.ts +156 -0
- package/src/plugin-factory.ts +19 -0
- package/src/register-plugin.ts +114 -0
- package/src/resources.ts +45 -0
- package/src/scoped-component.ts +159 -0
- package/src/set-next-state.ts +105 -0
- 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 @@
|
|
|
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
|
+
}
|
package/src/cli-state.ts
ADDED
|
@@ -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
|
+
}
|