Files
feuerwerk/tests/core/scene-scope.test.ts
adminandClaude Opus 5.5 d7c0b7cfdb
game-maker / gate (push) Successful in 2m43s
game-maker / suite (push) Successful in 8m40s
Variables and state machines can reset with every scene (#164)
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>
2026-09-23 06:06:53 +02:00

209 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});