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

+208
View File
@@ -0,0 +1,208 @@
/**
* The third scope (#164). `global` belongs to the run and survives `Load scene`; `scene` belongs to
* the scene and is built again from the declarations with every swap. What a level counts is gone
* with the level, while the score beside it is not.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
SetAssetPropertyCommand,
SetMachineScopeCommand,
SetVariablesPersistentCommand,
SetVariablesScopeCommand,
} from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { isPersistent, STATE, STATE_NAME_PARAM } from '../../src/core/graph';
import type { StateMachineAsset, VariablesAsset } from '../../src/core/model/Asset';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
roundTrip,
type SceneFixture,
} from '../helpers';
/** The sprite scene plus a second scene to swap to. */
function twoScenes(): SceneFixture & { otherId: string } {
const f = makeSpriteScene('Box');
const second = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(second);
return { ...f, otherId: second.asset.id };
}
/** A machine with one state, `start` set to it, in the given scope. */
function makeMachine(f: SceneFixture, name: string, scope: 'local' | 'global' | 'scene') {
const created = new CreateAssetCommand('statemachine', name);
f.stack.execute(created);
const state = new AddNodeCommand(created.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Idle' });
f.stack.execute(state);
(created.asset as StateMachineAsset).start = state.node.id;
if (scope !== 'local') f.stack.execute(new SetMachineScopeCommand(created.asset.id, scope));
return { id: created.asset.id, stateId: state.node.id };
}
/** Runs the first scene, moves the shared values on, then swaps to the second scene of the same run. */
function swap(
f: SceneFixture & { otherId: string },
move: (system: ReturnType<typeof addGameSystems>) => void,
) {
const input = new Input();
const first = World.instantiate(f.project, f.sceneId);
const before = addGameSystems(first, f.project, input);
move(before);
const second = World.instantiate(f.project, f.otherId);
return addGameSystems(second, f.project, input, { carried: before.shared });
}
/**
* A behavior placed in the second scene that writes `run`/`level` when the machine of that name
* enters its state. What it wrote is the only evidence an entry was announced at all.
*/
function watching(
f: SceneFixture & { otherId: string },
forRun: { id: string; stateId: string },
forLevel: { id: string; stateId: string },
) {
const behavior = makeBehavior(f, 'Watcher', ['run', 'level']);
const editor = graphEditor(f, behavior);
for (const [machine, key] of [
[forRun, 'run'],
[forLevel, 'level'],
] as const) {
const entered = editor.addNode('OnStateEntered', { machine: machine.id });
const one = editor.addNode('Add', { a: 1, b: 0 });
const write = editor.variables([key], { write: true });
editor.wire([
[entered, machine.stateId, write, 'exec'],
[one, 'result', write, key],
]);
}
f.stack.execute(new PlaceAssetCommand(f.otherId, behavior.id, { x: 0, y: 0 }));
return behavior;
}
describe('a Variables asset scoped to the scene', () => {
it('starts over on a scene swap while a global one carries on', () => {
const f = twoScenes();
const run = makeVariables(f, 'Run', ['score']);
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
f.stack.execute(new SetAssetPropertyCommand(level.id, 'enemies', 5));
const after = swap(f, (before) => {
before.liveVariables(run.id)!.score = 7;
before.liveVariables(level.id)!.enemies = 1;
});
expect(after.liveVariables(run.id)?.score).toBe(7);
expect(after.liveVariables(level.id)?.enemies).toBe(5);
});
it('is built from the declarations at the start of a run, like every other one', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
f.stack.execute(new SetAssetPropertyCommand(level.id, 'enemies', 3));
const world = World.instantiate(f.project, f.sceneId);
const system = addGameSystems(world, f.project, new Input());
expect(system.liveVariables(level.id)?.enemies).toBe(3);
});
it('is never persistent, and switching to it takes the switch off', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesPersistentCommand(level.id, true));
expect(isPersistent(level)).toBe(true);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
expect(level.persistent).toBeUndefined();
expect(isPersistent(level)).toBe(false);
f.stack.undo();
expect(level.scope).toBeUndefined();
expect(isPersistent(level)).toBe(true);
});
/** A flag left over from an older file must not outvote the scope it contradicts. */
it('reads as not persistent even when the document says both', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
level.persistent = true;
level.scope = 'scene';
expect(isPersistent(level)).toBe(false);
expect(
(roundTrip(f.project).assets.find((a) => a.id === level.id) as VariablesAsset).persistent,
).toBeUndefined();
});
it('survives a round trip, and an unknown word reads as global', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
const loaded = () => roundTrip(f.project).assets.find((a) => a.id === level.id) as VariablesAsset;
expect(loaded().scope).toBe('scene');
level.scope = 'nonsense' as never;
expect(loaded().scope).toBeUndefined();
});
});
describe('a state machine scoped to the scene', () => {
it('starts over on a scene swap while a global one keeps its state', () => {
const f = twoScenes();
const forRun = makeMachine(f, 'Run', 'global');
const forLevel = makeMachine(f, 'Level', 'scene');
const after = swap(f, (before) => {
before.shared.states[forRun.id] = 'moved';
before.shared.states[forLevel.id] = 'moved';
});
expect(after.shared.states[forRun.id]).toBe('moved');
expect(after.shared.states[forLevel.id]).toBe(forLevel.stateId);
});
it('is shared between instances, the way a global one is', () => {
const f = twoScenes();
const machine = makeMachine(f, 'Level', 'scene');
const world = World.instantiate(f.project, f.sceneId);
const system = addGameSystems(world, f.project, new Input());
expect(system.shared.states[machine.id]).toBe(machine.stateId);
});
it('survives a round trip', () => {
const f = twoScenes();
const machine = makeMachine(f, 'Level', 'scene');
const loaded = roundTrip(f.project).assets.find((a) => a.id === machine.id) as StateMachineAsset;
expect(loaded.scope).toBe('scene');
});
/**
* The entry of a shared machine is announced once, before any instance exists. After a swap a
* `global` machine stands where it stood, so announcing it again would be a move that never
* happened – a `scene` machine really was entered again, and has to say so.
*/
it('announces its entry again in the next scene, where a global one stays quiet', () => {
const f = twoScenes();
const forRun = makeMachine(f, 'Run', 'global');
const forLevel = makeMachine(f, 'Level', 'scene');
const watcher = watching(f, forRun, forLevel);
const input = new Input();
const first = World.instantiate(f.project, f.sceneId);
const before = addGameSystems(first, f.project, input);
first.tick(1 / 60);
const second = World.instantiate(f.project, f.otherId);
addGameSystems(second, f.project, input, { carried: before.shared });
second.tick(1 / 60);
const heard = second.entities.find((e) => e.assetId === watcher.id)!;
expect(heard.props.level).toBe(1);
expect(heard.props.run).toBe(0);
});
});