A variable or a state machine declared `global` keeps one value for the whole run instead of one per behavior instance. The scope sits on the declaration -- `PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence meaning `local` -- and `local` is never written, so every project made before this saves back unchanged. **The name is the key.** Two behaviors declaring a global `score` mean one variable; a behavior declaring `score` locally keeps its own. Kinds that disagree about one global name stop the run before it starts, with the two behaviors named; starting values that disagree are a warning on the behavior that declares it, and the first declaration wins (`globalConflicts`). The runtime needs no redirection layer. A global variable falls out of `schemaForObject`, so it never reaches `entity.props` -- and then the whole rule is "whoever declares the name takes their own value" (`graph/sharedState.ts`). The `Variables` node is untouched and stays transparent: switching a variable to global breaks no existing graph, because `GraphInterpreter.readAll` builds a fresh object per read anyway and only asks `port.key in all`. That same cut answers the inspector. A shared value has no owner, so the placed object shows no row for it and can hold no override -- `SetVariableScopeCommand` takes the deviations that were there and undo gives them back. Both places say it in words rather than leaving a glyph to be guessed: the declaration explains the mark below the list, and the placed object names the variable it no longer holds instead of claiming the behavior declares none -- which is what it said before, untruthfully. Screenshots of the e2e run turned that up; no check had been looking at it. `SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from the *declarations* rather than from any instance (a global variable exists before its behavior is placed), and is thrown away with the run: nothing is written back, not even in the editor run. `GraphInterpreter.call` passes it on through `...ctx` while nested calls keep getting fresh local variables, which is why `program.defaults` is stripped of global keys. Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or the inspector does not rebuild after the switch -- and a state machine has no rows at all, so its scope is appended to the signature by hand. And the object inspector does not go through `schemaForObject` for behaviors, it reads `asset.variables` directly; filtering in one place was not enough, and no unit test covered that path. The e2e caught it (`global: true` where false belonged), and the rule now lives in one named function, `objectRows`. Scopes `object` and `scene` are deliberately not built. The object case already has a route (`Object by name` -> `Properties`), and a run knows exactly one scene, so `scene` would be indistinguishable from `global` until #39 exists. Closes #59: a machine's scope is a word on the machine asset, not a setting per `Set state` node, so the same machine cannot live in two places at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
9.0 KiB
TypeScript
203 lines
9.0 KiB
TypeScript
/**
|
||
* A variable or a state machine declared `global` keeps one value for the whole run instead of one
|
||
* per behavior instance. The name is the key: two behaviors declaring the same global name mean the
|
||
* same variable, while a behavior declaring it locally keeps its own.
|
||
*/
|
||
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, SetVariableScopeCommand } from '../../src/core/commands/variableCommands';
|
||
import { globalConflicts, STATE, STATE_NAME_PARAM } from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import type { PropertyValue } from '../../src/core/model/Properties';
|
||
import type { VariableKind } from '../../src/core/properties/kinds';
|
||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||
|
||
/** A behavior that adds one to `key` whenever it starts – the coin counter of the example game. */
|
||
function makeCounter(fixture: SceneFixture, name: string, key: string, scope: 'local' | 'global') {
|
||
const behavior = makeBehavior(fixture, name, [key]);
|
||
if (scope === 'global') fixture.stack.execute(new SetVariableScopeCommand(behavior.id, key, scope));
|
||
const editor = graphEditor(fixture, behavior);
|
||
const start = editor.addNode('OnStart');
|
||
const read = editor.variables([key]);
|
||
const add = editor.addNode('Add', { b: 1 });
|
||
const write = editor.variables([key], { write: true });
|
||
editor.wire([
|
||
[start, 'exec', write, 'exec'],
|
||
[read, key, add, 'a'],
|
||
[add, 'result', write, key],
|
||
]);
|
||
return behavior;
|
||
}
|
||
|
||
/** Places `count` objects of the behavior and runs one 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('variable scope', () => {
|
||
it('counts once per instance when local', () => {
|
||
const f = makeSpriteScene();
|
||
const counter = makeCounter(f, 'Counter', 'score', 'local');
|
||
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]);
|
||
});
|
||
|
||
it('counts once for the whole run when global', () => {
|
||
const f = makeSpriteScene();
|
||
const counter = makeCounter(f, 'Counter', 'score', 'global');
|
||
const started = runWith(f, [counter, counter]);
|
||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||
// The value belongs to the run, so it is no longer a property of any placed object.
|
||
for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined();
|
||
});
|
||
|
||
it('shares one variable between two behaviors declaring the same global name', () => {
|
||
const f = makeSpriteScene();
|
||
const one = makeCounter(f, 'One', 'score', 'global');
|
||
const other = makeCounter(f, 'Other', 'score', 'global');
|
||
const started = runWith(f, [one, other]);
|
||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||
});
|
||
|
||
/** The scope sits on the declaration, so declaring the name yourself is how you opt out. */
|
||
it('lets a behavior declaring the name locally keep its own value', () => {
|
||
const f = makeSpriteScene();
|
||
const shared = makeCounter(f, 'Shared', 'score', 'global');
|
||
const own = makeCounter(f, 'Own', 'score', 'local');
|
||
const started = runWith(f, [shared, shared, own]);
|
||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||
const ownEntity = started.world.entities.find((e) => e.assetId === own.id)!;
|
||
expect(ownEntity.props.score).toBe(1);
|
||
});
|
||
|
||
it('starts a global variable at its declared value again on the next run', () => {
|
||
const f = makeSpriteScene();
|
||
const counter = makeCounter(f, 'Counter', 'score', 'global');
|
||
place(f, counter.id, { x: 0, y: 0 });
|
||
const first = startWorld(f, { behaviors: true });
|
||
first.frame();
|
||
expect(first.behaviors!.shared.variables.score).toBe(1);
|
||
|
||
const second = startWorld(f, { behaviors: true });
|
||
second.frame();
|
||
expect(second.behaviors!.shared.variables.score).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('two behaviors declaring the same global name', () => {
|
||
/** Declares `score` as `kind` with `start` as its value, global. */
|
||
function declare(fixture: SceneFixture, name: string, kind: VariableKind, start: PropertyValue) {
|
||
const behavior = makeBehavior(fixture, name);
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'score', kind));
|
||
fixture.stack.execute(new SetAssetPropertyCommand(behavior.id, 'score', start));
|
||
fixture.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global'));
|
||
return behavior;
|
||
}
|
||
|
||
it('refuses to run when the kinds disagree', () => {
|
||
const f = makeSpriteScene();
|
||
const counting = declare(f, 'Counting', 'number', 0);
|
||
declare(f, 'Naming', 'text', '');
|
||
place(f, counting.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
|
||
expect(started.world.stopRequested).toBe(true);
|
||
expect(started.world.log.join('\n')).toContain('is a number in Counting and a text in Naming');
|
||
expect(started.behaviors!.shared.variables.score).toBe(0);
|
||
});
|
||
|
||
it('runs on the first starting value when only the defaults disagree', () => {
|
||
const f = makeSpriteScene();
|
||
const first = declare(f, 'First', 'number', 7);
|
||
declare(f, 'Second', 'number', 99);
|
||
place(f, first.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
|
||
expect(started.world.stopRequested).toBe(false);
|
||
expect(started.behaviors!.shared.variables.score).toBe(7);
|
||
const conflicts = globalConflicts(f.project);
|
||
expect(conflicts.map((c) => c.severity)).toEqual(['warning']);
|
||
expect(conflicts[0]!.message).toContain('starts at 7 in First and at 99 in Second');
|
||
});
|
||
|
||
it('says nothing when they agree', () => {
|
||
const f = makeSpriteScene();
|
||
declare(f, 'First', 'number', 7);
|
||
declare(f, 'Second', 'number', 7);
|
||
expect(globalConflicts(f.project)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|