/** * Headless proof that Hollow Horde 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 survivor * loop end to end: * * MOVE — holding `move` walks the player (top-down CharacterBody2D). * ATTACK — the auto-attack fires bolts at a nearby enemy; the enemy DIES, which * drops an XP shard (group 'xp') and tallies a kill (EnemyDrop). * LEVEL — gaining XP raises the level (and headlessly auto-takes an upgrade). * WIN — surviving the clock sets the score to scoreToWin → ScoreKeeper `won`. * LOSE — draining the player's Health to 0 → `died` → loseLife → `lost`. * * Run with `bun verify.ts` (or `bun run verify`). */ import type { Node } from 'incanto'; import { registerBehavior } from 'incanto'; import { auditScene, registerAllNodes, runScript } from 'incanto/test'; import { Director, EnemyDrop, EnemyFace, GridFloor, Survivor } from './src/behaviors'; import sceneJson from './src/game.scene.json'; const behaviors = { Survivor, Director, EnemyDrop, EnemyFace, GridFloor }; 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 Vec { position: number[]; } interface SurvivorB { kills: number; level: number; elapsed: number; addXp(n: number): void; } interface HealthB { kill(): void; } // ---- MOVE -------------------------------------------------------------------- { let startX = 0; let moved = false; await runScript(sceneJson, { durationMs: 1200, seed: 1, behaviors, steps: [ { atMs: 100, do: (ctx) => { startX = (ctx.getNode('Player') as unknown as Vec).position[0] ?? 0; }, }, { atMs: 150, vector: ['move', 1, 0] }, { atMs: 900, label: 'holding move walked the player', do: (ctx) => { moved = ((ctx.getNode('Player') as unknown as Vec).position[0] ?? 0) > startX + 40; }, }, ], }); ok('holding move walked the player', moved); } // ---- AUTO-ATTACK kills an enemy → XP drop + kill tally ------------------------ { let kills = 0; let xpShards = 0; let retreat = 0; // px a bolt shoved the goblin back from its closest approach (Health.knockback) const result = await runScript(sceneJson, { durationMs: 5000, seed: 2, behaviors, steps: [ { atMs: 300, label: 'spawn a goblin in attack range so the auto-attack cuts it down', do: (ctx) => { const player = ctx.getNode('Player') as unknown as Vec & { behavior: SurvivorB }; // turn the magnet off so the dropped shard lingers in the world for the assert (player.behavior as unknown as { magnet: number }).magnet = 0; const spawner = ctx.getNode('GoblinSpawner').behavior as unknown as { spawn(): Node | null; }; const enemy = spawner.spawn() as unknown as Vec | null; if (enemy) enemy.position = [(player.position[0] ?? 0) + 200, player.position[1] ?? 0]; let nearest = Number.POSITIVE_INFINITY; for (let i = 0; i < 260; i++) { if ((player.behavior as unknown as SurvivorB).kills >= 1) break; ctx.engine.step(); // a chaser only ever closes in; every step it is FURTHER than its // closest approach so far is a hit that moved it if (enemy && (enemy as unknown as { tree: unknown }).tree) { const d = Math.hypot( (enemy.position[0] ?? 0) - (player.position[0] ?? 0), (enemy.position[1] ?? 0) - (player.position[1] ?? 0), ); nearest = Math.min(nearest, d); retreat = Math.max(retreat, d - nearest); } } kills = (player.behavior as unknown as SurvivorB).kills; xpShards = ctx.scene.tree.getNodesInGroup('xp').length; }, }, ], }); ok('the auto-attack killed an enemy', kills >= 1); ok(`a bolt SHOVED the goblin back ${retreat.toFixed(1)} px (Health.knockback)`, retreat >= 10); ok('a dead enemy dropped an XP shard', xpShards >= 1); ok('runScript reported no failures (attack path)', result.ok); } // ---- XP → LEVEL UP ----------------------------------------------------------- { let leveled = false; await runScript(sceneJson, { durationMs: 800, seed: 3, behaviors, steps: [ { atMs: 200, label: 'gaining XP raises the level', do: (ctx) => { const s = ctx.getNode('Player').behavior as unknown as SurvivorB; const before = s.level; s.addXp(60); leveled = s.level > before; }, }, ], }); ok('gaining XP raised the level', leveled); } // ---- WIN (survive the clock) ------------------------------------------------- { let won = 0; await runScript(sceneJson, { durationMs: 1200, seed: 4, behaviors, steps: [ { atMs: 100, do: (ctx) => ctx.scene.root.on('won', () => won++) }, { atMs: 300, label: 'fast-forward the survive clock → WIN', do: (ctx) => { const s = ctx.getNode('Player').behavior as unknown as SurvivorB; s.elapsed = 89.9; for (let i = 0; i < 12 && won === 0; i++) ctx.engine.step(); }, }, ], }); ok('surviving the clock emitted `won`', won === 1); } // ---- LOSE (HP to 0) ---------------------------------------------------------- { let lost = 0; await runScript(sceneJson, { durationMs: 1200, seed: 5, behaviors, steps: [ { atMs: 100, do: (ctx) => ctx.scene.root.on('lost', () => lost++) }, { atMs: 300, label: 'drain the player to 0 HP → died → loseLife → lost', do: (ctx) => { (ctx.getNode('Player/Vitals').behavior as unknown as HealthB).kill(); for (let i = 0; i < 6 && lost === 0; i++) ctx.engine.step(); }, }, ], }); ok('player death lost the game (`lost`)', lost === 1); } console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK — Hollow Horde plays end to end');