/** * Headless proof that Molehill actually PLAYS — driven by the MOUSE, which is * the only input it has. * * Every other starter's harness holds `move` and presses `jump`. This one uses * `runScript`'s `click` step: it installs a geometric picker (there is no * renderer headless, so there is no raycast), points the cursor at a node and * spreads the press and the release across the two frames `Clickable` needs. * * BOARD — nine holes, and a mole is up within the first second. * WHACK — clicking the mole that is up scores; the score climbs. * MISS — standing by while a mole's time runs out costs a life. * LOSE — three misses and the ScoreKeeper says `lost`. * WIN — twelve whacks and it says `won`, and the field stops. * SILENCE — a click on a hole with no mole in it scores nothing. * * Run with `bun verify.ts` (or `bun run verify`). */ import { registerBehavior } from 'incanto'; import { auditScene, registerAllNodes, runScript } from 'incanto/test'; import { Molehill, Scoreboard } from './src/behaviors'; import sceneJson from './src/game.scene.json'; const behaviors = { Molehill, Scoreboard }; const ok = (label: string, cond: boolean): void => { if (!cond) { console.error(`FAIL: ${label}`); process.exitCode = 1; } else { console.log(`pass: ${label}`); } }; // ---- AUDIT --------------------------------------------------------------------------- { registerAllNodes(); for (const [name, ctor] of Object.entries(behaviors)) { registerBehavior(name, ctor as never, { replace: true }); } const warnings = auditScene(sceneJson as unknown as Parameters[0]); ok(`auditScene is clean (${warnings.length} warnings)`, warnings.length === 0); for (const w of warnings) console.error(` warn: ${w}`); } interface Field { holeCount: number; } interface Keeper { score: number; lives: number; } interface Mole { visible: boolean; getPath(): string; } /** Whichever mole is up right now, by path — what a player is aiming at. */ const moleUp = (ctx: { getNode(p: string): unknown }): string | null => { const field = ctx.getNode('Field') as unknown as { children: Array<{ name: string }> }; for (const hole of field.children) { const mole = ctx.getNode(`Field/${hole.name}/Mole`) as unknown as Mole; if (mole.visible) return `Field/${hole.name}/Mole`; } return null; }; // ---- BOARD + WHACK ------------------------------------------------------------------- { let holes = 0; let upEarly: string | null = null; let scored = 0; const result = await runScript(sceneJson, { durationMs: 6000, seed: 3, behaviors, steps: [ { atMs: 800, label: 'nine holes, and a mole is up', do: (ctx) => { holes = (ctx.getNode('Field').behavior as unknown as Field).holeCount; upEarly = moleUp(ctx); }, }, // Sixteen chances at whichever mole is up — enough to outrun the ramp. // `click` takes a FUNCTION because a whack-a-mole cannot name its target // when the script is written: it clicks whatever is up right now, and // `null` on a frame with nothing up is a quiet no-op. ...Array.from({ length: 16 }, (_, i) => ({ atMs: 1000 + i * 300, click: (ctx: Parameters[0]) => moleUp(ctx), })), { atMs: 5900, label: 'clicking the mole that is up scores', do: (ctx) => { scored = (ctx.getNode('.').behavior as unknown as Keeper).score; }, }, ], }); ok(`the field has nine holes (${holes})`, holes === 9); ok(`a mole is up within the first second (${upEarly ?? 'none'})`, upEarly !== null); ok(`whacking scores (${scored})`, scored > 0); ok('nothing threw', result.ok); if (!result.ok) console.error(result.describe()); } // ---- MISS + LOSE --------------------------------------------------------------------- { let lives = MISSES(); let lost = false; await runScript(sceneJson, { durationMs: 12000, seed: 5, behaviors, steps: [ { atMs: 100, label: 'watch, and never click', do: (ctx) => { ctx.getNode('.').on('lost', () => { lost = true; }); }, }, { atMs: 11500, do: (ctx) => { lives = (ctx.getNode('.').behavior as unknown as Keeper).lives; }, }, ], }); ok(`missing costs lives (${lives} left)`, lives < MISSES()); ok('three misses lose the game', lost); } function MISSES(): number { return 3; } // ---- WIN ----------------------------------------------------------------------------- { let won = false; let stopped = false; await runScript(sceneJson, { durationMs: 14000, seed: 3, behaviors, steps: [ { atMs: 100, do: (ctx) => { ctx.getNode('.').on('won', () => { won = true; }); }, }, ...Array.from({ length: 44 }, (_, i) => ({ atMs: 400 + i * 280, click: (ctx: Parameters[0]) => moleUp(ctx), })), { atMs: 13500, label: 'the field stops once the game is over', do: (ctx) => { // `won` is wired to `Field.stop` in the scene, so no mole should be // up after the win — a board that keeps popping after the banner is // the classic "the game ended and nobody told the level" bug. stopped = won && moleUp(ctx) === null; }, }, ], }); ok('twelve whacks win the game', won); ok('the field stops when the game is won', stopped); } // ---- SILENCE ------------------------------------------------------------------------- { let scored = -1; await runScript(sceneJson, { durationMs: 2000, seed: 9, behaviors, steps: [ // A hole is not a mole: the click lands on nothing pickable and scores 0. { atMs: 300, click: [160, 180 + 40] }, { atMs: 1500, do: (ctx) => { scored = (ctx.getNode('.').behavior as unknown as Keeper).score; }, }, ], }); ok(`a click on bare ground scores nothing (${scored})`, scored === 0); } // ---- PLAY AGAIN ---------------------------------------------------------------------- { // The second time through is its own game: the score, the misses and the // field all have to come back, and a button that only works once is the // classic version of this bug. let afterLoss = { score: -1, lives: -1, running: false }; let secondScore = -1; await runScript(sceneJson, { durationMs: 24000, seed: 5, behaviors, steps: [ // Lose by watching: three moles get away. { atMs: 11500, label: 'press PLAY AGAIN after losing', do: (ctx) => { const keeper = ctx.getNode('.').behavior as unknown as Keeper; afterLoss = { score: keeper.score, lives: keeper.lives, running: false }; (ctx.getNode('Hud/Again') as unknown as { visible: boolean; press(): void }).press(); }, }, { atMs: 12200, label: 'the board is running again', do: (ctx) => { afterLoss.running = moleUp(ctx) !== null; }, }, // …and it can still be scored in. ...Array.from({ length: 20 }, (_, i) => ({ atMs: 12500 + i * 300, click: (ctx: Parameters[0]) => moleUp(ctx), })), { atMs: 19000, do: (ctx) => { secondScore = (ctx.getNode('.').behavior as unknown as Keeper).score; }, }, ], }); ok(`the game is lost before PLAY AGAIN (lives ${afterLoss.lives})`, afterLoss.lives === 0); ok('PLAY AGAIN starts the moles again', afterLoss.running); ok(`the second round can be scored in (${secondScore})`, secondScore > 0); }