Files
feuerwerk/tools/smoke-actor.mjs
T
rootandClaude Fable 5.1 3e99480b55
game-maker / suite (push) Successful in 8m51s
Actor smoke: measure the stop with two samples after key-up, not against a pre-screenshot probe
The check compared the position after key-up with a sample taken before
shot(); the player keeps walking during the screenshot, so the check
measured screenshot duration on the runner (22/30/40 px on CI runs 11-13)
and failed at exactly 40.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:32:34 +02:00

231 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Plays the walk demo (tools/seed-actor.mjs) in a headless browser: standing, walking in four
* directions, the diagonal, the turn when a key is let go, and the edge of the camera. It is the
* proof that animations, `Axis input` and a sliced sheet work together.
*
* Usage: node tools/seed-actor.mjs && node tools/smoke-actor.mjs
* Screenshots land in /tmp/gm-actor-*.png.
*/
import { runChecks } from './lib/harness.mjs';
import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('smoke-actor.mjs', PROJECTS.actor);
/** What the seeder writes. A mismatch means the loader dropped a node type or an edge. */
const NODES = 44;
const EDGES = 56;
/**
* The standing frame of each direction: the middle column of its row in the sheet. The graph reaches
* these through the `idle` list, indexed by `facing` – no id is spelled anywhere in it.
*/
const IDLE = { down: 'fr-0-1', left: 'fr-1-1', right: 'fr-2-1', up: 'fr-3-1' };
/** The 12×8 grid over the 576×384 sheet, and the corner the camera holds him at. */
const CELLS = 96;
const CELL = 48;
const EDGE_X = 296;
const EDGE_Y = 156;
await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, async (t) => {
const { check, sleep, json, shot, entity, play, stop, navigate, exceptions, holdKey, keyDown, keyUp } = t;
await navigate();
// 1. Nothing was dropped by the loader – an unknown node type vanishes silently.
const graph = await json(
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { n: b.graph.nodes.length, e: b.graph.edges.length }; })()`,
);
check(
`the player graph loads complete (${NODES} nodes, ${EDGES} edges)`,
graph.n === NODES && graph.e === EDGES,
JSON.stringify(graph),
);
// 2. The sheet: a full 12×8 slice, unsmoothed.
const sheet = await json(
`(() => { const t = __gm.state.project.assets.find((a) => a.id === 'sheet'); return { frames: t.frames.length, w: t.width, h: t.height, filter: t.props.filter, cell: { w: t.frames[0].w, h: t.frames[0].h }, last: t.frames[95] }; })()`,
);
check(
`the sheet is sliced into ${CELLS} cells of ${CELL}×${CELL}, filtered nearest`,
sheet.frames === CELLS && sheet.cell.w === CELL && sheet.cell.h === CELL && sheet.filter === 'nearest',
JSON.stringify(sheet),
);
check(
'the last cell sits in the bottom right corner',
sheet.last.x === 528 && sheet.last.y === 336 && sheet.last.name === 'Frame 96',
JSON.stringify(sheet.last),
);
// 3. Four walk animations, three frames each, ping-pong.
const anims = await json(
`__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, n: a.frames.length, fps: a.props.fps, pb: a.props.playback, mid: a.frames[1] }))`,
);
check(
'four walk animations of three frames play ping-pong',
anims.length === 4 && anims.every((a) => a.n === 3 && a.pb === 'pingpong' && a.fps === 8),
JSON.stringify(anims.map((a) => `${a.id}:${a.n}`)),
);
check(
'each walk animation has the standing frame in the middle',
anims.every((a) => a.mid === IDLE[a.id.replace('walk-', '')]),
JSON.stringify(anims.map((a) => a.mid)),
);
// 3b. How the graph reaches a picture: two list variables of tagged texture items, indexed by the
// row the character faces. Each item is what the texture picker writes, not a pasted id.
const pictures = await json(
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { walk: b.props.walk, idle: b.props.idle, facing: b.props.facing }; })()`,
);
check(
'the two picture lists hold four tagged textures each, indexed by facing',
pictures.walk.length === 4 &&
pictures.idle.length === 4 &&
pictures.walk.every((item, row) => item.$texture === `walk-${['down', 'left', 'right', 'up'][row]}`) &&
pictures.idle.every((item, row) => item.$texture === `fr-${row}-1`) &&
pictures.facing === 0,
JSON.stringify(pictures),
);
await shot('01-editor');
// 4. Standing still.
await play();
await sleep(300);
const start = await entity('oplayer');
check(
'the player starts at the origin showing the standing frame, facing down',
Math.abs(start.x) < 1 && Math.abs(start.y) < 1 && start.props.texture === IDLE.down,
JSON.stringify({ x: start.x, y: start.y, texture: start.props.texture }),
);
await shot('02-idle-down');
// 5. Walking right, and stopping on the standing frame of the way he faces.
await keyDown('d');
await sleep(400);
await shot('03-walk-right');
const walking = await entity('oplayer');
await keyUp('d');
await sleep(250);
const stopped = await entity('oplayer');
await sleep(150);
const still = await entity('oplayer');
check(
'holding D walks right and plays the right walk animation',
walking.x > 30 && walking.props.texture === 'walk-right',
JSON.stringify({ x: Math.round(walking.x), texture: walking.props.texture }),
);
// Two samples after the release: he must not move between them. Comparing against the walking
// sample measured how long the screenshot took (22 to 40 px on the CI runner), not the stop.
check(
'letting go stops him on the standing frame of the way he faces',
stopped.props.texture === IDLE.right && Math.abs(still.x - stopped.x) < 1 && stopped.x > walking.x - 1,
JSON.stringify({ x: Math.round(stopped.x), later: Math.round(still.x), texture: stopped.props.texture }),
);
// 6. Walking up is −y.
await holdKey('w', 400);
await sleep(200);
const up = await entity('oplayer');
check(
'holding W walks up (−y) and faces up afterwards',
up.y < -30 && up.props.texture === IDLE.up,
JSON.stringify({ y: Math.round(up.y), texture: up.props.texture }),
);
// 7. A diagonal: the key pressed last decides where he looks.
await keyDown('d');
await sleep(250);
const beforeW = await entity('oplayer');
await keyDown('w');
await sleep(250);
const diagonal = await entity('oplayer');
await shot('04-diagonal');
check(
'pressing W while D is held turns him up, the key pressed last',
beforeW.props.texture === 'walk-right' && diagonal.props.texture === 'walk-up',
JSON.stringify({ beforeW: beforeW.props.texture, diagonal: diagonal.props.texture }),
);
check(
'the diagonal moves both axes at once',
diagonal.x > beforeW.x + 5 && diagonal.y < beforeW.y - 5,
JSON.stringify({ dx: Math.round(diagonal.x - beforeW.x), dy: Math.round(diagonal.y - beforeW.y) }),
);
// 8. Letting the faced key go turns him onto the one still held, rather than walking backwards.
await keyUp('w');
await sleep(250);
const afterW = await entity('oplayer');
await keyUp('d');
await sleep(200);
check(
'releasing W while D is held turns him back to the right',
afterW.props.texture === 'walk-right',
JSON.stringify({ texture: afterW.props.texture }),
);
// 9. `Axis input` hands back a normalized vector, so a diagonal is not a faster way to travel.
const distanceWith = async (keys, ms) => {
const before = await entity('oplayer');
for (const k of keys) await keyDown(k);
await sleep(ms);
const after = await entity('oplayer');
for (const k of keys) await keyUp(k);
await sleep(150);
return Math.hypot(after.x - before.x, after.y - before.y);
};
const straight = await distanceWith(['d'], 400);
const diag = await distanceWith(['a', 'w'], 400);
check(
'walking diagonally covers the same distance as walking straight',
Math.abs(straight - diag) < straight * 0.25,
JSON.stringify({ straight: Math.round(straight), diagonal: Math.round(diag) }),
);
// 10. The edge of the camera holds him – and keeping the keys down there walks him on the spot,
// which is what lets the next check watch the animation from a fixed pixel.
await keyDown('d');
await keyDown('s');
await sleep(4000);
const corner = await entity('oplayer');
await shot('05-corner');
check(
'he stops at the edge of the camera instead of walking out of it',
Math.abs(corner.x - EDGE_X) < 1.5 && Math.abs(corner.y - EDGE_Y) < 1.5,
JSON.stringify({ x: Math.round(corner.x), y: Math.round(corner.y) }),
);
// 11. Read off the drawn pixels, not off a model field: a clock that ticks without the picture
// following would pass otherwise.
const spot = await json(`__gm.gameScreen('oplayer')`);
const stripe = async () =>
json(
`[0,1,2,3,4,5,6,7,8,9].map((i) => __gm.pixelAt(${Math.round(spot.x)} - 18 + i * 4, ${Math.round(spot.y)} + 16))`,
).then((p) => p.join(''));
const walkingFrames = new Set();
for (let i = 0; i < 14; i++) {
walkingFrames.add(await stripe());
await sleep(70);
}
await keyUp('d');
await keyUp('s');
await sleep(250);
check(
'the walk animation draws more than one frame',
walkingFrames.size > 1,
`${walkingFrames.size} distinct`,
);
const standing = new Set();
for (let i = 0; i < 8; i++) {
standing.add(await stripe());
await sleep(80);
}
check('standing still holds a single frame', standing.size === 1, `${standing.size} distinct`);
await stop();
check('nothing threw', (await exceptions()).length === 0, (await exceptions()).join(' | '));
});