- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 lines
6.9 KiB
JavaScript
121 lines
6.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 { 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(' | '));
|
||
});
|