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>
432 lines
19 KiB
JavaScript
432 lines
19 KiB
JavaScript
import { authHeaders, forgetSession } from '../lib/auth.mjs';
|
||
import { edge, node, port, sceneObject, variable } from '../lib/builders.mjs';
|
||
|
||
/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */
|
||
const TEMP_NAME = 'Release 60';
|
||
const UPDATED = 'Release 60 updated';
|
||
// Not a 16:9 multiple of the window on purpose: fitting it would need a scale between the steps.
|
||
const CAMERA = { width: 300, height: 180 };
|
||
/** The zoom steps of the game view (`GAME_SCALE_STEPS`); Auto takes the largest that fits whole. */
|
||
const STEPS = [0.25, 0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
|
||
const RED = '#e0553f';
|
||
const BACKGROUND = '#22252c';
|
||
|
||
/**
|
||
* A small game of its own: a red crate in the middle of the camera that the arrow keys move, a
|
||
* `Log` and a recording on start, and S stopping the game.
|
||
*/
|
||
function game(id) {
|
||
const mover = {
|
||
nodes: [
|
||
node('m-update', 'OnUpdate', 0, 0),
|
||
node('m-owner', 'Owner', 0, 120),
|
||
node('m-axis', 'AxisInput', 0, 200),
|
||
node('m-speed', 'Variables', 0, 400, {}, [port('speed', 'number')]),
|
||
node('m-move', 'Move', 340, 0),
|
||
],
|
||
edges: [
|
||
edge('m1', 'm-update', 'exec', 'm-move', 'exec'),
|
||
edge('m2', 'm-update', 'dt', 'm-move', 'dt'),
|
||
edge('m3', 'm-owner', 'object', 'm-move', 'object'),
|
||
edge('m4', 'm-axis', 'x', 'm-move', 'dx'),
|
||
edge('m5', 'm-axis', 'y', 'm-move', 'dy'),
|
||
edge('m6', 'm-speed', 'speed', 'm-move', 'scale'),
|
||
],
|
||
};
|
||
const control = {
|
||
nodes: [
|
||
node('c-start', 'OnStart', 0, 0),
|
||
node('c-log', 'Log', 260, 0, { message: 'released game started' }),
|
||
node('c-record', 'StartRecording', 520, 0, { name: 'Released 60', fps: '30' }),
|
||
node('c-key', 'OnKeyDown', 0, 160, { key: 's' }),
|
||
node('c-stop', 'StopGame', 260, 160),
|
||
],
|
||
edges: [
|
||
edge('c1', 'c-start', 'exec', 'c-log', 'exec'),
|
||
edge('c2', 'c-key', 'exec', 'c-stop', 'exec'),
|
||
edge('c3', 'c-log', 'exec', 'c-record', 'exec'),
|
||
],
|
||
};
|
||
return {
|
||
formatVersion: 2,
|
||
id,
|
||
name: TEMP_NAME,
|
||
activeSceneId: 'scene',
|
||
assets: [
|
||
{
|
||
id: 'scene',
|
||
type: 'scene',
|
||
name: 'Level',
|
||
props: { backgroundColor: 0x22252c },
|
||
objects: [
|
||
sceneObject('crate', 'crate'),
|
||
sceneObject('mover', 'mover', { parentId: 'crate' }),
|
||
sceneObject('camera', 'camera'),
|
||
sceneObject('control', 'control'),
|
||
],
|
||
},
|
||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 40, height: 40, color: 0xe0553f } },
|
||
{ id: 'camera', type: 'camera', name: 'Camera', props: { ...CAMERA, zoom: 1 } },
|
||
{
|
||
id: 'mover',
|
||
type: 'behavior',
|
||
name: 'Mover',
|
||
props: { speed: 200 },
|
||
variables: [variable('speed', 'number', 0)],
|
||
graph: mover,
|
||
},
|
||
{ id: 'control', type: 'behavior', name: 'Control', props: {}, variables: [], graph: control },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Releasing a project (#266): published from the main menu, played from the link without a
|
||
* session – only the game, zoomed to Auto or, once the project says so, to Fit (#279), on the page
|
||
* colour, with a fullscreen button in the corner (#267) – updated under the same link, and taken down.
|
||
*
|
||
* Works on a throwaway project of its own, like step 50. The last thing it does is delete that
|
||
* project and open the fixture again.
|
||
*/
|
||
export async function release(t) {
|
||
const { check, cdp, json, click, locate, sleep, waitUntil, settle, holdKey, press } = t;
|
||
const { clickToolbar, clickText, shot, baseUrl, navigate, exceptions, saved } = t;
|
||
|
||
const send = async (method, route, body, signedIn = true) =>
|
||
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||
method,
|
||
headers: { ...(signedIn ? await authHeaders(baseUrl) : {}), 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: AbortSignal.timeout(20_000),
|
||
});
|
||
// Step 45 changes the password of `e2e`, which ends the session the tools had cached.
|
||
const api = async (method, route, body) => {
|
||
const first = await send(method, route, body);
|
||
if (first.status !== 401) return first;
|
||
forgetSession(baseUrl);
|
||
return send(method, route, body);
|
||
};
|
||
const sweep = async () => {
|
||
for (const project of await (await api('GET', 'projects')).json()) {
|
||
if (project.name === TEMP_NAME || project.name === UPDATED)
|
||
await api('DELETE', `projects/${project.id}`);
|
||
}
|
||
};
|
||
const clickLast = async (text) => {
|
||
await settle();
|
||
const at = await locate(text, true);
|
||
await click(at.x, at.y);
|
||
};
|
||
const openRelease = async () => {
|
||
await clickToolbar('menu');
|
||
await clickText('Release…');
|
||
await waitUntil('__gm.release() !== null', { label: 'the release dialog opened' });
|
||
return json('__gm.release()');
|
||
};
|
||
const player = (what) => json(`__feuerwerkPlay.${what}`);
|
||
/** The play page as somebody without an account opens it. */
|
||
const openLink = async (link) => {
|
||
await cdp.send('Network.clearBrowserCookies');
|
||
await cdp.send('Page.navigate', { url: link });
|
||
await waitUntil('Boolean(window.__feuerwerkPlay)', { timeout: 15000, label: 'the play page is up' });
|
||
};
|
||
|
||
// The step before may leave an edit on its way to the server; leaving the page then would stop at
|
||
// the browser's "leave site?" question, and nothing answers it.
|
||
await saved();
|
||
await sweep();
|
||
const made = await (await api('POST', 'projects', { name: TEMP_NAME })).json();
|
||
const put = await api('PUT', `projects/${made.id}`, { revision: made.revision, project: game(made.id) });
|
||
if (!put.ok) throw new Error(`could not write the game: ${put.status}`);
|
||
try {
|
||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
|
||
await sleep(4000);
|
||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
|
||
timeout: 15000,
|
||
label: 'the throwaway project is open',
|
||
});
|
||
|
||
// 60a. Not released yet: no link, and Publish makes one from the saved project.
|
||
const before = await openRelease();
|
||
await clickLast('Publish');
|
||
await waitUntil('__gm.release()?.link != null', { label: 'the dialog shows the link' });
|
||
const after = await json('__gm.release()');
|
||
const token = /\?game=([a-f0-9]{32})$/.exec(after.link ?? '')?.[1] ?? '';
|
||
const origin = new URL(baseUrl).origin;
|
||
check(
|
||
'Publish in the release dialog makes a link to the play page, for the project as saved',
|
||
before.link === null &&
|
||
before.state.startsWith('Not released') &&
|
||
after.link === `${origin}/feuerwerk/play.html?game=${token}` &&
|
||
after.state.endsWith('the link plays the project as saved') &&
|
||
after.warnings.length === 0,
|
||
JSON.stringify({ before, after }),
|
||
);
|
||
const open = await send('GET', `releases/${token}`, undefined, false);
|
||
const copy = open.ok ? await open.json() : null;
|
||
check(
|
||
'the released copy is open without a session, under the token rather than the project id',
|
||
open.status === 200 && copy?.name === TEMP_NAME && copy?.project?.id === token,
|
||
JSON.stringify({ status: open.status, name: copy?.name, id: copy?.project?.id }),
|
||
);
|
||
await shot('60-release-dialog');
|
||
await clickLast('Cancel');
|
||
|
||
// 60b. A change to the saved project is not in the copy until Update release, which keeps the link.
|
||
const stored = await (await api('GET', `projects/${made.id}`)).json();
|
||
await api('PUT', `projects/${made.id}`, {
|
||
revision: stored.revision,
|
||
project: { ...stored.project, name: UPDATED },
|
||
});
|
||
await cdp.send('Page.reload');
|
||
await sleep(4000);
|
||
await waitUntil(`window.__gm?.state?.project?.name === ${JSON.stringify(UPDATED)}`, {
|
||
timeout: 15000,
|
||
label: 'the changed project is open',
|
||
});
|
||
const stale = await openRelease();
|
||
const nameBefore = (await (await send('GET', `releases/${token}`, undefined, false)).json()).name;
|
||
await clickLast('Update release');
|
||
await waitUntil('__gm.release()?.state.endsWith("as saved")', { label: 'the release is up to date' });
|
||
const updated = await json('__gm.release()');
|
||
const nameAfter = (await (await send('GET', `releases/${token}`, undefined, false)).json()).name;
|
||
check(
|
||
'the copy stays as it was until Update release, which keeps the link',
|
||
stale.state.endsWith('the project has changed since') &&
|
||
nameBefore === TEMP_NAME &&
|
||
nameAfter === UPDATED &&
|
||
updated.link === after.link,
|
||
JSON.stringify({ stale: stale.state, nameBefore, nameAfter, link: updated.link }),
|
||
);
|
||
await clickLast('Cancel');
|
||
|
||
// 60c. The link, without a session: the page colour, "Click to play", and nothing of the editor.
|
||
await openLink(after.link);
|
||
await waitUntil('__feuerwerkPlay.screen().caption === "Click to play"', {
|
||
timeout: 15000,
|
||
label: 'the game offers to start',
|
||
});
|
||
const page = {
|
||
background: await json('getComputedStyle(document.body).backgroundColor'),
|
||
title: await json('document.title'),
|
||
editor: await json('typeof window.__gm'),
|
||
running: await player('running()'),
|
||
captionHeight: (await player('screen()')).captionHeight,
|
||
cursor: await json(`getComputedStyle(document.querySelector('canvas')).cursor`),
|
||
};
|
||
check(
|
||
'the link opens without a session: only the game, on the page colour, waiting for a click',
|
||
page.background === 'rgb(27, 29, 34)' &&
|
||
page.title === UPDATED &&
|
||
page.editor === 'undefined' &&
|
||
page.running === false,
|
||
JSON.stringify(page),
|
||
);
|
||
// The panel titles are 11 px; the caption is set at Theme.captionSize (16). The page keeps the
|
||
// arrow throughout – Pixi writes `default` as `inherit`, so read what the browser makes of it.
|
||
check(
|
||
'"Click to play" is set larger than the panel titles, under the arrow',
|
||
page.captionHeight >= 16 && ['auto', 'default'].includes(page.cursor),
|
||
JSON.stringify(page),
|
||
);
|
||
|
||
// 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit.
|
||
// What the page hands the browser to download is caught here instead (60j).
|
||
await t.evaluate(`window.__downloads = [];
|
||
HTMLAnchorElement.prototype.click = function () { window.__downloads.push({ name: this.download, href: this.href }); };`);
|
||
const size = { w: await json('innerWidth'), h: await json('innerHeight') };
|
||
await click(size.w / 2, size.h / 2);
|
||
await waitUntil('__feuerwerkPlay.running()', { label: 'the game runs' });
|
||
await sleep(300);
|
||
const cornerAfterClick = await player('fullscreen()');
|
||
const screen = await player('screen()');
|
||
const centre = { x: screen.frame.x + screen.frame.w / 2, y: screen.frame.y + screen.frame.h / 2 };
|
||
const scale = screen.scale;
|
||
const pixels = {
|
||
centre: await player(`pixelAt(${centre.x}, ${centre.y})`),
|
||
beside: await player(`pixelAt(${centre.x - 40 * scale}, ${centre.y})`),
|
||
};
|
||
const auto = Math.max(...STEPS.filter((s) => CAMERA.width * s <= size.w && CAMERA.height * s <= size.h));
|
||
check(
|
||
'a click starts the game in its start scene, zoomed to the largest step that fits whole',
|
||
scale === auto &&
|
||
screen.frame.w === CAMERA.width * scale &&
|
||
screen.frame.h === CAMERA.height * scale &&
|
||
pixels.centre === RED &&
|
||
pixels.beside === BACKGROUND &&
|
||
JSON.stringify(await player('log()')) === JSON.stringify(['released game started']),
|
||
JSON.stringify({ size, auto, screen, pixels, log: await player('log()') }),
|
||
);
|
||
await shot('60-release-playing');
|
||
const playingCursor = await json(`getComputedStyle(document.querySelector('canvas')).cursor`);
|
||
check(
|
||
'over a running game the cursor is still the arrow',
|
||
['auto', 'default'].includes(playingCursor),
|
||
playingCursor,
|
||
);
|
||
|
||
// 60g (#267). The fullscreen button sits in the top right corner while the mouse moves, steps
|
||
// aside once it rests over the running game, and is back with the next move.
|
||
await sleep(2300);
|
||
const rested = await player('fullscreen()');
|
||
await t.mouse('mouseMoved', size.w / 2 + 30, size.h / 2 + 30);
|
||
await settle();
|
||
const movedAgain = await player('fullscreen()');
|
||
check(
|
||
'over a running game the fullscreen button shows while the mouse moves and steps aside when it rests',
|
||
cornerAfterClick.button !== null &&
|
||
cornerAfterClick.button.x > size.w - 60 &&
|
||
cornerAfterClick.button.y < 60 &&
|
||
rested.button === null &&
|
||
movedAgain.button !== null,
|
||
JSON.stringify({ cornerAfterClick, rested, movedAgain }),
|
||
);
|
||
|
||
// 60e. The keys go to the game: the arrow moves the crate out of the middle, S stops the game.
|
||
await holdKey('ArrowRight', 500);
|
||
await sleep(200);
|
||
const moved = await player(`pixelAt(${centre.x}, ${centre.y})`);
|
||
await press('s');
|
||
await sleep(400);
|
||
check(
|
||
'the keys reach the game, and a game that stops itself offers to play again',
|
||
moved === BACKGROUND && (await player('screen()')).caption === 'Click to play again',
|
||
JSON.stringify({ moved, screen: await player('screen()') }),
|
||
);
|
||
|
||
// 60j (#405). The game recorded itself from its start: ending the run saved the recording, and a
|
||
// released game, with no project to put it in, hands it to the browser as a Matroska file.
|
||
await waitUntil('window.__downloads.length > 0', { label: 'the recording is downloaded' });
|
||
const downloads = await json('window.__downloads');
|
||
const file = JSON.parse(
|
||
await t.evaluate(`fetch(${JSON.stringify(downloads[0]?.href ?? '')})
|
||
.then((r) => r.arrayBuffer())
|
||
.then((b) => JSON.stringify({ bytes: b.byteLength, head: [...new Uint8Array(b.slice(0, 4))] }))`),
|
||
);
|
||
check(
|
||
'a released game downloads its recording as an MKV file once the run ends',
|
||
downloads.length === 1 &&
|
||
downloads[0].name === 'Released 60.mkv' &&
|
||
file.bytes > 1000 &&
|
||
JSON.stringify(file.head) === JSON.stringify([0x1a, 0x45, 0xdf, 0xa3]),
|
||
JSON.stringify({ downloads: downloads.map((d) => d.name), file }),
|
||
);
|
||
|
||
// 60h (#267). A click on the corner button makes the page fullscreen and does not reach the
|
||
// game – it would start it again; a second click leaves fullscreen.
|
||
const corner = (await player('fullscreen()')).button;
|
||
if (corner) await click(corner.x, corner.y);
|
||
await sleep(500);
|
||
const entered = { ...(await player('fullscreen()')), running: await player('running()') };
|
||
const exitAt = entered.button ?? corner;
|
||
if (exitAt) await click(exitAt.x, exitAt.y);
|
||
await sleep(500);
|
||
const left = await player('fullscreen()');
|
||
check(
|
||
'the corner button puts the page into fullscreen and back, without starting the game',
|
||
corner !== null && entered.on && !entered.running && !left.on,
|
||
JSON.stringify({ corner, entered, left }),
|
||
);
|
||
check('the play page threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||
|
||
// 60i (#279). Project settings switch the link from Auto to Fit, with the mouse and as one undo
|
||
// step; once the release is updated, the same link fills the window at a scale between the steps.
|
||
await t.signIn();
|
||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
|
||
await sleep(4000);
|
||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
|
||
timeout: 15000,
|
||
label: 'the throwaway project is open for its settings',
|
||
});
|
||
const undoBefore = await json('__gm.state.commands.undoStack.length');
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
await settle();
|
||
const shown = (await json('__gm.projectSettings()')).gameLinkZoom;
|
||
const zoomAt = await json('__gm.projectSettingsGameLinkZoomAt()');
|
||
await click(zoomAt.x, zoomAt.y);
|
||
await clickLast('Fit');
|
||
await settle();
|
||
const chosen = (await json('__gm.projectSettings()')).gameLinkZoom;
|
||
await shot('60-release-link-zoom');
|
||
await clickLast('Apply');
|
||
await settle();
|
||
const stored279 = await json('__gm.state.project.gameLinkZoom');
|
||
const undoAfter = await json('__gm.state.commands.undoStack.length');
|
||
check(
|
||
'the Project settings show the link zoom as Auto and take Fit from the menu, as one undo step',
|
||
shown === 'auto' && chosen === 'fit' && stored279 === 'fit' && undoAfter === undoBefore + 1,
|
||
JSON.stringify({ shown, chosen, stored: stored279, undoBefore, undoAfter }),
|
||
);
|
||
await saved();
|
||
const changed = await openRelease();
|
||
await clickLast('Update release');
|
||
await waitUntil('__gm.release()?.state.endsWith("as saved")', { label: 'the release carries the zoom' });
|
||
await clickLast('Cancel');
|
||
const releasedZoom = (await (await send('GET', `releases/${token}`, undefined, false)).json()).project
|
||
.gameLinkZoom;
|
||
await openLink(after.link);
|
||
await waitUntil('__feuerwerkPlay.screen().caption === "Click to play"', {
|
||
timeout: 15000,
|
||
label: 'the fitted game offers to start',
|
||
});
|
||
await click(size.w / 2, size.h / 2);
|
||
await waitUntil('__feuerwerkPlay.running()', { label: 'the fitted game runs' });
|
||
await sleep(300);
|
||
const fitted = await player('screen()');
|
||
const fit = Math.min(size.w / CAMERA.width, size.h / CAMERA.height);
|
||
const near = (a, b) => Math.abs(a - b) < 0.01;
|
||
const fittedCentre = {
|
||
x: fitted.frame.x + fitted.frame.w / 2,
|
||
y: fitted.frame.y + fitted.frame.h / 2,
|
||
};
|
||
const fittedPixels = {
|
||
centre: await player(`pixelAt(${fittedCentre.x}, ${fittedCentre.y})`),
|
||
beside: await player(`pixelAt(${fittedCentre.x - 40 * fitted.scale}, ${fittedCentre.y})`),
|
||
};
|
||
check(
|
||
'with Fit the link fills the window, at a scale between the steps',
|
||
changed.state.endsWith('the project has changed since') &&
|
||
releasedZoom === 'fit' &&
|
||
!STEPS.includes(fit) &&
|
||
fit > auto &&
|
||
near(fitted.scale, fit) &&
|
||
near(fitted.frame.w, CAMERA.width * fit) &&
|
||
near(fitted.frame.h, CAMERA.height * fit) &&
|
||
(near(fitted.frame.w, size.w) || near(fitted.frame.h, size.h)) &&
|
||
fittedPixels.centre === RED &&
|
||
fittedPixels.beside === BACKGROUND,
|
||
JSON.stringify({ size, auto, fit, fitted, fittedPixels, releasedZoom, changed: changed.state }),
|
||
);
|
||
await shot('60-release-fit');
|
||
await press('s');
|
||
await sleep(400);
|
||
check('the fitted play page threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||
|
||
// 60f. Take down asks, then the link is dead – for the API and for the page.
|
||
await t.signIn();
|
||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
|
||
await sleep(4000);
|
||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
|
||
timeout: 15000,
|
||
label: 'the throwaway project is open again',
|
||
});
|
||
await openRelease();
|
||
await clickLast('Take down');
|
||
await clickLast('Take down');
|
||
await waitUntil('__gm.release()?.link === null', { label: 'the dialog shows no link' });
|
||
await clickLast('Cancel');
|
||
const dead = await send('GET', `releases/${token}`, undefined, false);
|
||
await openLink(after.link);
|
||
await waitUntil('__feuerwerkPlay.screen().caption !== "Loading…"', { label: 'the page gave up' });
|
||
check(
|
||
'Take down makes the link dead',
|
||
dead.status === 404 && (await player('screen()')).caption === 'This game is not available.',
|
||
JSON.stringify({ status: dead.status, screen: await player('screen()') }),
|
||
);
|
||
} finally {
|
||
await sweep();
|
||
await navigate();
|
||
}
|
||
}
|