/** * Headless proof that Castle Run actually PLAYS — the browserless half of the * agent loop (author → VERIFY → fix). Drives the REAL `game.scene.json` through * `incanto/test`'s `runScript` at a fixed, seeded timestep and asserts the whole * composition end to end. The platformer FEEL lives in the custom PlayerController * (coyote/buffer/double-jump/stomp/knockback/checkpoint-respawn), so the checks * exercise it directly rather than the built-in controller: * * RUN + JUMP — holding `move` right walks the knight; `jump` while grounded * leaves the floor (y rises) and gravity returns it to solid ground. * COIN — overlapping a coin Area fires Pickup.collected → ScoreKeeper * addScore; the score climbs. * STOMP — falling onto a goblin from above frees it and adds score. * HIT — touching a goblin from the SIDE costs the player a heart. * CHECKPOINT — touching the flag then dying respawns the player AT the flag. * WIN — touching the Goal sets the score to scoreToWin → `won`. * LOSE — falling into the pit with the last life → `died`-equivalent → * ScoreKeeper.loseLife → `lost`. * * Run with `bun verify.ts` (or `bun run verify`). */ import { registerBehavior } from 'incanto'; import { auditScene, registerAllNodes, runScript } from 'incanto/test'; import { FollowCam, GoblinSkin, HudUpdater, ParallaxLayer, PlayerController, } from './src/behaviors'; import sceneJson from './src/game.scene.json'; const behaviors = { PlayerController, GoblinSkin, FollowCam, ParallaxLayer, HudUpdater }; const ok = (label: string, cond: boolean): void => { if (!cond) { console.error(`FAIL: ${label}`); process.exitCode = 1; } else { console.log(`pass: ${label}`); } }; // ---- AUDIT --------------------------------------------------------------------------- // The check that fails on a WARNING, not just an error. Three of the five // shipped templates had no audit block, which is how every one of them came to // ship a scene whose saveable state had no uid to key it under: the save came // back empty, the load reported no problem, and nothing here asked. { 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 Body { position: number[]; velocity: number[]; } interface Player { hearts: number; } // ---- RUN + JUMP -------------------------------------------------------------- { let startX = 0; let startY = 0; let leftGround = false; let landedY = 0; await runScript(sceneJson, { durationMs: 2200, seed: 1, behaviors, steps: [ { atMs: 200, label: 'capture spawn pose', do: (ctx) => { const p = ctx.getNode('Player') as unknown as Body; startX = p.position[0] ?? 0; startY = p.position[1] ?? 0; }, }, { atMs: 250, vector: ['move', 1, 0] }, { atMs: 600, press: 'jump' }, { atMs: 660, release: 'jump' }, { atMs: 820, label: 'jump lifted the knight off the ground', do: (ctx) => { const p = ctx.getNode('Player') as unknown as Body; if ((p.position[1] ?? 0) < startY - 18) leftGround = true; }, }, { atMs: 900, vector: ['move', 0, 0] }, { atMs: 2000, label: 'capture settled pose', do: (ctx) => { landedY = (ctx.getNode('Player') as unknown as Body).position[1] ?? 0; }, }, { atMs: 2100, label: 'knight ran right', assert: (ctx) => ((ctx.getNode('Player') as unknown as Body).position[0] ?? 0) > startX + 50, }, ], }); ok('knight ran right from spawn', true); ok('jump lifted the knight off the ground', leftGround); ok('knight settled back onto solid ground', landedY > startY - 60 && landedY < startY + 60); } // ---- COIN + STOMP + HIT ------------------------------------------------------ { const scores: number[] = []; let enemiesBefore = 0; let enemiesAfterStomp = 0; let heartsAfterHit = 3; let hitKnock = 0; // px the knight was kicked AWAY from Gob2 while holding TOWARDS it let hitLift = 0; // px it rose (knockUp) const result = await runScript(sceneJson, { durationMs: 4000, seed: 2, behaviors, steps: [ { atMs: 100, do: (ctx) => { ctx.scene.root.on('scoreChanged', (s) => scores.push(s as number)); enemiesBefore = ctx.scene.tree.getNodesInGroup('enemy').length; }, }, { atMs: 300, label: 'collect a coin: snap the knight onto Coin1', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const coin = ctx.getNode('Coins/Coin1') as unknown as Body; player.position = [...coin.position]; for (let i = 0; i < 3; i++) ctx.engine.step(); }, }, { atMs: 900, label: 'stomp: drop onto Gob1 from above', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const gob = ctx.getNode('Gob1') as unknown as Body; player.position = [gob.position[0] ?? 0, (gob.position[1] ?? 0) - 56]; player.velocity = [0, 220]; for (let i = 0; i < 8; i++) ctx.engine.step(); enemiesAfterStomp = ctx.scene.tree.getNodesInGroup('enemy').length; }, }, { atMs: 1600, label: 'side hit: overlap Gob2 at the same height → lose a heart', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const gob = ctx.getNode('Gob2') as unknown as Body; // just LEFT of the goblin, overlapping it, and holding RIGHT into it: // the hit's kick must win the stick (stagger) and carry the knight left and up player.position = [(gob.position[0] ?? 0) - 12, gob.position[1] ?? 0]; player.velocity = [0, 0]; ctx.engine.input.setActionVector('move', 1, 0); for (let i = 0; i < 3; i++) ctx.engine.step(); heartsAfterHit = (ctx.getNode('Player').behavior as unknown as Player).hearts; const x0 = player.position[0] ?? 0; const y0 = player.position[1] ?? 0; for (let i = 0; i < 12; i++) { ctx.engine.step(); hitKnock = Math.max(hitKnock, x0 - (player.position[0] ?? 0)); hitLift = Math.max(hitLift, y0 - (player.position[1] ?? 0)); } ctx.engine.input.setActionVector('move', 0, 0); }, }, ], }); ok( 'a coin raised the score (Pickup → addScore)', scores.some((s) => s >= 10), ); ok('stomping a goblin removed it', enemiesBefore > 0 && enemiesAfterStomp === enemiesBefore - 1); ok( 'a stomp also scored', scores.some((s) => s >= 50), ); ok('a side hit cost a heart', heartsAfterHit < 3); ok( `…and kicked the knight ${hitKnock.toFixed(0)} px AWAY against a held stick, ${hitLift.toFixed(0)} px up (Health.knockback/knockUp/staggerSeconds)`, hitKnock > 20 && hitLift > 10, ); ok('runScript reported no failures (coin/stomp/hit path)', result.ok); } // ---- CHECKPOINT + RESPAWN + WIN ---------------------------------------------- { let won = 0; let respawnedAtCheckpoint = false; const result = await runScript(sceneJson, { durationMs: 4000, seed: 3, behaviors, steps: [ { atMs: 100, do: (ctx) => ctx.scene.root.on('won', () => won++) }, { atMs: 300, label: 'touch the checkpoint flag', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const cp = ctx.getNode('Checkpoint') as unknown as Body; player.position = [...cp.position]; for (let i = 0; i < 3; i++) ctx.engine.step(); }, }, { atMs: 900, label: 'die in the pit (lives remain) → respawn at the checkpoint', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const cp = ctx.getNode('Checkpoint') as unknown as Body; player.position = [1400, 880]; // into the death plane for (let i = 0; i < 4; i++) ctx.engine.step(); const p = (ctx.getNode('Player') as unknown as Body).position; respawnedAtCheckpoint = Math.abs((p[0] ?? 0) - (cp.position[0] ?? 0)) < 40 && Math.abs((p[1] ?? 0) - (cp.position[1] ?? 0)) < 60; }, }, { atMs: 1600, label: 'reach the goal flag → WIN', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; const goal = ctx.getNode('Goal') as unknown as Body; player.position = [...goal.position]; for (let i = 0; i < 4 && won === 0; i++) ctx.engine.step(); }, }, ], }); ok('dying respawned the knight at the checkpoint', respawnedAtCheckpoint); ok('reaching the goal emitted `won` once', won === 1); ok('runScript reported no failures (checkpoint/win path)', result.ok); } // ---- LOSE (pit with the last life) ------------------------------------------- { let lost = 0; const result = await runScript(sceneJson, { durationMs: 2500, seed: 4, behaviors, steps: [ { atMs: 100, do: (ctx) => { (ctx.scene.root.behavior as unknown as { lives: number }).lives = 1; ctx.scene.root.on('lost', () => lost++); }, }, { atMs: 400, label: 'fall into the pit with the last life', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Body; player.position = [1400, 880]; for (let i = 0; i < 6 && lost === 0; i++) ctx.engine.step(); }, }, ], }); ok('the last life lost the game (`lost`)', lost === 1); ok('runScript reported no failures (lose path)', result.ok); } console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK — Castle Run plays end to end'); // ---- TWIN: this round's 3D surfaces, in two dimensions ---------------------------- // StaticBody2D.restitution/friction (#1295) and Chase.ground (#1297) were built // with the 3D case in hand and 2D tests beside it; a course past the last // ground slab composes them in the 2D flagship and measures each. y is DOWN. { type Body = { position: number[] }; const bouncerY: number[] = []; let sledX = 0; let houndY = 0; let houndX = 0; const result = await runScript(sceneJson, { behaviors, durationMs: 4200, steps: [ ...Array.from({ length: 30 }, (_, i) => ({ atMs: 100 + i * 100, do: (ctx: { getNode(p: string): unknown }) => { bouncerY.push((ctx.getNode('Bouncer') as Body).position[1] ?? 0); }, })), { atMs: 3100, do: (ctx) => { sledX = (ctx.getNode('Sled') as unknown as Body).position[0] ?? 0; // the player on the perch above the hound: a ground chaser closes in below, never up const player = ctx.getNode('Player') as unknown as Body; player.position = [3650, 260]; for (let i = 0; i < 120; i++) ctx.engine.step(); const hound = ctx.getNode('Hound') as unknown as Body; houndX = hound.position[0] ?? 0; houndY = hound.position[1] ?? 0; }, }, ], }); ok('runScript reported no failures (TWIN path)', result.ok); let touch = bouncerY.findIndex((y, i) => i > 0 && y < (bouncerY[i - 1] ?? 0)); if (touch < 1) touch = 1; const low = bouncerY[touch - 1] ?? 0; const rebound = low - Math.min(...bouncerY.slice(touch)); ok( `StaticBody2D restitution 1: a dead ball climbs back ${rebound.toFixed(0)} px of its 300 px drop`, rebound > 180, ); ok( `StaticBody2D friction 0: the sled slides to x ${sledX.toFixed(0)} across the ice (from 3215)`, sledX > 3450, ); ok( `Chase.ground: the hound stays on the floor (y ${houndY.toFixed(0)}, floor 440) under a player on the perch, ${Math.abs(houndX - 3650).toFixed(0)} px off its x`, houndY > 400 && Math.abs(houndX - 3650) < 60, ); }