#!/usr/bin/env node /** * Plays the example game (tools/seed-jumprun.mjs) in a headless browser: title screen, start * button, running, jumping, collecting a coin, the win screen, restart and the lose screen. * It is the proof that the engine features this game needs actually work together. * * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs * Screenshots land in /tmp/gm-jr-*.png. */ import { runChecks } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun); /** What the seeder writes; a mismatch means the loader dropped something. */ const OBJECT_COUNT = 77; const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] }; const GROUND_Y = 130; await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => { const { check, sleep, evaluate, json, shot, click, holdKey, press, entity, world, mutate, play, stop, navigate, exceptions } = t; /** Variables of the "Game" behavior instance, live from the running world. */ const gameVars = async () => (await entity('ogame')).props; const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length; const visible = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`); /** Clicks an object where the *player* sees it (through the camera), not in the scene view. */ const clickObject = async (objectId) => { const p = await json(`__gm.gameScreen(${JSON.stringify(objectId)})`); if (!p) throw new Error(`object ${objectId} is not on screen in the game view`); await click(p.x, p.y); }; /** Edits the play copy (never the saved project) and pushes it into the running world. */ const setVariable = (objectId, key, value) => mutate(`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`); await navigate(); // 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived. const graphs = await json(`__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`); check( 'all behavior graphs load complete (no unknown node types, no dropped edges)', graphs.length === 4 && graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]), JSON.stringify(graphs), ); const objectCount = await evaluate('__gm.state.activeScene.objects.length'); check('the scene holds the seeded objects', objectCount === OBJECT_COUNT, `${objectCount} objects`); await shot('01-editor'); // 2. Play: the Game behavior shows the title screen and switches the level off. await play(); await sleep(300); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); check('the title screen is up', await visible('JUMP & RUN')); const level = await entity('L'); const title = await entity('T'); check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled })); await shot('02-title'); // 3. The Start button is a click target in the running game (On click + hit test). await clickObject('T-title-button'); await sleep(400); check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false); const vars = await gameVars(); check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 28, JSON.stringify(vars)); await shot('03-running'); // 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150). const grounded = await entity('L-player'); check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - GROUND_Y) < 1.5, `world y ${grounded.worldY.toFixed(2)}`); // 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down. await press('Space'); await sleep(200); const airborne = await entity('L-player'); check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`); await sleep(1200); const landed = await entity('L-player'); check('the player falls back onto the ground', Math.abs(landed.worldY - GROUND_Y) < 1.5, `y ${landed.worldY.toFixed(2)}`); await shot('04-jumped'); // 6. Walking and collecting: running into a coin removes it and counts up. const coinsBefore = await coins(); await holdKey('ArrowRight', 1500); const walked = await entity('L-player'); check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`); const afterCoin = await gameVars(); const coinsAfter = await coins(); check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter })); check('the HUD shows the counter', await visible('Coins 1/10')); check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`); await shot('05-collected'); // 7. Winning: with all ten coins the Game behavior swaps the level for the win screen. await setVariable('ogame', 'coins', 10); await sleep(300); check('collecting everything wins the game', (await visible('YOU WIN!')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false); await shot('06-won'); // 8. Restart game: back to a fresh run at the title screen, coins and clock reset. await clickObject('W-win-button'); await sleep(1200); check('Play again restarts the scene at the title screen', (await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play'); check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`); // 9. Losing: start again, run the clock out. await clickObject('T-title-button'); await sleep(300); await setVariable('ogame', 'timeLeft', 0.2); await sleep(600); check('the clock running out loses the game', (await visible('TIME UP')) && (await entity('B')).props.enabled === true); await shot('07-lost'); // 10. Stop throws the run away – the saved project is untouched. await stop(); await sleep(200); const saved = await json('{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }'); check('stop restores the edited scene', saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved)); check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); });