The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
16 KiB
JavaScript
393 lines
16 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, 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'),
|
||
],
|
||
};
|
||
|
||
/** `On update → Variables{set bag} ← Join lists(bag, 7)`: a shared list that grows every frame. */
|
||
const fillerGraph = {
|
||
nodes: [
|
||
node('sc-f-update', 'OnUpdate', 0, 0),
|
||
node('sc-f-read', 'Variables', 0, 200, { variablesAsset: 'sc-bag' }, [port('bag', 'list')]),
|
||
node('sc-f-join', 'Concat', 260, 200, { b: [7] }),
|
||
node('sc-f-write', 'Variables', 560, 0, { variablesAsset: 'sc-bag' }, [written('bag', 'list')]),
|
||
],
|
||
edges: [
|
||
edge('sc-f-e1', 'sc-f-update', 'exec', 'sc-f-write', 'exec'),
|
||
edge('sc-f-e2', 'sc-f-read', 'bag', 'sc-f-join', 'a'),
|
||
edge('sc-f-e3', 'sc-f-join', 'result', 'sc-f-write', 'bag'),
|
||
],
|
||
};
|
||
|
||
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();
|
||
|
||
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the items drawn
|
||
// under it and in the Variables tab it opens have to be those too, and follow them – not
|
||
// the empty list the document declares.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const mover = p.assets.find((a) => a.id === 'mover');
|
||
const f = structuredClone(mover);
|
||
Object.assign(f, {
|
||
id: 'sc-filler',
|
||
name: 'Filler',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify(fillerGraph)},
|
||
});
|
||
p.assets.unshift(f, {
|
||
id: 'sc-bag',
|
||
type: 'variables',
|
||
name: 'Bag',
|
||
props: { bag: [] },
|
||
variables: ${JSON.stringify([variable('bag', 'list', [])])},
|
||
});
|
||
${MAKE_OBJECT}
|
||
scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler'));
|
||
`);
|
||
const bagIn = async (where) =>
|
||
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === 'Bag · bag') ?? null;
|
||
// Held, so the count the row shows stands still long enough to be found and clicked.
|
||
await t.play();
|
||
await sleep(300);
|
||
await t.clickToolbar('pause');
|
||
await sleep(150);
|
||
const held = (await json('__gm.world()')).shared.variables['sc-bag']?.bag ?? [];
|
||
await selectAsset('sc-bag');
|
||
await sleep(200);
|
||
const countRow = await t.find(`${held.length} items`);
|
||
const inline = await bagIn('inspector');
|
||
const head = await json(`__gm.values.headAt('inspector', 'Bag · bag')`);
|
||
if (head?.open) await click(head.open.x, head.open.y);
|
||
await sleep(150);
|
||
const first = await bagIn('tab');
|
||
await shot('01b-live-list');
|
||
await t.clickToolbar('pause');
|
||
await sleep(500);
|
||
const later = await bagIn('tab');
|
||
await stop();
|
||
await sleep(200);
|
||
const stopped = await bagIn('tab');
|
||
check(
|
||
'while playing, a shared list shows the items the run put in – inline and in its tab',
|
||
held.length > 0 &&
|
||
Boolean(countRow) &&
|
||
inline?.rows === Math.min(held.length, 20) &&
|
||
(await json('__gm.tab()')) === 'variables' &&
|
||
first?.rows === held.length &&
|
||
first.items.every((item) => item === '7'),
|
||
JSON.stringify({ run: held.length, row: countRow, inline: inline?.rows, tab: first?.rows }),
|
||
);
|
||
check(
|
||
'and the tab follows the list as it grows, then shows the declared list again once the run stops',
|
||
later?.rows > first?.rows && stopped?.rows === 0 && stopped.items.length === 0,
|
||
JSON.stringify({ first: first?.rows, later: later?.rows, stopped: stopped?.rows }),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f');
|
||
p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
|
||
// 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) => `feuerwerk.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);
|
||
`);
|
||
}
|