A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.
- Menu -> Release... opens a dialog with the state of the release,
the link with Copy link and Open, Publish or Update release, and
Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
snapshots. Updating keeps the link. Take down kills it, and a new
Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
paints Theme.colors.background, zooms with Auto and waits for
"Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
are stored per link in the browser. Plugins are not loaded, and the
dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
without a login. The public answer carries the token as project.id,
so the project id stays hidden. Release files are written with 0640
and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
EditorState. Pointer forwarding (gamePointer.ts) and setting up
textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
without the editor.
Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.
Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
JavaScript
283 lines
12 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, 4];
|
||
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` 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-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')],
|
||
};
|
||
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, on the page colour – 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(`__gameMakerPlay.${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.__gameMakerPlay)', { 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}/game-maker/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('__gameMakerPlay.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()'),
|
||
};
|
||
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),
|
||
);
|
||
|
||
// 60d. A click starts it in the start scene, zoomed to Auto: a whole number of pixels per unit.
|
||
const size = { w: await json('innerWidth'), h: await json('innerHeight') };
|
||
await click(size.w / 2, size.h / 2);
|
||
await waitUntil('__gameMakerPlay.running()', { label: 'the game runs' });
|
||
await sleep(300);
|
||
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');
|
||
|
||
// 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()') }),
|
||
);
|
||
check('the 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('__gameMakerPlay.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();
|
||
}
|
||
}
|