Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
7.2 KiB
JavaScript
190 lines
7.2 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 { 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(' | '));
|
||
});
|