fertig-classic-games/tools/verifyTempest.js

581 lines
27 KiB
JavaScript

// 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.');