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, the switch on the machine, and a * persistent asset going on from the last run (#166). * * 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, json, shot, click, locate, asset, mutate, stop, has } = t; const selectAsset = async (id) => { await t.select({ kind: 'asset', assetId: id }); }; const selectNode = async (nodeId) => { await t.select({ kind: 'node', assetId: 'sc-logic', nodeId }); }; /** 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); }; // 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'), ); `); // 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(); // 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 = ''; `); 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 clickLast('Global'); 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. Persistent (#166): the switch on the asset, a second run going on from the first, the // editor run saving apart, and both ways of clearing. Saves live in this browser, so the // step starts by forgetting whatever an earlier run of the suite left there. // 32d pointed the reading node at the behavior; counting on from a save needs it back on the asset. 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 = 'sc-vars'; `); const pid = await json('__gm.state.project.id'); const saveKey = (area) => `game-maker.saves.${pid}.${area}`; const saved = (area) => json(`JSON.parse(localStorage.getItem(${JSON.stringify(saveKey(area))}) ?? 'null')`); await t.evaluate( `localStorage.removeItem(${JSON.stringify(saveKey('play'))}); localStorage.removeItem(${JSON.stringify(saveKey('editor'))})`, ); await selectAsset('sc-vars'); const persistentBox = await json(`__gm.inspectorField('Persistent')`); if (persistentBox) await click(persistentBox.x, persistentBox.y); await sleep(200); const kept = await asset('sc-vars'); const keptNote = await has('Kept between runs, in this browser.'); check( 'the Persistent switch on a Variables asset marks it and says what it means', Boolean(persistentBox) && kept.persistent === true && keptNote, JSON.stringify({ box: persistentBox, persistent: kept.persistent, note: keptNote }), ); await shot('04-persistent'); // 32f-ii. An object variable in a persistent asset is allowed, but is never saved: the inspector // says so once there is one, and the note goes again with it. const objectNote = 'Object variables are not kept,'; const noteBefore = await has(objectNote); await clickLast('+ Variable'); await clickLast('Object'); const noteWith = await has(objectNote); await shot('04b-persistent-object'); await t.undo(); const noteAfter = await has(objectNote); check( 'a persistent asset with an Object variable notes that the object is not kept', !noteBefore && noteWith && !noteAfter && (await asset('sc-vars')).variables.length === 1, JSON.stringify({ noteBefore, noteWith, noteAfter }), ); const scoreOfRun = async () => { await t.play(); await sleep(600); const score = (await json('__gm.world()')).shared.variables['sc-vars']?.score; await stop(); return score; }; const firstRun = await scoreOfRun(); const secondRun = await scoreOfRun(); const afterPlay = await saved('play'); check( 'the next run goes on where the last one ended, and the save says so', firstRun === 2 && secondRun === 4 && afterPlay?.['sc-vars']?.score === 4, JSON.stringify({ firstRun, secondRun, saved: afterPlay }), ); const declaredScore = (await asset('sc-vars')).props.score; check( 'the project itself keeps the declared start value', declaredScore === 0, `props.score = ${JSON.stringify(declaredScore)}`, ); // One box runs in the editor: a run of its own, which must not touch what Play saved. await t.select({ kind: 'object', holderId: await json('__gm.state.project.activeSceneId'), objectId: 'sc-brain-a', }); await sleep(200); const runBox = await json(`__gm.inspectorField('Run in editor')`); if (runBox) await click(runBox.x, runBox.y); await sleep(600); const editorSave = await saved('editor'); const playSave = await saved('play'); if (runBox) await click(runBox.x, runBox.y); await sleep(200); check( 'an editor run saves into an area of its own and leaves the played save alone', editorSave?.['sc-vars']?.score === 1 && playSave?.['sc-vars']?.score === 4, JSON.stringify({ box: runBox, editor: editorSave, play: playSave }), ); await selectAsset('sc-vars'); const clearButton = await t.find('Clear saved values'); if (clearButton) await click(clearButton.x, clearButton.y); await sleep(200); const clearedNotice = await json('__gm.state.notice'); const clearedBoth = (await saved('play')) === null && (await saved('editor')) === null; const freshRun = await scoreOfRun(); check( '"Clear saved values" forgets both saves, and the next run starts from the declared value', Boolean(clearButton) && clearedBoth && freshRun === 2 && clearedNotice === 'Saved values of Score cleared', JSON.stringify({ button: Boolean(clearButton), clearedBoth, freshRun, notice: clearedNotice }), ); await t.clickToolbar('menu'); await t.clickText('Clear saved data'); await sleep(200); const menuCleared = await saved('play'); check( '"Clear saved data" in the menu forgets the saves of the whole project', menuCleared === null && (await json('__gm.state.notice')) === 'Saved data cleared', JSON.stringify({ play: menuCleared }), ); // 32h. The third scope (#164): the switch beside Persistent, what it offers, and that the two // cannot both be on – `scene` throws the values away with every scene, so there is nothing // left to keep between runs and the Persistent switch is not even shown. await selectAsset('sc-vars'); await sleep(200); const scopeRow = await json(`__gm.inspectorField('Scope')`); await click(scopeRow.x, scopeRow.y); const scopes = await json('__gm.menuRows()'); await clickLast('Scene'); await sleep(200); const scoped = await asset('sc-vars'); const sceneNote = await has('every scene that is loaded.'); check( 'a Variables asset offers Global and Scene, and taking Scene says what it means', JSON.stringify(scopes) === JSON.stringify(['Global', 'Scene']) && scoped.scope === 'scene' && sceneNote, JSON.stringify({ scopes, scope: scoped.scope, note: sceneNote }), ); check( 'a scene-scoped asset is not persistent any more, and cannot be switched to it', scoped.persistent === undefined && (await json(`__gm.inspectorField('Persistent')`)) === null, JSON.stringify({ persistent: scoped.persistent, row: await json(`__gm.inspectorField('Persistent')`) }), ); await shot('05-scene-scope'); const backRow = await json(`__gm.inspectorField('Scope')`); await click(backRow.x, backRow.y); await clickLast('Global'); await sleep(200); const back = await asset('sc-vars'); check( 'switching back to Global takes the scope out of the document and brings the switch back', back.scope === undefined && Boolean(await json(`__gm.inspectorField('Persistent')`)), JSON.stringify({ scope: back.scope, persistent: back.persistent }), ); // 32g. 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); `); }