game-maker / suite (push) Failing after 8m59s
This reverses the design of the previous commit. `PropertyDef.scope` is gone without replacement: a behavior variable is an object property again, without exception, and sharing is something you point at rather than something you declare twice. A **Variables asset** is declarations and nothing else -- no graph, no position, never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that costs no line). A `Variables` node names one through its new `variablesAsset` setting, empty meaning the behavior's own variables as before, and then reads and writes one value per key for the whole run. Its property list follows the chosen source, which was the actual request: a value another graph declared is now offerable, where before every participant had to declare the name itself. That deletes more than it adds (-72 lines net): `globalConflicts` and its run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both object-inspector filters and the "shared by the whole run" note on the placed object. A typo is no longer a silent second variable -- naming an asset is picking from a list. And two Variables assets may carry the same key without meeting, because the asset is the key, not the name. The one shape it copies is the one that already existed: `Set state` picks its machine the same way. Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the declarations and never written back, so playing in the editor cannot change the project -- which is the whole reason a counter must not be an object property (`World.syncToModel` would `commit()` it, and the coin counter would stand at 7 for good). Full symmetry across that gap, without mirroring anything. Two named wires: `Inspector.liveVariables` shows the values as they stand while a run is on and falls back to the declarations when it ends, and `adoptDeclarations()` takes a key into the run *only where the document changed it since the last sync*. Without that condition any unrelated project edit would snap a running counter back to its starting value; a counter-test bends exactly that and falls over. Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches nodes without a `read`, and the variable node has one. The wire was missing. The state machine `Scope` row is now an ordinary `enum` row through `createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and began ~34px left of every value above it; the explanations beside `Local` and `Global` are gone from the menu, the sentence below the row keeps saying it. Machine scope is otherwise untouched -- `StateMachineAsset.scope`, `SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59 stays closed as built and `Scope` as a type now carries machines alone. Six counter-proofs, each biting at its own guard. One of them did not bite at first: the suggestion list was computed twice, in `nodeSection` and `portSection`, so only the browser test covered the request itself. It is one named function now, `variableChoices`, with a unit test of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
6.2 KiB
JavaScript
170 lines
6.2 KiB
JavaScript
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Shared state: a Variables asset holds one value per key for the whole run, and a state machine
|
||
* declared `global` one current state. The step checks the run (two objects counting into one
|
||
* number), that the node offers the asset's declarations, and the switch on the machine.
|
||
*
|
||
* The step brings its own boxes, behavior, Variables asset and machine; everything goes at the end.
|
||
*/
|
||
|
||
const SOURCE = { variablesAsset: 'sc-vars' };
|
||
|
||
/** `On start → Variables{set score} ← Add(score, 1)`, counting on the shared asset rather than its own. */
|
||
const counterGraph = {
|
||
nodes: [
|
||
node('sc-start', 'OnStart', 0, 0),
|
||
node('sc-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]),
|
||
node('sc-add', 'Add', 260, 200, { b: 1 }),
|
||
node('sc-write', 'Variables', 560, 0, SOURCE, [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'),
|
||
],
|
||
};
|
||
|
||
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 selectNode = async (nodeId) => {
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: 'sc-logic', nodeId: ${JSON.stringify(nodeId)} })`,
|
||
);
|
||
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);
|
||
};
|
||
|
||
// 32a. A Variables asset, a behavior counting on it, and two boxes wearing that behavior.
|
||
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 },
|
||
variables: [],
|
||
graph: ${JSON.stringify(counterGraph)},
|
||
});
|
||
p.assets.unshift(a, {
|
||
id: 'sc-vars',
|
||
type: 'variables',
|
||
name: 'Score',
|
||
props: { score: 0 },
|
||
variables: ${JSON.stringify([variable('score', 'number', 0)])},
|
||
});
|
||
${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);
|
||
const shared = world.shared.variables['sc-vars'];
|
||
check(
|
||
'two behavior instances count into one shared variable',
|
||
shared && shared.score === 2,
|
||
`score = ${JSON.stringify(shared && shared.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. The asset is where the declaration lives, and it says what it is for.
|
||
await selectAsset('sc-vars');
|
||
const declared = await has('score');
|
||
const explains = await has('Every graph pointing here shares them.');
|
||
check(
|
||
'the Variables asset lists its declarations and says what they are',
|
||
declared && explains,
|
||
`row: ${declared}, note: ${explains}`,
|
||
);
|
||
await shot('01-variables-asset');
|
||
|
||
// 32d. The node picks its ports from the asset it names – the whole point of naming one.
|
||
await selectNode('sc-read');
|
||
const offersShared = await has('score');
|
||
const complains = await has('"score" is not a variable of this behavior.');
|
||
check(
|
||
'a node naming the asset offers its variables instead of the behavior’s',
|
||
offersShared && !complains,
|
||
`row: ${offersShared}, warning shown: ${complains}`,
|
||
);
|
||
await shot('02-node-source');
|
||
|
||
// Emptying the setting points the node back at the behavior, which declares nothing – so the
|
||
// very same port is now a stranger. That is the switch doing something visible.
|
||
await mutate(`
|
||
const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph;
|
||
g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = '';
|
||
`);
|
||
await sleep(400);
|
||
const complainsNow = await has('"score" is not a variable of this behavior.');
|
||
check(
|
||
'and clearing it points the node back at the behavior, which has no such variable',
|
||
complainsNow,
|
||
`warning after clearing: ${complainsNow}`,
|
||
);
|
||
|
||
// 32e. A machine carries its scope on the asset too, with its own switch.
|
||
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');
|
||
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);
|
||
}
|