/** * A Variables asset holds one value per key for the whole run, whatever points at it; a state * machine declared `global` does the same for its current state. A behavior's own variables stay * what they were: properties of the object it is placed on. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand, SetMachineScopeCommand, } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { STATE, STATE_NAME_PARAM, VARIABLES_PARAM } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { PropertyBag } from '../../src/core/model/Properties'; import { graphEditor, makeBehavior, makeSpriteScene, makeVariables, place, type SceneFixture, startWorld, } from '../helpers'; /** * A behavior that adds one to `key` whenever it starts – the coin counter of the example game. * With `source` it counts on that Variables asset, without it on its own declaration. */ function makeCounter(fixture: SceneFixture, name: string, key: string, source = '') { const behavior = makeBehavior(fixture, name, source ? [] : [key]); const editor = graphEditor(fixture, behavior); const params: PropertyBag = source ? { [VARIABLES_PARAM]: source } : {}; const start = editor.addNode('OnStart'); const read = editor.variables([key], { params }); const add = editor.addNode('Add', { b: 1 }); const write = editor.variables([key], { write: true, params }); editor.wire([ [start, 'exec', write, 'exec'], [read, key, add, 'a'], [add, 'result', write, key], ]); return behavior; } /** Places one object per behavior and runs a frame, so every `On start` has fired. */ function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) { for (const behavior of behaviors) place(fixture, behavior.id, { x: 0, y: 0 }); const started = startWorld(fixture, { behaviors: true }); started.frame(); return started; } describe('variables of a behavior', () => { it('counts once per instance', () => { const f = makeSpriteScene(); const counter = makeCounter(f, 'Counter', 'score'); const started = runWith(f, [counter, counter]); const values = started.world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.score); expect(values).toEqual([1, 1]); }); }); describe('variables of a Variables asset', () => { it('counts once for the whole run', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Score', ['score']); const counter = makeCounter(f, 'Counter', 'score', shared.id); const started = runWith(f, [counter, counter]); expect(started.behaviors!.shared.variables[shared.id]!.score).toBe(2); // The value belongs to the run, so no placed object carries one of its own. for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined(); }); it('shares one value between two different behaviors', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Score', ['score']); const one = makeCounter(f, 'One', 'score', shared.id); const other = makeCounter(f, 'Other', 'score', shared.id); const started = runWith(f, [one, other]); expect(started.behaviors!.shared.variables[shared.id]!.score).toBe(2); }); /** The asset is the key, not the name – which is what two declaration places are worth. */ it('keeps two assets apart although they declare the same name', () => { const f = makeSpriteScene(); const players = makeVariables(f, 'Players', ['score']); const enemies = makeVariables(f, 'Enemies', ['score']); const player = makeCounter(f, 'Player', 'score', players.id); const enemy = makeCounter(f, 'Enemy', 'score', enemies.id); const started = runWith(f, [player, player, enemy]); expect(started.behaviors!.shared.variables[players.id]!.score).toBe(2); expect(started.behaviors!.shared.variables[enemies.id]!.score).toBe(1); }); it('starts at the declared value again on the next run', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Score', ['score']); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 7)); const counter = makeCounter(f, 'Counter', 'score', shared.id); place(f, counter.id, { x: 0, y: 0 }); const first = startWorld(f, { behaviors: true }); first.frame(); expect(first.behaviors!.shared.variables[shared.id]!.score).toBe(8); const second = startWorld(f, { behaviors: true }); second.frame(); expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(8); }); /** The declaration is in another asset, so the rename has to reach graphs it knows nothing about. */ it('carries a rename into every graph pointing at it', () => { const f = makeSpriteScene(); const shared = makeVariables(f, 'Score', ['score']); const counter = makeCounter(f, 'Counter', 'score', shared.id); const keys = () => counter.graph.nodes.filter((n) => n.ports).flatMap((n) => n.ports!.map((p) => p.key)); expect(keys()).toEqual(['score', 'score']); f.stack.execute(new RenameVariableCommand(shared.id, 'score', 'total')); expect(keys()).toEqual(['total', 'total']); f.stack.undo(); expect(keys()).toEqual(['score', 'score']); }); /** A node left pointing at a deleted asset must not fall back to the behavior it sits in. */ it('writes nowhere when the asset it names is gone', () => { const f = makeSpriteScene(); const counter = makeCounter(f, 'Counter', 'score', 'no-such-asset'); const started = runWith(f, [counter]); expect(started.world.stopRequested).toBe(false); for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined(); }); }); /** * Editing a declaration while the run is on. The run holds its values apart from the document, so * the two have to meet on purpose: an edit reaches the run, and the run reaches nothing else. */ describe('a declaration edited during the run', () => { /** Counts `score` up by one, with `lives` declared alongside it and left to the document. */ function counting() { const f = makeSpriteScene(); const shared = makeVariables(f, 'Score', ['score', 'lives']); const started = runWith(f, [makeCounter(f, 'Counter', 'score', shared.id)]); const bag = () => started.behaviors!.shared.variables[shared.id]!; expect(bag().score).toBe(1); return { f, shared, started, bag }; } it('reaches the running value', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); started.behaviors!.syncDeclarations(); expect(bag().lives).toBe(3); }); it('leaves a value the run has moved on', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); started.behaviors!.syncDeclarations(); // The document still declares score as 0, but the run counted it to 1 – and keeps counting. expect(bag().score).toBe(1); }); it('wins over the run for the very key it changes', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 10)); started.behaviors!.syncDeclarations(); expect(bag().score).toBe(10); }); it('brings a variable added during the run along', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new AddVariableCommand(shared.id, 'coins', 'number')); started.behaviors!.syncDeclarations(); expect(bag().coins).toBe(0); expect(bag().score).toBe(1); }); /** * A list of typed items is declared as a fresh copy each time, the `$struct` marks taken off * (#254). Equal but not the same object must still count as unchanged. */ it('leaves a list the run has filled when the declaration only reads the same again', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new AddVariableCommand(shared.id, 'bags', 'list')); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'bags', [{ Owner: 'Player', $struct: 'inv' }])); started.behaviors!.syncDeclarations(); expect(bag().bags).toEqual([{ Owner: 'Player' }]); bag().bags = [{ Owner: 'Player' }, { Owner: 'Test' }]; f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); started.behaviors!.syncDeclarations(); expect(bag().bags).toEqual([{ Owner: 'Player' }, { Owner: 'Test' }]); }); it('writes nothing back into the document', () => { const { f, shared, started, bag } = counting(); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); started.behaviors!.syncDeclarations(); // What the run counted stays in the run: the document still declares the value it started from. const asset = f.project.assets.find((a) => a.id === shared.id)!; expect(asset.props.score).toBe(0); expect(bag().score).toBe(1); }); }); describe('state machine scope', () => { /** A machine with one state, plus a behavior that enters it on start. */ function makeMachineAndSetter(fixture: SceneFixture, scope: 'local' | 'global') { const machine = new CreateAssetCommand('statemachine', 'Doors'); fixture.stack.execute(machine); const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); fixture.stack.execute(state); if (scope === 'global') fixture.stack.execute(new SetMachineScopeCommand(machine.asset.id, scope)); const behavior = makeBehavior(fixture, 'Opener'); const editor = graphEditor(fixture, behavior); const start = editor.addNode('OnStart'); const set = editor.addNode('SetState', { machine: machine.asset.id }); fixture.stack.execute( new ConnectCommand( behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: state.node.id }, ), ); return { machineId: machine.asset.id, stateId: state.node.id, behavior }; } /** * A second behavior that never sets the state, only branches on it: it writes `sawOpen = 1` when * the machine is already in Open. That is the whole difference a scope makes. */ function makeReader(fixture: SceneFixture, machineId: string, stateId: string) { const behavior = makeBehavior(fixture, 'Reader', ['sawOpen']); const editor = graphEditor(fixture, behavior); const start = editor.addNode('OnStart'); const branch = editor.addNode('StateBranch', { machine: machineId }); const one = editor.addNode('Add', { a: 1, b: 0 }); const write = editor.variables(['sawOpen'], { write: true }); editor.wire([ [start, 'exec', branch, 'exec'], [branch, stateId, write, 'exec'], [one, 'result', write, 'sawOpen'], ]); return behavior; } it('hides the state from another instance when local', () => { const f = makeSpriteScene(); const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'local'); const reader = makeReader(f, machineId, stateId); const started = runWith(f, [behavior, reader]); expect(started.behaviors!.shared.states[machineId]).toBeUndefined(); const seen = started.world.entities.find((e) => e.assetId === reader.id)!; expect(seen.props.sawOpen).toBe(0); }); it('shows the state to another instance when global', () => { const f = makeSpriteScene(); const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'global'); const reader = makeReader(f, machineId, stateId); const started = runWith(f, [behavior, reader]); expect(started.behaviors!.shared.states[machineId]).toBe(stateId); const seen = started.world.entities.find((e) => e.assetId === reader.id)!; expect(seen.props.sawOpen).toBe(1); }); });