#!/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: node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs * Screenshots land in /tmp/gm-jr-*.png. */ import { instanceChild } from './lib/builders.mjs'; import { runChecks } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun); /** * What the editor lists: the six stored objects plus everything the placed containers resolve to * (the level with its ten nested coins, and the three screens). A mismatch means the loader * dropped something. */ const OBJECT_COUNT = 77; /** The scene stores the container objects alone – the rest is resolved from their templates. */ const STORED_COUNT = 6; // Ids inside a placed container are derived from the template path, never stored. const PLAYER = instanceChild('L', 'player'); const START_BUTTON = instanceChild('T', 'title-button'); const AGAIN_BUTTON = instanceChild('W', 'win-button'); const GRAPHS = { player: [34, 37], game: [38, 43], 'start-button': [7, 6], '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, has, } = 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; /** 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. The * stored list is the right one here: play bakes every placed container into it, and only a * stored object can be written to. */ 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.sceneObjects().length'); const storedCount = await evaluate('__gm.state.activeScene.objects.length'); check('the scene holds the seeded objects', objectCount === OBJECT_COUNT, `${objectCount} objects`); check( 'the placed containers are stored as one object each, their content only resolved', storedCount === STORED_COUNT, `${storedCount} stored, ${objectCount} listed`, ); 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 has('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(START_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'); // 3a. Play bakes the placed containers into stored objects, keeping the ids the editor showed – // which is why the checks below can address the player and the buttons by the same id. const baked = await json('__gm.state.activeScene.objects.map((o) => o.id)'); check( 'play spells the containers out under the ids the editor listed', baked.length === OBJECT_COUNT && [PLAYER, START_BUTTON, AGAIN_BUTTON].every((id) => baked.includes(id)), `${baked.length} objects, player ${baked.includes(PLAYER)}`, ); // 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150). const grounded = await entity(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(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(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(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 }), ); // Against the count the run actually reached, not a fixed 1: how many coins the walk sweeps up // depends on machine speed – a faster host collects two. const hud = `Coins ${afterCoin.coins}/${afterCoin.needed}`; check('the HUD shows the counter', await has(hud), hud); 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 has('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(AGAIN_BUTTON); await sleep(1200); check( 'Play again restarts the scene at the title screen', (await has('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(START_BUTTON); await sleep(300); await setVariable('ogame', 'timeLeft', 0.2); await sleep(600); check( 'the clock running out loses the game', (await has('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.sceneObjects().length, stored: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }', ); check( 'stop restores the edited scene', saved.objects === OBJECT_COUNT && saved.stored === STORED_COUNT && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved), ); check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); });