// Headless verification for Tempest. // node tools/verifyTempest.js // Exits non-zero on any failure. // // 1. Web geometry invariants (16 webs, 16 lanes each, star-shaped about pit). // 2. Projection sanity (t=0 on rim, t=1 converges at the vanishing point). // 3. Mouse-aim mapping (angle -> lane, unambiguous on every web). // 4. Player movement (regulated speed, shorter-arc wrap, open-web clamping). // 5. Firing (cooldown, 8-shot cap, fire state survives level transitions). // 6. Enemy fixtures (flipper climb/flip/grab, tanker split, spiker/spikes, // fuseball edges + pause intangibility, pulsar lane pulses). // 7. Superzapper (full clear worth 0 pts, second use kills exactly one, // recharges next level). // 8. Scoring / extra lives / skill-step start bonus. // 9. Level flow (clear -> warp -> next level; warp spike deaths re-fly). // 10. Difficulty escalation monotonicity. // 11. Monte-carlo bot soak (many seeds, invariant checks every step). import { WEBS, LANES, TUNE, BANDS, ENEMY_COLORS, laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle, bandIndexForLevel, startBonus, startLevelOptions, flipperSpeed, enemyShotSpeed, enemyFireRate, levelBudget, maxConcurrent, spawnInterval, flipRest, createGame, step, setAim, setFiring, superzap, } from '../src/games/tempest/TempestLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function angleNorm(d) { let a = d; while (a > Math.PI) a -= Math.PI * 2; while (a < -Math.PI) a += Math.PI * 2; return a; } // Drives one sim frame at 60fps. const DT = 1000 / 60; // ── 1. Web geometry ────────────────────────────────────────────────────────── console.log('Web geometry'); { check('exactly 16 webs', WEBS.length === 16); const names = new Set(WEBS.map((w) => w.name)); check('web names unique', names.size === 16); const closedCount = WEBS.filter((w) => w.closed).length; check('mix of closed and open webs', closedCount >= 9 && closedCount <= 12, `closed=${closedCount}`); for (const web of WEBS) { const n = laneCount(web); check(`${web.name}: 16 lanes`, n === LANES, `got ${n}`); check(`${web.name}: vert count matches ${web.closed ? 'closed' : 'open'}`, web.verts.length === (web.closed ? LANES : LANES + 1)); let minLen = Infinity; const edges = web.closed ? web.verts.length : web.verts.length - 1; for (let i = 0; i < edges; i += 1) { const [ax, ay] = web.verts[i]; const [bx, by] = web.verts[(i + 1) % web.verts.length]; minLen = Math.min(minLen, Math.hypot(bx - ax, by - ay)); } check(`${web.name}: no degenerate lane edges`, minLen > 0.02, `min=${minLen.toFixed(4)}`); let maxAbs = 0; for (const [x, y] of web.verts) maxAbs = Math.max(maxAbs, Math.abs(x), Math.abs(y)); check(`${web.name}: normalized to unit half-extent`, Math.abs(maxAbs - 1) < 1e-6, `max=${maxAbs}`); // Star-shape property: lane bearings from the pit sweep monotonically, // so the mouse angle picks exactly one lane everywhere. let inc = true; let dec = true; for (let i = 1; i < web.laneAngles.length; i += 1) { const d = angleNorm(web.laneAngles[i] - web.laneAngles[i - 1]); if (d <= 0) inc = false; if (d >= 0) dec = false; } check(`${web.name}: lane bearings monotonic about pit`, inc || dec); } check('level 1 is the circle', webForLevel(1).name === 'CIRCLE'); check('level 17 wraps back to the circle', webForLevel(17).name === 'CIRCLE'); check('level 16 is the oval', webForLevel(16).name === 'OVAL'); } // ── 2. Projection ──────────────────────────────────────────────────────────── console.log('Projection'); { check('depthScale(0) is 1', Math.abs(depthScale(0) - 1) < 1e-9); check('depthScale(1) matches FAR', Math.abs(depthScale(1) - 0.135) < 1e-3); let monotonic = true; for (let i = 1; i <= 100; i += 1) { if (depthScale(i / 100) >= depthScale((i - 1) / 100)) monotonic = false; } check('depthScale strictly shrinks with depth', monotonic); for (const web of WEBS) { const { project, vp } = makeProjector(web, 960, 540, 330); let rimOk = true; let pitOk = true; for (let lane = 0; lane < laneCount(web); lane += 1) { const near = project(lane + 0.5, 0); const [rx, ry] = rimPoint(web, lane + 0.5); if (Math.hypot(near.x - (960 + rx * 330), near.y - (540 + ry * 330)) > 0.001) rimOk = false; const far = project(lane + 0.5, 1); if (Math.hypot(far.x - vp.x, far.y - vp.y) > 330 * 0.15 * 2.2) pitOk = false; } check(`${web.name}: t=0 lands on the rim`, rimOk); check(`${web.name}: t=1 converges near the vanishing point`, pitOk); } } // ── 3. Mouse aim ───────────────────────────────────────────────────────────── console.log('Mouse aim'); { const circle = WEBS[0]; check('circle: aiming straight down picks the bottom lane', Math.abs(angleNorm(circle.laneAngles[aimLaneForAngle(circle, Math.PI / 2)] - Math.PI / 2)) < 0.25); check('circle: aiming right picks the right lane', Math.abs(angleNorm(circle.laneAngles[aimLaneForAngle(circle, 0)])) < 0.25); // Feeding back each lane's own bearing must return that same lane. for (const web of WEBS) { let stable = true; for (let lane = 0; lane < web.laneAngles.length; lane += 1) { if (aimLaneForAngle(web, web.laneAngles[lane]) !== lane) stable = false; } check(`${web.name}: every lane reachable by its own bearing`, stable); } } // ── 4. Player movement ─────────────────────────────────────────────────────── console.log('Player movement'); { const sim = createGame({ seed: 7, startLevel: 1 }); // circle, closed const n = laneCount(sim.web); const start = sim.player.pos; sim.player.targetLane = (Math.round(start) + 3) % n; const before = sim.player.pos; step(sim, DT); const moved = Math.abs(angleNorm(((sim.player.pos - before) / n) * Math.PI * 2)) * (n / (Math.PI * 2)); check('movement per frame respects the speed cap', moved <= TUNE.MOVE_LANES_PER_SEC * (DT / 1000) + 1e-6, `moved=${moved}`); // Wraparound: target 2 lanes "behind" should go the short way (negative). sim.player.pos = 1; sim.player.targetLane = n - 1; step(sim, DT); check('closed web takes the shorter arc through the wrap', sim.player.pos > 1 - 1 || sim.player.pos > n - 3, `pos=${sim.player.pos}`); let posWrapped = sim.player.pos; check('closed web position stays within [0, N)', posWrapped >= 0 && posWrapped < n); // Open web: clamps at the ends, never wraps. const open = createGame({ seed: 7, startLevel: 8 }); // VEE check('level 8 fixture is an open web', !open.web.closed); open.player.pos = 0; open.player.targetLane = 0; setAim(open, open.web.laneAngles[laneCount(open.web) - 1]); for (let i = 0; i < 600; i += 1) step(open, DT); check('open web reaches the far end', Math.round(open.player.pos) === laneCount(open.web) - 1, `pos=${open.player.pos}`); let inBounds = true; setAim(open, open.web.laneAngles[0]); for (let i = 0; i < 600; i += 1) { step(open, DT); if (open.player.pos < 0 || open.player.pos > laneCount(open.web) - 1) inBounds = false; } check('open web clamps at the ends', inBounds && Math.round(open.player.pos) === 0); } // ── 5. Firing ──────────────────────────────────────────────────────────────── console.log('Firing'); { const sim = createGame({ seed: 11 }); sim.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim.enemies.push(sim.makeEnemy('spiker', 5, 0.9)); // keep the level from clearing setFiring(sim, true); let fired = 0; for (let i = 0; i < 60; i += 1) { for (const e of step(sim, DT)) if (e.type === 'shotFired') fired += 1; } const expected = Math.floor(1000 / TUNE.FIRE_COOLDOWN_MS) + 1; check('fire rate honors the cooldown', Math.abs(fired - expected) <= 1, `fired=${fired}`); check('live shots never exceed the arcade cap', sim.shots.length <= TUNE.MAX_SHOTS); // Held fire must survive a level transition (regression: enterLevel reset it). const sim2 = createGame({ seed: 12 }); setFiring(sim2, true); sim2.enterLevel(2); check('firing state survives enterLevel', sim2.player.firing === true); } // ── 6. Enemy fixtures ──────────────────────────────────────────────────────── console.log('Enemy fixtures'); { // Flipper: climbs, rests at the rim, hops toward the player, grabs on contact. const sim = createGame({ seed: 21 }); sim.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const playerLane = sim.playerLane(); const flipLane = (playerLane + 3) % LANES; const flipper = sim.makeEnemy('flipper', flipLane, 0.4); sim.enemies.push(flipper); setAim(sim, sim.web.laneAngles[playerLane]); let grabbed = false; for (let i = 0; i < 60 * 30 && !grabbed; i += 1) { for (const e of step(sim, DT)) if (e.type === 'playerHit' && e.cause === 'grabbed') grabbed = true; if (sim.phase === 'death') break; } check('flipper climbs, hunts, and grabs a stationary player', grabbed); // Shooting a flipper as it climbs. const sim2 = createGame({ seed: 22 }); sim2.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const lane2 = sim2.playerLane(); sim2.enemies.push(sim2.makeEnemy('flipper', lane2, 0.8)); sim2.enemies.push(sim2.makeEnemy('spiker', (lane2 + 8) % LANES, 0.95)); // hold level open setFiring(sim2, true); let killedPts = 0; for (let i = 0; i < 60 * 5 && !killedPts; i += 1) { for (const e of step(sim2, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'flipper') killedPts = e.points; } check('shot flipper dies for 150', killedPts === TUNE.POINTS.flipper, `got ${killedPts}`); // Tanker: releases two flippers when shot. const sim3 = createGame({ seed: 23 }); sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const lane3 = sim3.playerLane(); sim3.enemies.push(sim3.makeEnemy('tanker', lane3, 0.7)); setFiring(sim3, true); let tankerPts = 0; for (let i = 0; i < 60 * 5 && !tankerPts; i += 1) { for (const e of step(sim3, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'tanker') tankerPts = e.points; } const flippersOut = sim3.enemies.filter((e) => e.type === 'flipper').length; check('shot tanker dies for 100', tankerPts === TUNE.POINTS.tanker, `got ${tankerPts}`); check('shot tanker releases two flippers', flippersOut === 2, `got ${flippersOut}`); // Tanker reaching the rim also splits (no points). const sim4 = createGame({ seed: 24 }); sim4.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const away = (sim4.playerLane() + 8) % LANES; sim4.enemies.push(sim4.makeEnemy('tanker', away, 0.2)); let rimSplit = false; for (let i = 0; i < 60 * 6 && !rimSplit; i += 1) { for (const e of step(sim4, DT)) if (e.type === 'enemyReachedRim' && e.enemyType === 'tanker') rimSplit = true; if (sim4.phase !== 'playing') break; } check('tanker reaching the rim pops open', rimSplit); check('rim split produced flippers', sim4.enemies.some((e) => e.type === 'flipper')); // Spiker: builds a spike, capped near the rim; shots trim it. const sim5 = createGame({ seed: 25 }); sim5.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const sLane = sim5.playerLane(); sim5.enemies.push(sim5.makeEnemy('spiker', sLane, 0.9)); for (let i = 0; i < 60 * 6; i += 1) { step(sim5, DT); if (sim5.phase !== 'playing') break; } check('spiker grows a spike in its lane', sim5.spikes[sLane] < 1, `tip=${sim5.spikes[sLane]}`); check('spike growth capped away from the rim', sim5.spikes[sLane] >= TUNE.SPIKE_MIN_T - 1e-9, `tip=${sim5.spikes[sLane]}`); const tipBefore = sim5.spikes[sLane]; // Kill the spiker with the zapper so only the spike remains, then shoot it. superzap(sim5); for (let i = 0; i < 30; i += 1) step(sim5, DT); setFiring(sim5, true); let trimmed = false; for (let i = 0; i < 60 * 2 && !trimmed; i += 1) { for (const e of step(sim5, DT)) if (e.type === 'spikeTrimmed') trimmed = true; if (sim5.phase !== 'playing') break; } check('shots trim the spike', trimmed && sim5.spikes[sLane] > tipBefore, `before=${tipBefore} after=${sim5.spikes[sLane]}`); // Fuseball: rides edges, intangible while paused, lethal at the rim edge. const sim6 = createGame({ seed: 26 }); sim6.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const fLane = sim6.playerLane(); const fuse = sim6.makeEnemy('fuseball', fLane, 0.5); fuse.state = 'pause'; fuse.phaseMs = 1e9; // frozen mid-web, sparking sim6.enemies.push(fuse); sim6.enemies.push(sim6.makeEnemy('spiker', (fLane + 8) % LANES, 0.95)); setFiring(sim6, true); let fuseKilled = false; for (let i = 0; i < 90; i += 1) { for (const e of step(sim6, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'fuseball') fuseKilled = true; } check('paused fuseball is intangible to shots', !fuseKilled); fuse.state = 'move'; fuse.dir = -1; fuse.phaseMs = 1e9; for (let i = 0; i < 60 * 4 && !fuseKilled; i += 1) { for (const e of step(sim6, DT)) { if (e.type === 'enemyKilled' && e.enemyType === 'fuseball') { fuseKilled = true; check('fuseball kill pays a proximity bonus', TUNE.FUSEBALL_POINTS.includes(e.points)); } } if (sim6.phase !== 'playing') break; } check('moving fuseball can be shot', fuseKilled); // Fuseball contact at the rim kills the player. const sim7 = createGame({ seed: 27 }); sim7.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const contact = sim7.makeEnemy('fuseball', sim7.playerLane(), 0.08); contact.state = 'move'; contact.dir = -1; contact.phaseMs = 1e9; sim7.enemies.push(contact); let fuseDeath = false; for (let i = 0; i < 60 * 3 && !fuseDeath; i += 1) { for (const e of step(sim7, DT)) if (e.type === 'playerHit' && e.cause === 'fuseball') fuseDeath = true; if (sim7.phase !== 'playing') break; } check('fuseball at the rim edge kills the player', fuseDeath); // Pulsar: pulses on the shared clock; lethal only near the rim in-lane. const sim8 = createGame({ seed: 28, startLevel: 17 }); sim8.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const pLane = sim8.playerLane(); const pulsar = sim8.makeEnemy('pulsar', pLane, TUNE.PULSAR_LETHAL_T - 0.02); sim8.enemies.push(pulsar); let pulsarDeath = false; for (let i = 0; i < 60 * 8 && !pulsarDeath; i += 1) { pulsar.t = TUNE.PULSAR_LETHAL_T - 0.02; pulsar.dir = 1; // pin it near the rim for (const e of step(sim8, DT)) if (e.type === 'playerHit' && e.cause === 'pulsar') pulsarDeath = true; if (sim8.phase !== 'playing') break; } check('pulsing pulsar near the rim kills in-lane player', pulsarDeath); const sim9 = createGame({ seed: 29, startLevel: 17 }); sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6); sim9.enemies.push(deep); let deepDeath = false; for (let i = 0; i < 60 * 8; i += 1) { deep.t = 0.6; deep.dir = 1; // pin it deep in the well for (const e of step(sim9, DT)) if (e.type === 'playerHit') deepDeath = true; if (sim9.phase !== 'playing') break; } check('pulsar deep in the well is harmless', !deepDeath); } // ── 7. Superzapper ─────────────────────────────────────────────────────────── console.log('Superzapper'); { const sim = createGame({ seed: 31 }); // Keep one enemy in the budget (with spawns frozen) so clearing the web // with the zapper doesn't end the level mid-fixture. sim.spawn.remaining = { flipper: 1, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim.spawn.timerMs = 1e9; for (let i = 0; i < 5; i += 1) sim.enemies.push(sim.makeEnemy('flipper', i * 3 % LANES, 0.5 + i * 0.08)); sim.enemies.push(sim.makeEnemy('spiker', 1, 0.9)); const scoreBefore = sim.score; superzap(sim); let zapKills = 0; for (let i = 0; i < 60 * 2; i += 1) { for (const e of step(sim, DT)) if (e.type === 'enemyKilled' && e.zap) zapKills += 1; if (sim.phase !== 'playing') break; } check('full zap clears every enemy on the web', zapKills === 6, `kills=${zapKills}`); check('zap kills score nothing', sim.score === scoreBefore, `delta=${sim.score - scoreBefore}`); check('one charge consumed', sim.zapper.uses === 1); sim.enemies.push(sim.makeEnemy('flipper', 2, 0.5)); sim.enemies.push(sim.makeEnemy('flipper', 9, 0.5)); superzap(sim); step(sim, DT); check('second zap kills exactly one enemy', sim.enemies.length === 1, `left=${sim.enemies.length}`); check('zapper now empty', sim.zapper.uses === 0); superzap(sim); step(sim, DT); check('empty zapper does nothing', sim.enemies.length === 1); sim.enterLevel(sim.level + 1); check('zapper recharges on a new level', sim.zapper.uses === TUNE.ZAPPER_USES); } // ── 8. Scoring, extra lives, skill step ────────────────────────────────────── console.log('Scoring and skill step'); { const sim = createGame({ seed: 41 }); sim.addScore(TUNE.EXTRA_LIFE_EVERY - 50); check('no early extra life', sim.lives === TUNE.LIVES); let extra = false; sim.events = []; sim.addScore(100); extra = sim.events.some((e) => e.type === 'extraLife'); check('extra life at 20k', extra && sim.lives === TUNE.LIVES + 1); sim.addScore(TUNE.EXTRA_LIFE_EVERY); check('another at 40k', sim.lives === TUNE.LIVES + 2); check('start bonus is zero at level 1', startBonus(1) === 0); let bonusMono = true; for (let l = 2; l <= 40; l += 1) if (startBonus(l) <= startBonus(l - 1)) bonusMono = false; check('start bonus grows with depth', bonusMono); const deep = createGame({ seed: 42, startLevel: 9 }); check('deep start seeds the bonus as score', deep.score === startBonus(9)); check('deep-start bonus counts toward the next extra life', deep.nextExtraLifeAt > startBonus(9)); check('skill-step options start at level 1', startLevelOptions(1).join(',') === '1'); const opts = startLevelOptions(20); check('skill-step options are odd steps then wider steps', opts[0] === 1 && opts.includes(15) && opts.every((v, i) => i === 0 || v > opts[i - 1])); check('options never exceed the reached level', opts.every((v) => v <= 20)); } // ── 9. Level flow: clear, warp, spike death ────────────────────────────────── console.log('Level flow'); { const sim = createGame({ seed: 51 }); sim.spawn.remaining = { flipper: 1, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; setFiring(sim, true); let clearedAt = null; let warped = null; for (let i = 0; i < 60 * 60 && warped == null; i += 1) { // chase the last flipper const target = sim.enemies[0]; if (target) setAim(sim, sim.web.laneAngles[sim.visualLane(target)]); for (const e of step(sim, DT)) { if (e.type === 'levelCleared') clearedAt = e.level; if (e.type === 'warpDone') warped = e.level; } } check('killing the whole budget clears the level', clearedAt === 1); check('warp lands on the next level', warped === 2 && sim.level === 2); check('new level rebuilds the web', sim.web === webForLevel(2)); // Warp spike death: costs a life and re-flies the same warp. const sim2 = createGame({ seed: 52 }); sim2.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim2.enemies = []; const lane = sim2.playerLane(); sim2.spikes.fill(0.5); // every lane spiked: no dodging sim2.maybeClearLevel(); check('empty web enters the warp', sim2.phase === 'warp'); const livesBefore = sim2.lives; let spiked = false; let reflew = false; let advanced = false; for (let i = 0; i < 60 * 20; i += 1) { for (const e of step(sim2, DT)) { if (e.type === 'spikeHit') spiked = true; if (e.type === 'warpStart' && e.refly) reflew = true; if (e.type === 'warpDone') advanced = true; } if (spiked && sim2.phase === 'warp' && reflew) { // dodging is impossible (all lanes spiked) — shoot the spike away instead setFiring(sim2, true); } if (advanced || sim2.phase === 'gameover') break; } check('flying into a spike costs a life', spiked && sim2.lives < livesBefore); check('spike death re-flies the warp', reflew); // Death and respite: survivors rejoin the spawn budget. const sim3 = createGame({ seed: 53 }); sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 5) % LANES, 0.5)); sim3.enemies.push(sim3.makeEnemy('spiker', (sim3.playerLane() + 8) % LANES, 0.9)); sim3.killPlayer('shot'); check('death costs a life', sim3.lives === TUNE.LIVES - 1); let respited = false; for (let i = 0; i < 60 * 5; i += 1) { for (const e of step(sim3, DT)) if (e.type === 'respiteStart') respited = true; if (sim3.phase === 'playing') break; } check('respite follows a death with lives left', respited && sim3.phase === 'playing'); check('survivors rejoined the spawn budget', sim3.spawn.remaining.flipper === 1 && sim3.spawn.remaining.spiker === 1 && sim3.enemies.length === 0); // Game over at zero lives. const sim4 = createGame({ seed: 54 }); sim4.lives = 1; sim4.killPlayer('shot'); let over = false; for (let i = 0; i < 60 * 5 && !over; i += 1) { for (const e of step(sim4, DT)) if (e.type === 'gameOver') over = true; } check('last life ends the game', over && sim4.phase === 'gameover'); } // ── 10. Difficulty escalation ──────────────────────────────────────────────── console.log('Difficulty escalation (levels 1-60)'); { let ok = true; for (let l = 2; l <= 60; l += 1) { if (flipperSpeed(l) < flipperSpeed(l - 1)) ok = false; if (enemyShotSpeed(l) < enemyShotSpeed(l - 1)) ok = false; if (enemyFireRate(l) < enemyFireRate(l - 1)) ok = false; if (levelBudget(l) < levelBudget(l - 1)) ok = false; if (maxConcurrent(l) < maxConcurrent(l - 1)) ok = false; if (spawnInterval(l) > spawnInterval(l - 1)) ok = false; if (flipRest(l) > flipRest(l - 1)) ok = false; } check('all difficulty curves monotonic', ok); check('curves stay bounded', flipperSpeed(999) <= TUNE.FLIPPER_SPEED_MAX && levelBudget(999) <= TUNE.BUDGET_MAX && spawnInterval(999) >= TUNE.SPAWN_MS_MIN && flipRest(999) >= TUNE.FLIP_REST_MIN_MS); check('unlock order matches the arcade schedule', TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker && TUNE.UNLOCK.tanker < TUNE.UNLOCK.spiker && TUNE.UNLOCK.spiker < TUNE.UNLOCK.fuseball && TUNE.UNLOCK.fuseball < TUNE.UNLOCK.pulsar); check('five color bands defined', BANDS.length === 5 && BANDS.every((b) => b.web != null && b.lane != null && b.player != null && b.flipper != null)); check('band cycles every 16 levels', bandIndexForLevel(1) === 0 && bandIndexForLevel(16) === 0 && bandIndexForLevel(17) === 1 && bandIndexForLevel(81) === 0); check('canonical enemy colors defined', ENEMY_COLORS.tanker != null && ENEMY_COLORS.spiker != null && ENEMY_COLORS.fuseball != null && ENEMY_COLORS.pulsar != null); } // ── 11. Monte-carlo bot soak ───────────────────────────────────────────────── console.log('Monte-carlo bot soak (40 games)'); { let cleared = 0; let deaths = 0; let maxLevel = 0; let bad = 0; let overs = 0; const started = Date.now(); for (let g = 0; g < 40; g += 1) { const sim = createGame({ seed: 9000 + g, startLevel: 1 + (g % 4) * 4 }); setFiring(sim, true); let steps = 0; const stepCap = 60 * 60 * 6; // six simulated minutes per game while (sim.phase !== 'gameover' && steps < stepCap) { steps += 1; let target = null; for (const e of sim.enemies) if (!target || e.t < target.t) target = e; if (target) { const lane = target.type === 'fuseball' ? target.edge : sim.visualLane(target); const idx = Math.min(lane, sim.web.laneAngles.length - 1); if (target.t < 0.05 && target.type === 'flipper') { // sidestep rim flippers instead of walking into them setAim(sim, sim.web.laneAngles[(idx + 4) % sim.web.laneAngles.length]); } else { setAim(sim, sim.web.laneAngles[idx]); } } if (sim.enemies.length >= 6 && sim.zapper.uses === TUNE.ZAPPER_USES) superzap(sim); for (const e of step(sim, DT)) { if (e.type === 'levelCleared') cleared += 1; if (e.type === 'playerHit') deaths += 1; if (e.type === 'gameOver') overs += 1; } // Per-step invariants. for (const e of sim.enemies) { if (!(e.t >= -0.02 && e.t <= 1.02)) bad += 1; if (e.type === 'fuseball') { if (!(e.edge >= 0 && e.edge < sim.edgeCount())) bad += 1; } else if (!(e.lane >= 0 && e.lane < laneCount(sim.web))) bad += 1; } for (const s of sim.shots) if (!(s.t >= -0.02 && s.t <= 1.02)) bad += 1; for (const s of sim.enemyShots) if (!(s.t >= -0.05 && s.t <= 1.02)) bad += 1; const n = laneCount(sim.web); if (sim.web.closed ? (sim.player.pos < 0 || sim.player.pos >= n) : (sim.player.pos < 0 || sim.player.pos > n - 1)) bad += 1; for (const tip of sim.spikes) if (!(tip >= TUNE.SPIKE_MIN_T - 1e-6 && tip <= 1)) bad += 1; if (sim.shots.length > TUNE.MAX_SHOTS) bad += 1; } maxLevel = Math.max(maxLevel, sim.level); } check('no invariant violations across the soak', bad === 0, `violations=${bad}`); check('bot makes real progress', cleared >= 40 && maxLevel >= 5, `cleared=${cleared} maxLevel=${maxLevel}`); check('deaths occur (the game bites back)', deaths > 0, `deaths=${deaths}`); console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs} maxLevel=${maxLevel} in ${((Date.now() - started) / 1000).toFixed(1)}s`); } // ── Result ─────────────────────────────────────────────────────────────────── if (failures) { console.error(`\n${failures} FAILURE${failures === 1 ? '' : 'S'}`); process.exit(1); } console.log('\nAll checks passed.');