637 lines
30 KiB
JavaScript
637 lines
30 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, flipClimbCooldown, 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 deepPulseDeath = false; let deepFired = 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' && e.cause === 'pulsar') deepPulseDeath = true;
|
|
if (e.type === 'enemyShotFired') deepFired = true;
|
|
}
|
|
if (sim9.phase !== 'playing') break;
|
|
}
|
|
check('a pulsar\'s pulse is harmless deep in the well', !deepPulseDeath);
|
|
check('but a deep pulsar still shoots', deepFired);
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
// A cleared level hands back one charge, not a full refill — burning both
|
|
// has to cost something.
|
|
sim.enterLevel(sim.level + 1);
|
|
check('a new level grants exactly one zapper charge',
|
|
sim.zapper.uses === TUNE.ZAPPER_REFILL, `uses=${sim.zapper.uses}`);
|
|
sim.enterLevel(sim.level + 1);
|
|
check('zapper charge caps at ZAPPER_USES',
|
|
sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.zapper.uses}`);
|
|
sim.enterLevel(sim.level + 1);
|
|
check('zapper does not exceed its cap',
|
|
sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.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 is not a reset: only the enemies that cornered the claw go up with
|
|
// it, the rest of the wave stays on the web, and nothing is refunded.
|
|
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.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 2) % LANES, 0.1));
|
|
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('deep enemies survive the player\'s death',
|
|
sim3.enemies.length === 2 && sim3.enemies.every((e) => e.t >= TUNE.DEATH_CLEAR_T),
|
|
`left=${sim3.enemies.length}`);
|
|
check('the enemies that cornered the claw are cleared',
|
|
!sim3.enemies.some((e) => e.t < TUNE.DEATH_CLEAR_T));
|
|
check('death does not refund the spawn budget', sim3.budgetTotal() === 0,
|
|
`budget=${sim3.budgetTotal()}`);
|
|
|
|
// 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);
|
|
|
|
// Timings genuinely cannot fall to zero, so they keep hard floors.
|
|
check('timing curves respect their floors',
|
|
spawnInterval(999) >= TUNE.SPAWN_MS_MIN
|
|
&& flipRest(999) >= TUNE.FLIP_REST_MIN_MS
|
|
&& flipClimbCooldown(999) >= TUNE.FLIP_CLIMB_COOLDOWN_MIN_MS);
|
|
|
|
// The growth curves must NOT plateau — endless play has to keep ramping
|
|
// instead of replaying level 44 forever.
|
|
check('growth curves still rise deep into an endless run',
|
|
flipperSpeed(200) > flipperSpeed(100)
|
|
&& enemyShotSpeed(200) > enemyShotSpeed(100)
|
|
&& enemyFireRate(200) > enemyFireRate(100)
|
|
&& levelBudget(200) > levelBudget(100));
|
|
|
|
// ...but stay sane: a flipper must always be slower than a player shot, or
|
|
// it could never be shot down on approach.
|
|
check('growth curves stay under their sanity ceilings',
|
|
flipperSpeed(9999) <= TUNE.FLIPPER_SPEED_HARD_MAX
|
|
&& TUNE.FLIPPER_SPEED_HARD_MAX < TUNE.SHOT_SPEED
|
|
&& enemyShotSpeed(9999) <= TUNE.ENEMY_SHOT_SPEED_HARD_MAX
|
|
&& enemyFireRate(9999) <= TUNE.ENEMY_FIRE_HARD_MAX
|
|
&& levelBudget(9999) <= TUNE.BUDGET_HARD_MAX
|
|
&& maxConcurrent(9999) <= TUNE.CONCURRENT_MAX);
|
|
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;
|
|
let gained = 0; // total levels *gained*, since games start at 1/5/9/13
|
|
const started = Date.now();
|
|
for (let g = 0; g < 40; g += 1) {
|
|
const startLevel = 1 + (g % 4) * 4;
|
|
const sim = createGame({ seed: 9000 + g, startLevel });
|
|
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);
|
|
gained += sim.level - startLevel;
|
|
}
|
|
const avgRun = gained / 40;
|
|
check('no invariant violations across the soak', bad === 0, `violations=${bad}`);
|
|
|
|
// Difficulty regression guard. This bot is deliberately naive (aim at the
|
|
// nearest enemy, hold fire, panic-zap) — it stands in for a careless player.
|
|
// Before the retune it posted deaths=2, gameovers=0, maxLevel=22 across
|
|
// these same 40 games, i.e. it could not lose. It now has to lose.
|
|
check('a careless player actually loses', overs >= 30, `gameovers=${overs}/40`);
|
|
check('deaths are common, not incidental', deaths / 40 >= 3,
|
|
`deaths=${deaths} (${(deaths / 40).toFixed(1)}/game)`);
|
|
check('but level 1 is still winnable', cleared >= 40, `cleared=${cleared}`);
|
|
// Mean levels gained per run — far stabler across seeds than a max, and the
|
|
// number to watch if this is ever retuned again. Too low means level 1 got
|
|
// unfair; too high means the retune has eroded.
|
|
check('the naive bot does not cruise', avgRun <= 6, `avgRun=${avgRun.toFixed(1)}`);
|
|
check('runs are not instantly fatal', avgRun >= 1, `avgRun=${avgRun.toFixed(1)}`);
|
|
console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs}`
|
|
+ ` maxLevel=${maxLevel} avgRun=+${avgRun.toFixed(1)} 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.');
|