A typed global list lost the items the game had added to it. The item showed up in the inspector for a moment and then was gone again. - Since #254 the project hands out a typed list as a fresh copy each time, with the `$struct` marks stripped. - While a game runs, every change to the project runs `adoptDeclarations`. It took a declared value over when it differed from the last one it had seen, compared with `!==`. - The fresh copy never equals the last one by identity. So the empty list from the project overwrote the live list on every project change, for example when the game writes something back to the scene. - The comparison now uses `sameValue`. A real edit to the variable during a run still reaches the game. Tests: - ./check.sh: CHECK EXIT 0 - New unit test in scope.test.ts, red without the fix. - e2e 55i: a graph appends to a typed global list, then another asset changes during the run. With the fix {"filled":2,"afterEdit":2}, without it afterEdit is 1 and the check fails. - GM_STEPS=1,55: ALL PASSED, 26 checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|