@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
@@ -0,0 +1,101 @@
1
+ // @forgeax/engine-state -- setNextState / getState / getPreviousState (M2 / m2w4)
2
+ //
3
+ // Free functions that read and write per-token Resource slots. All return
4
+ // Result<T, StateError> (never throw for AI-user call sites per AGENTS.md
5
+ // Error model).
6
+ //
7
+ // Decision anchors:
8
+ // - requirements C-3: setNextState returns Result.err, not throw
9
+ // - requirements C-4: free functions, not world.x methods
10
+ // - requirements F-5/F-6: State / NextState / PreviousState as Resources
11
+ // - plan-strategy D-4: State stores variant index (u32), decoded via token.variants
12
+
13
+ import type { World } from '@forgeax/engine-ecs';
14
+ import { err, ok, type Result } from '@forgeax/engine-types';
15
+ import type { StateToken, StateTokenVariant } from './define-state';
16
+ import type { StateError } from './errors';
17
+ import { invalidVariant, stateNotRegistered } from './errors';
18
+ import { nextStateResourceKey, previousStateResourceKey, stateResourceKey } from './resources';
19
+
20
+ interface NextStatePayload {
21
+ value: number;
22
+ force: boolean;
23
+ }
24
+
25
+ /**
26
+ * Request a state transition for `token` to `variant` at the next frame.
27
+ *
28
+ * `variant` is narrowed to the token's variant union: a misspelled variant is
29
+ * a compile-time error (`StateTokenVariant<T>`), not just a runtime
30
+ * `invalid-variant` Result.
31
+ */
32
+ export function setNextState<T extends StateToken>(
33
+ world: World,
34
+ token: T,
35
+ variant: StateTokenVariant<T>,
36
+ ): Result<void, StateError> {
37
+ return _runCheckAndWrite(world, token, variant, false);
38
+ }
39
+
40
+ /**
41
+ * Like {@link setNextState} but with `force=true`.
42
+ */
43
+ export function setNextStateForce<T extends StateToken>(
44
+ world: World,
45
+ token: T,
46
+ variant: StateTokenVariant<T>,
47
+ ): Result<void, StateError> {
48
+ return _runCheckAndWrite(world, token, variant, true);
49
+ }
50
+
51
+ function _runCheckAndWrite(
52
+ world: World,
53
+ token: StateToken,
54
+ variant: string,
55
+ force: boolean,
56
+ ): Result<void, StateError> {
57
+ const nsKey = nextStateResourceKey(token);
58
+ if (!world.hasResource(nsKey)) {
59
+ return err(stateNotRegistered(token.name));
60
+ }
61
+
62
+ const idx = token.nameToIdx.get(variant as never);
63
+ if (idx === undefined) {
64
+ return err(invalidVariant(token.name, variant, token.variants));
65
+ }
66
+
67
+ world.insertResource<NextStatePayload>(nsKey, { value: idx, force });
68
+ return ok(undefined);
69
+ }
70
+
71
+ /**
72
+ * Read the current state value for `token`.
73
+ */
74
+ export function getState(world: World, token: StateToken): Result<string, StateError> {
75
+ const key = stateResourceKey(token);
76
+ if (!world.hasResource(key)) {
77
+ return err(stateNotRegistered(token.name));
78
+ }
79
+ const idx = world.getResource<number>(key);
80
+ const variant = token.variants[idx];
81
+ if (variant === undefined) {
82
+ return err(invalidVariant(token.name, String(idx), token.variants));
83
+ }
84
+ return ok(variant);
85
+ }
86
+
87
+ /**
88
+ * Read the previous-frame state value for `token`.
89
+ */
90
+ export function getPreviousState(world: World, token: StateToken): Result<string, StateError> {
91
+ const key = previousStateResourceKey(token);
92
+ if (!world.hasResource(key)) {
93
+ return err(stateNotRegistered(token.name));
94
+ }
95
+ const idx = world.getResource<number>(key);
96
+ const variant = token.variants[idx];
97
+ if (variant === undefined) {
98
+ return err(invalidVariant(token.name, String(idx), token.variants));
99
+ }
100
+ return ok(variant);
101
+ }
@@ -0,0 +1,153 @@
1
+ // @forgeax/engine-state -- transitionStatesSystem (M3 / m3w4)
2
+ //
3
+ // 8-step per-token transition logic executed every frame by the
4
+ // 'transitionStates' system registered in registerStatesPlugin.
5
+ //
6
+ // Per token:
7
+ // 1. Read NextState Resource; if undefined -> continue (zero-cost skip)
8
+ // 2. Read State Resource; if prev===next && !force -> clear NextState, continue (same-state no-op)
9
+ // 3. Write PreviousState = prev, flip State = next
10
+ // 4. Collect exit-scoped entities -> world.despawn each
11
+ // 5. OnExit placeholder (M4)
12
+ // 6. Collect enter-scoped entities -> world.despawn each
13
+ // 7. OnEnter placeholder (M4)
14
+ // 8. Clear NextState = undefined
15
+ //
16
+ // Decision anchors:
17
+ // - plan-strategy sec 3.2: 8-step flowchart + OnEnter/OnExit dispatch between flip and despawn
18
+ // - plan-strategy D-2: unified world.despawn via linkedSpawn cascade
19
+ // - plan-strategy D-5: fn[] registry + transition body dispatch, zero ECS change
20
+ // - research F-6: row iteration + world.despawn is sufficient
21
+ // - requirements sec 7: despawn tolerance (entity already dead = no error)
22
+
23
+ import type { Component, EntityHandle, World } from '@forgeax/engine-ecs';
24
+ import { worldDespawnScene } from '@forgeax/engine-scene';
25
+ import { getRegisteredTokens } from './define-state';
26
+ import { getCallbacks, OnEnter, OnExit } from './on-enter-on-exit';
27
+ import { nextStateResourceKey, previousStateResourceKey, stateResourceKey } from './resources';
28
+ import { SCOPED_MODE_VALUE } from './scoped-component';
29
+
30
+ interface NextStatePayload {
31
+ value: number;
32
+ force: boolean;
33
+ }
34
+
35
+ /**
36
+ * Collect entities whose ScopedTo component matches a given mode and value,
37
+ * then despawn all of them. Single despawn fault (already-dead entity) does
38
+ * not abort the batch — despawn tolerance per requirements sec 7.
39
+ *
40
+ * A scoped entity that is a SceneInstance root is torn down with `despawnScene`
41
+ * (cascade over its instantiated members), NOT plain `world.despawn`. Plain
42
+ * despawn does not cascade through `ChildOf` (which ships `linkedSpawn=false`),
43
+ * so a scoped scene root would orphan every member entity it instantiated. On a
44
+ * state replay (e.g. Title->Play->Title->Play) those orphans linger, their index
45
+ * slots are reused at a new generation, and a surviving member's stale
46
+ * `ChildOf -> (oldRoot, oldGen)` makes `propagateTransforms` throw
47
+ * `hierarchy-broken` every frame. Cascading via `despawnScene` removes the whole
48
+ * instantiated subtree so nothing is left pointing at the dead root.
49
+ */
50
+ function scopeDespawn(world: World, scopedComponent: Component, mode: number, value: number): void {
51
+ const query = world.query({ read: [scopedComponent] }).unwrap();
52
+ const despawns: EntityHandle[] = [];
53
+ for (const row of query) {
54
+ const scoped = row.get(scopedComponent);
55
+ if (scoped.mode === mode && scoped.value === value) despawns.push(row.entity);
56
+ }
57
+ // SceneInstance is resolved by name through the global registry so the state
58
+ // package stays free of a runtime dependency (layering: state -> ecs only).
59
+ const sceneInstance = world.components.resolve('SceneInstance');
60
+ // Tear down SceneInstance roots FIRST, via despawnScene (cascade over their
61
+ // instantiated members). This must precede the plain despawns: a scoped scene
62
+ // root is often ChildOf a scoped non-scene entity (e.g. a character rig parented
63
+ // under a KCC body), and despawning that parent first invalidates the root
64
+ // handle before we can cascade it -- leaving the scene's members orphaned with a
65
+ // stale ChildOf -> dead-root ref. Doing the cascades up front guarantees each
66
+ // SceneInstance subtree is fully removed while its root is still live.
67
+ if (sceneInstance !== undefined) {
68
+ for (const e of despawns) {
69
+ if (world.get(e, sceneInstance).ok) worldDespawnScene(world, e);
70
+ }
71
+ }
72
+ for (const e of despawns) {
73
+ // Scene roots already torn down above are now dead -> world.despawn is a
74
+ // tolerated no-op (requirements sec 7); every other scoped entity despawns here.
75
+ world.despawn(e);
76
+ }
77
+ }
78
+
79
+ export function transitionStatesSystem(world: World): void {
80
+ for (const token of getRegisteredTokens().values()) {
81
+ const nsKey = nextStateResourceKey(token);
82
+
83
+ // (0) Skip tokens with no Resources yet. getRegisteredTokens() returns every
84
+ // token ever defined, but registerStatesPlugin only inserts Resources for
85
+ // tokens known at plugin time. A token defined after the plugin ran has no
86
+ // NextState Resource; world.getResource would throw ResourceNotFoundError.
87
+ // hasResource guard mirrors setNextState / getState in this package.
88
+ if (!world.hasResource(nsKey)) continue;
89
+ const ns = world.getResource<NextStatePayload | undefined>(nsKey);
90
+
91
+ // (1) No pending transition — zero-cost continue
92
+ if (ns === undefined) continue;
93
+
94
+ const sKey = stateResourceKey(token);
95
+ const prevIdx = world.getResource<number>(sKey);
96
+ const nextIdx = ns.value;
97
+ const force = ns.force;
98
+
99
+ // (2) Same-state no-op (unless force flag overrides)
100
+ if (prevIdx === nextIdx && !force) {
101
+ world.insertResource<NextStatePayload | undefined>(nsKey, undefined);
102
+ continue;
103
+ }
104
+
105
+ // (3) Write PreviousState = prev, flip State = next
106
+ const psKey = previousStateResourceKey(token);
107
+ world.insertResource(psKey, prevIdx);
108
+ world.insertResource(sKey, nextIdx);
109
+
110
+ // Resolve the per-token ScopedTo component from the global ECS registry
111
+ const scopedComponent = world.components.resolve(`__scopedTo__${token.name}`);
112
+ if (scopedComponent) {
113
+ // (4) Despawn exit-scoped entities (value=prev)
114
+ scopeDespawn(world, scopedComponent, SCOPED_MODE_VALUE.exit, prevIdx);
115
+
116
+ // (5) OnExit dispatch: fire registered callbacks for prev variant.
117
+ // Errors bubble to the transitionStatesSystem call stack per req §7.
118
+ const prevVariant = token.variants[prevIdx];
119
+ if (prevVariant !== undefined) {
120
+ const exitLabel = OnExit(token, prevVariant);
121
+ for (const fn of getCallbacks(exitLabel)) {
122
+ fn(world);
123
+ }
124
+ }
125
+
126
+ // (6) Despawn enter-scoped entities (value=next)
127
+ scopeDespawn(world, scopedComponent, SCOPED_MODE_VALUE.enter, nextIdx);
128
+
129
+ // (7) OnEnter dispatch: fire registered callbacks for next variant.
130
+ // Errors bubble to the transitionStatesSystem call stack per req §7.
131
+ const nextVariant = token.variants[nextIdx];
132
+ if (nextVariant !== undefined) {
133
+ const enterLabel = OnEnter(token, nextVariant);
134
+ for (const fn of getCallbacks(enterLabel)) {
135
+ fn(world);
136
+ }
137
+ }
138
+ }
139
+
140
+ // (8) Clear NextState — but only if OnEnter callbacks did not already
141
+ // write a new NextState payload (e.g. nested setNextState). If the
142
+ // payload differs from the original `ns`, leave it for the next frame.
143
+ const nsAfterCallbacks = world.getResource<NextStatePayload | undefined>(nsKey);
144
+ if (
145
+ nsAfterCallbacks !== undefined &&
146
+ nsAfterCallbacks.value === ns.value &&
147
+ nsAfterCallbacks.force === ns.force
148
+ ) {
149
+ world.insertResource<NextStatePayload | undefined>(nsKey, undefined);
150
+ }
151
+ // else: callbacks wrote a new NextState — survive for next frame
152
+ }
153
+ }