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>
167 lines
6.1 KiB
JavaScript
167 lines
6.1 KiB
JavaScript
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Scopes: a variable or a state machine declared `global` keeps one value for the whole run instead
|
||
* of one per behavior instance. The step checks the run (two objects counting into one number), the
|
||
* switch in the inspector, and that a global variable stops being a property of the placed object.
|
||
*
|
||
* The step brings its own boxes, behavior and machine; everything it adds is removed at the end.
|
||
*/
|
||
|
||
/** `On start → Variables{set score} ← Add(score, 1)`: every instance adds one to the same number. */
|
||
const counterGraph = {
|
||
nodes: [
|
||
node('sc-start', 'OnStart', 0, 0),
|
||
node('sc-read', 'Variables', 0, 200, {}, [port('score', 'number')]),
|
||
node('sc-add', 'Add', 260, 200, { b: 1 }),
|
||
node('sc-write', 'Variables', 560, 0, {}, [written('score', 'number')]),
|
||
],
|
||
edges: [
|
||
edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'),
|
||
edge('sc-e2', 'sc-read', 'score', 'sc-add', 'a'),
|
||
edge('sc-e3', 'sc-add', 'result', 'sc-write', 'score'),
|
||
],
|
||
};
|
||
|
||
const globalScore = { ...variable('score', 'number', 0), scope: 'global' };
|
||
|
||
export async function scope(t) {
|
||
const { check, sleep, evaluate, json, shot, click, locate, asset, mutate, stop, has } = t;
|
||
|
||
const selectAsset = async (id) => {
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`);
|
||
await sleep(300);
|
||
};
|
||
const selectObject = async (id) => {
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: ${JSON.stringify(id)} })`,
|
||
);
|
||
await sleep(300);
|
||
};
|
||
/** The inspector is drawn over the graph, so the last match is the row rather than a port. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
await sleep(250);
|
||
};
|
||
const scopeOfVariable = async () => (await asset('sc-logic')).variables[0].scope ?? 'local';
|
||
|
||
// 32a. Two boxes wearing one behavior whose `score` is global.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const mover = p.assets.find((a) => a.id === 'mover');
|
||
const a = structuredClone(mover);
|
||
Object.assign(a, {
|
||
id: 'sc-logic',
|
||
name: 'Counter',
|
||
props: { enabled: true, runInEditor: false, score: 0 },
|
||
variables: ${JSON.stringify([globalScore])},
|
||
graph: ${JSON.stringify(counterGraph)},
|
||
});
|
||
p.assets.unshift(a);
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(
|
||
make('sc-box-a', 'crate', '', 1500, 'Counter box A'),
|
||
make('sc-brain-a', 'sc-logic', 'sc-box-a', 0, 'Counter A'),
|
||
make('sc-box-b', 'crate', '', 1700, 'Counter box B'),
|
||
make('sc-brain-b', 'sc-logic', 'sc-box-b', 0, 'Counter B'),
|
||
);
|
||
`);
|
||
await sleep(400);
|
||
|
||
// 32b. The run: two instances, one number. A per-instance variable would stand at 1.
|
||
await t.play();
|
||
await sleep(600);
|
||
const world = await json('__gm.world()');
|
||
const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name);
|
||
check(
|
||
'two behavior instances count into one global variable',
|
||
world.shared.variables.score === 2,
|
||
`score = ${JSON.stringify(world.shared.variables.score)} after both started – 1 would mean each kept its own`,
|
||
);
|
||
check(
|
||
'and the value belongs to the run, not to either placed object',
|
||
carriers.length === 0,
|
||
`objects carrying "score": ${JSON.stringify(carriers)}`,
|
||
);
|
||
await stop();
|
||
await sleep(400);
|
||
|
||
// 32c. A global variable shows no row on the object placed from the behavior – overriding one
|
||
// value of something every object shares would mean nothing.
|
||
await selectObject('sc-brain-a');
|
||
const rowWhileGlobal = await has('score');
|
||
const saysWhereItWent = await has('Shared by the whole run: score.');
|
||
check(
|
||
'the object names the variable it no longer holds, instead of claiming there is none',
|
||
saysWhereItWent && !(await has('The behavior declares no variables.')),
|
||
`note found: ${saysWhereItWent}`,
|
||
);
|
||
await shot('01-object-without-global');
|
||
|
||
// 32d. The switch sits on the declaration, in the behavior's own inspector.
|
||
await selectAsset('sc-logic');
|
||
const picker = await locate('⊕');
|
||
check(
|
||
'a global variable is marked as shared where it is declared',
|
||
Boolean(picker),
|
||
JSON.stringify(picker),
|
||
);
|
||
await shot('02-declaration');
|
||
await click(picker.x, picker.y);
|
||
await sleep(250);
|
||
await clickLast('Local – one value per object');
|
||
await sleep(350);
|
||
check(
|
||
'and the menu switches it back to one value per object',
|
||
(await scopeOfVariable()) === 'local',
|
||
`scope is now ${await scopeOfVariable()}`,
|
||
);
|
||
|
||
await selectObject('sc-brain-a');
|
||
const rowWhileLocal = await has('score');
|
||
check(
|
||
'the row appears on the placed object exactly while the variable is local',
|
||
!rowWhileGlobal && rowWhileLocal,
|
||
`global: ${rowWhileGlobal}, local: ${rowWhileLocal}`,
|
||
);
|
||
|
||
// 32e. A machine carries the same switch, and its own inspector is where it lives.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.unshift({
|
||
id: 'sc-machine',
|
||
type: 'statemachine',
|
||
name: 'Shared doors',
|
||
props: {},
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
`);
|
||
await selectAsset('sc-machine');
|
||
const machineScope = await has('Local');
|
||
const pick = await locate('Local', true);
|
||
await click(pick.x, pick.y);
|
||
await sleep(250);
|
||
await clickLast('Global – one current state for the whole run');
|
||
await sleep(350);
|
||
const machine = await asset('sc-machine');
|
||
check(
|
||
'a state machine is switched between one current state per object and one for the run',
|
||
machineScope && machine.scope === 'global',
|
||
`dropdown found: ${machineScope}, scope now ${JSON.stringify(machine.scope)}`,
|
||
);
|
||
await shot('03-machine-scope');
|
||
|
||
// 32f. Everything this step added goes again.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sc-'));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('sc-'));
|
||
__gm.state.select(null);
|
||
`);
|
||
await sleep(300);
|
||
}
|