Files
feuerwerk/tools/e2e/32-scope.mjs
T
rootandClaude Opus 5 d1fcfe8b81 A shared value belongs to the run, so a declaration carries its scope (#91)
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>
2026-09-14 16:22:53 +02:00

167 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}