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(`__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()'), 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('__gameMakerPlay.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('__gameMakerPlay.screen().caption === "Click to play"', { timeout: 15000, label: 'the fitted game offers to start', }); await click(size.w / 2, size.h / 2); await waitUntil('__gameMakerPlay.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('__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(); } }