Variables and state machines can reset with every scene (#164)
game-maker / gate (push) Successful in 2m43s
game-maker / suite (push) Successful in 8m40s

A shared variable or machine used to live either per object or for the
whole run. Levels need a third lifetime: shared like global, but built
fresh from the declarations whenever a scene is loaded.

- New scope `scene` for Variables assets and state machines. A missing
  field keeps the old meaning: `global` for assets, `local` for
  machines, so existing projects do not change.
- Returning to a scene that was visited before is fresh as well. There
  is no memory per scene.
- A `scene` machine announces its start state again after a scene swap,
  so `On state entered` in the new scene hears it. A global machine
  stays quiet because it never left its state.
- The rule lives in two pure functions in `sharedState.ts`
  (`carriedForward`, `restartedAssets`). BehaviorSystem only calls them.
- The inspector shows a Scope dropdown on Variables assets and a third
  step on the machine dropdown, with a note on what `scene` means.

Decisions:
- `scene` and `persistent` exclude each other (variant A). The choice
  dialog was cancelled, so this is an assumption to revisit. A
  scene-scoped asset hides the Persistent switch, and switching to
  `scene` drops the flag (undo restores it). The guard sits in
  `isPersistent` itself, so no path can save values the scope throws
  away anyway.

Findings:
- `clickText('Scene')` hits the Scene tab, not the menu entry. One e2e
  check was red because of that, and its partner was falsely green
  because it compared `undefined` with `undefined`. Both use `clickLast`
  now.

Tests:
- ./check.sh: 144 files, 2142 tests, CHECK EXIT 0 (before 143 / 2133)
- 9 new unit tests in tests/core/scene-scope.test.ts
- e2e 32 fresh: ALL PASSED, 17 checks
- e2e 1-3,17,18,26,32,35 fresh: ALL PASSED, 213 checks
- 3 new e2e checks (35: scene-scoped asset resets on swap, 32h: dropdown,
  note, hidden Persistent switch)
- Counter-checks bite: ignoring `restarted` fails 2 unit tests and the
  new check in 35, the old announcement rule fails 1, dropping the scope
  guard in `isPersistent` fails 1. Originals restored, cmp IDENTICAL.
- Check sum 1307 (1304 + 3) is computed. Last CI measurement: 1293.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 06:06:53 +02:00
1 parent 6e3e8d23c5
commit d7c0b7cfdb
12 files changed
+487 -43

No files matched your search

+32 -3
View File
@@ -1,6 +1,6 @@
import type { PropertyBag } from '../model/Properties';
import type { Project } from '../model/Project';
import { isGlobal } from '../model/Scope';
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
import type { NodeContext, SharedState } from './NodeContext';
import { startStateOf } from './stateMachine';
@@ -27,11 +27,40 @@ export function declaredVariables(project: Project): Record<string, PropertyBag>
export function buildSharedState(project: Project): SharedState {
const shared: SharedState = { variables: declaredVariables(project), states: {} };
for (const asset of project.assets)
if (asset.type === 'statemachine' && isGlobal(asset))
if (asset.type === 'statemachine' && isShared(asset))
shared.states[asset.id] = startStateOf(asset.graph, asset.start);
return shared;
}
/**
* The state the next scene of the same run starts from: what is `global` travels as it stands,
* what is `scene` is built again from the declarations. Fresh bags rather than the carried ones,
* so the world before it cannot still be writing into what this one reads.
*/
export function carriedForward(project: Project, carried: Readonly<SharedState>): SharedState {
const fresh = buildSharedState(project);
const restarted = restartedAssets(project);
const next: SharedState = { variables: {}, states: {} };
for (const [id, bag] of Object.entries(fresh.variables))
next.variables[id] = restarted.has(id) ? bag : (carried.variables[id] ?? bag);
for (const [id, state] of Object.entries(fresh.states))
next.states[id] = restarted.has(id) ? state : (carried.states[id] ?? state);
return next;
}
/**
* The assets a scene swap starts over rather than hands on, by id: the `scene` Variables assets and
* the `scene` machines. Also what tells the new world which machines to announce an entry for.
*/
export function restartedAssets(project: Project): Set<string> {
const ids = new Set<string>();
for (const asset of project.assets) {
if (asset.type === 'variables' && sharedScopeOf(asset) === 'scene') ids.add(asset.id);
if (asset.type === 'statemachine' && isPerScene(asset)) ids.add(asset.id);
}
return ids;
}
/**
* The start state of every machine that has a usable one, keyed by asset id. A machine without one
* is absent, which is what keeps "no start" and "starts in a deleted state" the same thing.
@@ -80,7 +109,7 @@ export function variableBag(ctx: NodeContext, source: string): PropertyBag {
return ctx.shared.variables[source] ?? {};
}
/** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */
/** Where a machine's current state is kept: shared unless the machine is `local`, else per instance. */
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
}