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

+35
View File
@@ -244,6 +244,41 @@ export async function scope(t) {
JSON.stringify({ play: menuCleared }),
);
// 32h. The third scope (#164): the switch beside Persistent, what it offers, and that the two
// cannot both be on – `scene` throws the values away with every scene, so there is nothing
// left to keep between runs and the Persistent switch is not even shown.
await selectAsset('sc-vars');
await sleep(200);
const scopeRow = await json(`__gm.inspectorField('Scope')`);
await click(scopeRow.x, scopeRow.y);
const scopes = await json('__gm.menuRows()');
await clickLast('Scene');
await sleep(200);
const scoped = await asset('sc-vars');
const sceneNote = await has('every scene that is loaded.');
check(
'a Variables asset offers Global and Scene, and taking Scene says what it means',
JSON.stringify(scopes) === JSON.stringify(['Global', 'Scene']) && scoped.scope === 'scene' && sceneNote,
JSON.stringify({ scopes, scope: scoped.scope, note: sceneNote }),
);
check(
'a scene-scoped asset is not persistent any more, and cannot be switched to it',
scoped.persistent === undefined && (await json(`__gm.inspectorField('Persistent')`)) === null,
JSON.stringify({ persistent: scoped.persistent, row: await json(`__gm.inspectorField('Persistent')`) }),
);
await shot('05-scene-scope');
const backRow = await json(`__gm.inspectorField('Scope')`);
await click(backRow.x, backRow.y);
await clickLast('Global');
await sleep(200);
const back = await asset('sc-vars');
check(
'switching back to Global takes the scope out of the document and brings the switch back',
back.scope === undefined && Boolean(await json(`__gm.inspectorField('Persistent')`)),
JSON.stringify({ scope: back.scope, persistent: back.persistent }),
);
// 32g. Everything this step added goes again.
await mutate(`
const p = __gm.state.project;
+33 -3
View File
@@ -10,17 +10,27 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs';
* *run* carries on. A Variables asset is the witness, because a fresh run would put it back to
* what it declares. The sounds of the old scene end with it – not by a rule of their own, but
* because the behaviors playing them are destroyed like everything else in that scene.
*
* A second Variables asset scoped to the scene is the other half of that witness (#164): the two
* count the same way in the same graph, and only one of them is still counting after the swap.
*/
const SOURCE = { variablesAsset: 'ls-vars' };
const PER_SCENE = { variablesAsset: 'ls-level' };
/** `On start → Variables{set score = score + 1}`, plus `On key down g → Load scene`. */
/**
* `On start → Variables{set score = score + 1} → the same for the per-scene count`, plus
* `On key down g → Load scene`.
*/
const graphFor = (sceneId, soundId) => ({
nodes: [
node('ls-start', 'OnStart', 0, 0),
node('ls-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]),
node('ls-add', 'Add', 260, 200, { b: 1 }),
node('ls-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]),
node('ls-read2', 'Variables', 0, 640, PER_SCENE, [port('count', 'number')]),
node('ls-add2', 'Add', 260, 640, { b: 1 }),
node('ls-write2', 'Variables', 560, 640, PER_SCENE, [written('count', 'number')]),
node('ls-play', 'PlaySound', 820, 0, { sound: soundId, volume: 1, loop: 'true' }),
node('ls-key', 'OnKeyDown', 0, 420, { key: 'g' }),
node('ls-load', 'LoadScene', 260, 420, { scene: sceneId }),
@@ -29,7 +39,10 @@ const graphFor = (sceneId, soundId) => ({
edge('ls-e1', 'ls-start', 'exec', 'ls-write', 'exec'),
edge('ls-e2', 'ls-read', 'score', 'ls-add', 'a'),
edge('ls-e3', 'ls-add', 'result', 'ls-write', 'score'),
edge('ls-e4', 'ls-write', 'exec', 'ls-play', 'exec'),
edge('ls-e4', 'ls-write', 'exec', 'ls-write2', 'exec'),
edge('ls-e6', 'ls-read2', 'count', 'ls-add2', 'a'),
edge('ls-e7', 'ls-add2', 'result', 'ls-write2', 'count'),
edge('ls-e8', 'ls-write2', 'exec', 'ls-play', 'exec'),
edge('ls-e5', 'ls-key', 'exec', 'ls-load', 'exec'),
],
});
@@ -116,6 +129,13 @@ export async function loadScene(t) {
name: 'Run',
props: { score: 0 },
variables: ${JSON.stringify([variable('score', 'number', 0)])},
}, {
id: 'ls-level',
type: 'variables',
name: 'Level',
scope: 'scene',
props: { count: 0 },
variables: ${JSON.stringify([variable('count', 'number', 0)])},
}, {
id: 'ls-sound',
type: 'sound',
@@ -212,6 +232,16 @@ export async function loadScene(t) {
world.shared?.variables?.['ls-vars']?.score === 1,
JSON.stringify(world.shared?.variables),
);
// 35e². The other scope, counted by the same graph in the same frame (#164). The loader stayed
// behind with its scene, so nobody counts in the new one – and the value is the declared 0
// rather than the 1 the score right beside it still carries.
check(
'what belongs to the scene starts over: the per-scene count does not carry',
(before.shared?.variables?.['ls-level']?.count ?? 0) === 1 &&
world.shared?.variables?.['ls-level']?.count === 0 &&
world.shared?.variables?.['ls-vars']?.score === 1,
JSON.stringify({ before: before.shared?.variables, after: world.shared?.variables }),
);
check(
'the sounds of the old scene end with the behaviors that were playing them',
humming.playing >= 1 && (await stats()).playing === 0,
@@ -245,7 +275,7 @@ export async function loadScene(t) {
await mutate(`
const p = __gm.state.project;
p.startSceneId = '';
const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']);
const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-level', 'ls-sound']);
p.assets = p.assets.filter((a) => !gone.has(a.id));
__gm.state.select(null);`);
check(