The harness forced software WebGL (--disable-gpu --use-gl=swiftshader) on every host. Measured here on an idle editor page over 20 s: that costs 373% of a core at 8 fps, while the Intel HD 630 via ANGLE/Vulkan costs 69% at 56 fps. The full suite now runs in ~124 s. Note the app is not rendering 60 fps as previously assumed - in software it only reaches 8, and a single frame costs more than any throttle interval would save, which is why capping the framerate had measured no effect. --use-angle=gl and --use-gl=egl look cheaper still but get no WebGL context at all, so the app never renders. They are not usable and are named in the comment as such. There is deliberately no software fallback: a host without a usable GPU should fail loudly rather than drag the machine to a standstill. None of the 267 checks reads the rendered image anyway - they read window.__gm.locate() and the debug hook, and the screenshots assert nothing - but Pixi needs WebGL2 to run at all. The browser launch moves out of harness.mjs into tools/lib/browser.mjs, which also supports GM_REMOTE=user@host via an SSH-forwarded CDP port. That path is unfinished: the tunnel degraded until the suite crawled at ~25 s per check, and forcing software WebGL on a desktop with a real card brought it to its knees. README says so plainly rather than advertising it. Also: the jump-run HUD check asserted a fixed "Coins 1/10", which fails on a faster host where the same walk sweeps up two coins. It now asserts the HUD against the count the run actually reached - the real guarantee is that HUD and state agree. Verified: npm run check green (404 tests), 267 e2e ALL PASSED and 24 jump-run checks ALL PASSED against the GPU flags. The fallback removal leaves the flag set on this host identical (same flags, different order), so it was not re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
8.9 KiB
JavaScript
228 lines
8.9 KiB
JavaScript
#!/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 { 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: [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. 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 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(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 visible(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 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(AGAIN_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(START_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.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(' | '));
|
||
});
|