/** * 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) => 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); }); });