fertig-classic-games/tools/verifyPeggle.js

564 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Headless verification for Peggle.
// node tools/verifyPeggle.js
// Exits non-zero on any failure.
//
// 1. Physics invariants (bounce restitution, wall reflection, anti-tunnel bound,
// bucket catch direction, stuck nudge).
// 2. Rules (peg color assignment, multiplier ladder, free-ball thresholds,
// long shot, fever + bonus buckets, win/lose, purple re-roll).
// 3. Powers (superguide, multiball, spaceblast, fireball, zenball).
// 4. Determinism (seeded replay, frame-rate independence of the substep).
// 5. Data lint (levels.json manifest ↔ level files ↔ opponents.json ↔ POWERS).
//
// Rendering, input, camera fever zoom, portrait overlays, and the editor's
// Blob export are browser-only and must be smoke-tested manually.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
TUNING, SCORING, POWERS,
mulberry32, multiplierFor, createRound, cloneState,
clampAim, aimToVelocity, bucketX,
launchBall, stepSim, resolveFever,
simulatePreview, scoreShot, zenBallOptimize, applyPower,
} from '../src/games/peggle/PeggleLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle');
const T = TUNING;
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// ── Fixtures ─────────────────────────────────────────────────────────────────
function mkLevel(pegs, over = {}) {
return {
board: { width: T.BOARD_W, height: T.BOARD_H },
orangeCount: 0,
greenCount: 0,
powerId: over.powerId ?? null,
pegs,
...over,
};
}
function mkState(pegs, over = {}, stateOver = {}) {
const st = createRound(mkLevel(pegs, over), { seed: over.seed ?? 7 });
Object.assign(st, stateOver);
return st;
}
// Put a single ball in flight manually (bypasses launch bookkeeping).
function inject(st, ball) {
st.phase = 'flight';
st.balls.push({ stuckFor: 0, fireball: false, ...ball });
}
function run(st, seconds, dt = 1 / 60) {
const log = [];
for (let t = 0; t < seconds && (st.phase === 'flight' || st.phase === 'fever'); t += dt) {
log.push(...stepSim(st, dt));
}
return log;
}
// ── 1. Physics ───────────────────────────────────────────────────────────────
console.log('― physics');
check('anti-tunnel: MAX_SPEED × SUBSTEP_DT < BALL_R',
T.MAX_SPEED * T.SUBSTEP_DT < T.BALL_R,
`${T.MAX_SPEED * T.SUBSTEP_DT} !< ${T.BALL_R}`);
{
// Ball dropped straight onto a lone peg bounces back upward.
const st = mkState([{ x: 600, y: 500 }]);
inject(st, { x: 600, y: 430, vx: 0, vy: 200 });
let hit = null;
let vyAfter = null;
let vyBefore = null;
for (let i = 0; i < 600 && !hit; i++) {
vyBefore = st.balls[0]?.vy;
const evs = stepSim(st, 1 / 240);
hit = evs.find((e) => e.type === 'pegHit') ?? null;
if (hit) vyAfter = st.balls[0].vy;
}
check('peg bounce: hit registered', !!hit);
check('peg bounce: ball reflects upward', vyAfter != null && vyAfter < 0, `vy after ${vyAfter}`);
check('peg bounce: restitution ratio ≈ PEG_RESTITUTION',
vyAfter != null && near(Math.abs(vyAfter / vyBefore), T.PEG_RESTITUTION, 0.06),
`|${vyAfter}/${vyBefore}| = ${Math.abs(vyAfter / vyBefore)}`);
check('peg lit but still present until shot ends', st.pegs[0].lit && !st.pegs[0].removed);
}
{
// A lit peg clears out (and stops colliding) PEG_FADE_S after the hit.
const st = mkState([{ x: 600, y: 500 }]);
inject(st, { x: 600, y: 430, vx: 0, vy: 200 });
const log = run(st, T.PEG_FADE_S + 2);
const faded = log.find((e) => e.type === 'pegsCleared' && e.faded);
check('lit peg fades mid-flight after PEG_FADE_S', !!faded && faded.pegIds.includes(0));
check('faded peg no longer collides', st.pegs[0].removed);
}
{
// Wall reflection preserves |vx|·restitution; ball never escapes the walls.
const st = mkState([]);
inject(st, { x: 100, y: 300, vx: -900, vy: 0 });
let wallHit = false;
let vxAfter = null;
for (let i = 0; i < 200 && !wallHit; i++) {
const evs = stepSim(st, 1 / 240);
if (evs.some((e) => e.type === 'wallHit')) { wallHit = true; vxAfter = st.balls[0].vx; }
}
check('wall bounce: reflects rightward', wallHit && vxAfter > 0, `vx ${vxAfter}`);
check('wall bounce: restitution ratio ≈ WALL_RESTITUTION',
vxAfter != null && near(vxAfter / 900, T.WALL_RESTITUTION, 0.05), `${vxAfter / 900}`);
}
{
// Ball inside the bucket opening while descending → catch + free ball.
const st = mkState([]);
st.time = 0;
const bx = bucketX(st);
const ballsBefore = st.ballsLeft;
inject(st, { x: bx, y: T.BUCKET_Y - 30, vx: 0, vy: 300 });
const log = run(st, 1);
check('bucket catch while descending', log.some((e) => e.type === 'bucketCatch'));
check('bucket catch grants a free ball', st.ballsLeft === ballsBefore + 1);
// Same spot moving upward → no catch.
const st2 = mkState([]);
st2.time = 0;
inject(st2, { x: bucketX(st2), y: T.BUCKET_Y - 30, vx: 0, vy: -1200 });
let caught = false;
for (let i = 0; i < 30; i++) {
if (stepSim(st2, 1 / 240).some((e) => e.type === 'bucketCatch')) caught = true;
}
check('no bucket catch while ascending', !caught);
}
{
// Bucket kinematics: pure function of the clock, periodic, within travel.
const st = mkState([]);
st.time = 1.234;
const a = bucketX(st);
st.time = 1.234 + T.BUCKET_PERIOD;
const b = bucketX(st);
check('bucket periodic', near(a, b, 0.001));
let ok = true;
for (let t = 0; t < T.BUCKET_PERIOD; t += 0.05) {
st.time = t;
const x = bucketX(st);
if (x < T.BUCKET_MARGIN || x > st.board.width - T.BUCKET_MARGIN) ok = false;
}
check('bucket stays within travel bounds', ok);
}
{
// A ball resting on a peg gets nudged free.
const st = mkState([{ x: 600, y: 500 }]);
inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 });
const log = run(st, 6);
check('stuck ball receives a nudge', log.some((e) => e.type === 'nudge'));
}
{
// Failsafe: flight never exceeds MAX_FLIGHT_S.
const st = mkState([{ x: 600, y: 500 }]);
inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 });
const log = run(st, T.MAX_FLIGHT_S + 10);
check('shot always resolves (failsafe or exit)', st.balls.length === 0 && st.phase !== 'flight' || st.phase === 'aim',
`phase ${st.phase}, balls ${st.balls.length}`);
}
// ── 2. Rules ─────────────────────────────────────────────────────────────────
console.log('― rules');
{
// Color assignment on a real generated level.
const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8'));
const st = createRound(lvl, { seed: 123 });
const count = (c) => st.pegs.filter((p) => p.color === c).length;
check('createRound assigns exactly orangeCount orange', count('orange') === lvl.orangeCount, `${count('orange')}`);
check('createRound assigns exactly greenCount green', count('green') === lvl.greenCount, `${count('green')}`);
check('createRound assigns exactly one purple', count('purple') === 1);
check('orange pegs only on eligible slots',
st.pegs.every((p) => p.color !== 'orange' || lvl.pegs[p.id].orangeEligible));
check('purple/green never on the same peg', st.pegs[st.purpleId].color === 'purple');
check('10 balls to start', st.ballsLeft === T.BALLS_PER_LEVEL);
}
check('multiplier ladder ×1 →', multiplierFor(0) === 1 && multiplierFor(9) === 1);
check('multiplier ladder ×2 @10', multiplierFor(10) === 2 && multiplierFor(14) === 2);
check('multiplier ladder ×3 @15', multiplierFor(15) === 3 && multiplierFor(19) === 3);
check('multiplier ladder ×5 @20', multiplierFor(20) === 5 && multiplierFor(21) === 5);
check('multiplier ladder ×10 @22', multiplierFor(22) === 10 && multiplierFor(25) === 10);
{
// Peg values scale with the multiplier in effect before the hit.
const st = mkState([{ x: 600, y: 500, orangeEligible: true }], { orangeCount: 1 }, { orangeCleared: 0 });
st.orangeTotal = 5; // avoid fever for this test
st.orangeCleared = 22;
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const hit = log.find((e) => e.type === 'pegHit');
check('orange peg at ×10 scores 1000', hit && hit.points === SCORING.PEG_BASE.orange * 10, JSON.stringify(hit));
}
{
// Free-ball thresholds fire exactly once each.
const st = mkState([{ x: 600, y: 500 }], {}, { score: 24995 });
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const fb = log.filter((e) => e.type === 'freeBall' && e.reason === 'threshold');
check('crossing 25k grants one free ball', fb.length === 1 && fb[0].threshold === 25000, JSON.stringify(fb));
check('threshold not re-awarded', st.freeBallsGiven === 1);
const st2 = mkState([{ x: 600, y: 500 }], {}, { score: 130000, freeBallsGiven: 0 });
inject(st2, { x: 600, y: 450, vx: 0, vy: 200 });
const log2 = run(st2, 2);
const fb2 = log2.filter((e) => e.type === 'freeBall' && e.reason === 'threshold');
check('multiple owed thresholds granted together', fb2.length === 3 && st2.freeBallsGiven === 3);
}
{
// Long Shot: orange hit ≥ LONG_SHOT_DIST from previous peg contact with a
// wall bounce in between.
const st = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 });
st.orangeTotal = 5;
inject(st, { x: 900, y: 450, vx: 0, vy: 200 });
st.lastPegHit = { x: 200, y: 300 };
st.wallBouncesSincePeg = 1;
const log = run(st, 2);
check('long shot bonus awarded', log.some((e) => e.type === 'longShot'));
const st2 = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 });
st2.orangeTotal = 5;
inject(st2, { x: 900, y: 450, vx: 0, vy: 200 });
st2.lastPegHit = { x: 200, y: 300 };
st2.wallBouncesSincePeg = 0;
const log2 = run(st2, 2);
check('no long shot without a wall bounce', !log2.some((e) => e.type === 'longShot'));
}
{
// Fever: lighting the last orange starts fever; bottom exit resolves it into
// a bonus bucket; every leftover peg is swept.
const st = mkState([
{ x: 600, y: 500, orangeEligible: true },
{ x: 200, y: 700 }, { x: 1000, y: 700 },
], { orangeCount: 1 });
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 10);
check('fever starts on last orange', log.some((e) => e.type === 'feverStart'));
const res = log.find((e) => e.type === 'feverResolve');
check('fever resolves with a bucket', !!res && SCORING.FEVER_BUCKETS.includes(res.bucketPoints));
check('fever sweeps leftover pegs', !!res && res.pegIds.length === 2, res && JSON.stringify(res.pegIds));
check('fever ends in a win', st.phase === 'won' && log.some((e) => e.type === 'win'));
}
{
// Fever bucket mapping across the bottom fifths.
const n = SCORING.FEVER_BUCKETS.length;
let ok = true;
for (let i = 0; i < n; i++) {
const st = mkState([]);
st.phase = 'fever';
const x = ((i + 0.5) / n) * st.board.width;
const evs = resolveFever(st, x);
const r = evs.find((e) => e.type === 'feverResolve');
if (!r || r.bucketIndex !== i || r.bucketPoints !== SCORING.FEVER_BUCKETS[i]) ok = false;
}
check('fever bucket x → index mapping', ok);
}
{
// Lose: last ball drained with orange remaining.
const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 });
st.ballsLeft = 1;
const evs = launchBall(st, 0); // straight down the middle, missing the peg
const log = run(st, 10);
check('losing shot ends the round', st.phase === 'lost' && log.some((e) => e.type === 'lose'), `phase ${st.phase}`);
}
{
// Purple re-rolls between shots; lit pegs removed at shot end. An orange peg
// far off the drop path keeps the round alive (orangeTotal 0 means instant win).
const st = mkState(
[{ x: 600, y: 500 }, { x: 300, y: 400 }, { x: 900, y: 400 }, { x: 450, y: 640 },
{ x: 100, y: 780, orangeEligible: true }],
{ orangeCount: 1 }, { seed: 5 });
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 10);
const clearedEv = log.find((e) => e.type === 'pegsCleared');
check('lit pegs removed at shot end', !!clearedEv && clearedEv.pegIds.includes(0) && st.pegs[0].removed);
check('back to aim with a purple somewhere',
st.phase === 'aim' && st.purpleId != null && st.pegs[st.purpleId].color === 'purple');
}
// ── 3. Powers ────────────────────────────────────────────────────────────────
console.log('― powers');
{
const st = mkState([]);
applyPower(st, 'superguide');
check('superguide charges 3 shots', st.superGuideShots === 3);
launchBall(st, 0);
check('superguide decrements per launch', st.superGuideShots === 2);
}
{
const st = mkState([]);
inject(st, { x: 500, y: 400, vx: 320, vy: -100 });
const evs = applyPower(st, 'multiball', { ball: st.balls[0] });
check('multiball spawns a second ball', st.balls.length === 2 && evs.some((e) => e.type === 'multiball'));
check('multiball mirrors vx', st.balls[1].vx === -320 && st.balls[1].vy === -100);
}
{
const pegs = [
{ x: 600, y: 500 }, // green (via forced color below)
{ x: 600, y: 560 }, // within 150
{ x: 700, y: 450 }, // within 150
{ x: 1100, y: 200 }, // far away
];
const st = mkState(pegs, { powerId: 'spaceblast' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const blast = log.find((e) => e.type === 'spaceBlast');
check('green peg triggers space blast', !!blast);
check('space blast lights only nearby pegs',
!!blast && blast.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
blast && JSON.stringify(blast.pegIds));
check('blast pegs are scored', log.filter((e) => e.type === 'pegHit' && e.fromBlast).length === 2);
}
{
// Fireball burns through a column; a normal ball reflects off the top peg.
const pegs = [{ x: 600, y: 400 }, { x: 600, y: 480 }, { x: 600, y: 560 }];
const stNorm = mkState(pegs);
launchBall(stNorm, 0);
run(stNorm, 12);
const normHits = stNorm.pegs.filter((p) => p.removed).length;
const stFire = mkState(pegs);
applyPower(stFire, 'fireball');
check('fireball charges next ball', stFire.fireballNext === 1);
launchBall(stFire, 0);
check('fireball flag applied on launch', stFire.balls[0].fireball === true && stFire.fireballNext === 0);
run(stFire, 12);
check('fireball lights the whole column', stFire.pegs.every((p) => p.removed),
`${stFire.pegs.filter((p) => p.removed).length}/3 vs normal ${normHits}`);
}
{
// Zen ball: substituted angle never scores worse than the raw aim.
const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8'));
const st = createRound(lvl, { seed: 99 });
const rawAngle = 0.35;
const rawScore = scoreShot(st, rawAngle);
const best = zenBallOptimize(st, rawAngle, { samples: 9 });
check('zen ball never scores worse', best.score >= rawScore, `${best.score} < ${rawScore}`);
applyPower(st, 'zenball');
const evs = launchBall(st, rawAngle);
check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball'));
}
// ── 4. Determinism ───────────────────────────────────────────────────────────
console.log('― determinism');
{
const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-002.json'), 'utf8'));
const logOf = (dt) => {
const st = createRound(lvl, { seed: 4242 });
const log = [...launchBall(st, 0.42)];
for (let t = 0; t < 30 && st.phase === 'flight'; t += dt) log.push(...stepSim(st, dt));
return { log: log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|'), score: st.score };
};
const a = logOf(1 / 60);
const b = logOf(1 / 60);
check('seeded replay identical', a.log === b.log && a.score === b.score);
const c = logOf(1 / 120);
check('frame-rate independent (1/60 vs 1/120)', a.log === c.log && a.score === c.score,
`\n a: ${a.log}\n c: ${c.log}`);
}
{
// Preview is a dry run: it must not disturb the live state.
const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-003.json'), 'utf8'));
const st = createRound(lvl, { seed: 31337 });
const snap = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase });
const prev = simulatePreview(st, 0.2, { maxPegHits: 3 });
const snap2 = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase });
check('preview leaves live state untouched', snap === snap2);
check('preview produces a path', prev.points.length > 5);
// Preview trajectory agrees with the real shot up to the first peg contact.
const st2 = cloneState(st);
const log = [...launchBall(st2, 0.2)];
for (let t = 0; t < 20 && st2.phase === 'flight' && !log.some((e) => e.type === 'pegHit'); t += 1 / 120) {
log.push(...stepSim(st2, 1 / 120));
}
const firstReal = log.find((e) => e.type === 'pegHit');
const stP = cloneState(st);
const logP = [...launchBall(stP, 0.2)];
for (let t = 0; t < 20 && stP.phase === 'flight' && !logP.some((e) => e.type === 'pegHit'); t += 1 / 120) {
logP.push(...stepSim(stP, 1 / 120));
}
const firstPrev = logP.find((e) => e.type === 'pegHit');
check('preview and live shot hit the same first peg',
firstReal && firstPrev && firstReal.pegId === firstPrev.pegId);
}
{
const r1 = mulberry32(555);
const r2 = mulberry32(555);
const seq1 = [r1.next(), r1.next(), r1.next()];
const seq2 = [r2.next(), r2.next(), r2.next()];
check('mulberry32 deterministic', seq1.join() === seq2.join());
const r3 = mulberry32(0);
r3.state = r1.state;
check('rng state clone resumes identically', r3.next() === r2.next());
}
check('aim clamps to ±MAX_AIM_DEG', clampAim(3) === (T.MAX_AIM_DEG * Math.PI) / 180 && clampAim(-3) === -(T.MAX_AIM_DEG * Math.PI) / 180);
{
const v = aimToVelocity(0);
check('straight-down launch', near(v.vx, 0, 0.001) && near(v.vy, T.LAUNCH_SPEED, 0.001));
}
// ── 5. Data lint ─────────────────────────────────────────────────────────────
console.log('― data lint');
{
const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8'));
const opponents = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'opponents.json'), 'utf8'));
const roster = new Set((opponents.opponents ?? opponents).map((o) => o.id));
check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length >= 5);
check('manifest levels sequential from 1',
manifest.levels.every((l, i) => l.level === i + 1));
// Friend blocks: 5 consecutive levels per master, in the expected order,
// one power per block. The intro screen keys off masterId changing between
// consecutive entries, so block integrity matters.
const BLOCK_ORDER = ['ethel', 'kona', 'zanthor', 'fireball', 'victor'];
check('25 levels in 5 friend blocks', manifest.levels.length === 25);
let blocksOk = true;
for (let b = 0; b < BLOCK_ORDER.length; b++) {
const block = manifest.levels.slice(b * 5, b * 5 + 5);
if (!block.every((l) => l.masterId === BLOCK_ORDER[b])) blocksOk = false;
if (new Set(block.map((l) => l.powerId)).size !== 1) blocksOk = false;
}
check('blocks of 5 share master+power in order', blocksOk);
const introLevels = manifest.levels
.filter((l, i) => i === 0 || manifest.levels[i - 1].masterId !== l.masterId)
.map((l) => l.level);
check('masterId-change rule marks intros at 1,6,11,16,21', introLevels.join(',') === '1,6,11,16,21', introLevels.join(','));
check('level names unique', new Set(manifest.levels.map((l) => l.name)).size === manifest.levels.length);
check('manifest masters exist in opponents.json',
manifest.levels.every((l) => roster.has(l.masterId)),
manifest.levels.filter((l) => !roster.has(l.masterId)).map((l) => l.masterId).join(','));
check('manifest powers exist in POWERS',
manifest.levels.every((l) => POWERS[l.powerId]));
check('manifest names/files present',
manifest.levels.every((l) => l.name && /^level-\d{3}\.json$/.test(l.file)));
for (const entry of manifest.levels) {
const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8'));
const tag = entry.file;
check(`${tag}: board is ${T.BOARD_W}×${T.BOARD_H}`,
lvl.board.width === T.BOARD_W && lvl.board.height === T.BOARD_H);
check(`${tag}: enough orange-eligible pegs`,
lvl.pegs.filter((p) => p.orangeEligible).length >= lvl.orangeCount);
check(`${tag}: enough pegs for orange+green+purple`,
lvl.pegs.length >= lvl.orangeCount + lvl.greenCount + 1);
check(`${tag}: pegs in bounds and clear of launcher/bucket lanes`,
lvl.pegs.every((p) => p.x >= p.r && p.x <= T.BOARD_W - p.r && p.y >= 140 && p.y <= 820));
let overlap = false;
for (let i = 0; i < lvl.pegs.length && !overlap; i++) {
for (let j = i + 1; j < lvl.pegs.length; j++) {
const a = lvl.pegs[i];
const b = lvl.pegs[j];
const min = a.r + b.r + 6;
if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < min * min) { overlap = true; break; }
}
}
check(`${tag}: no overlapping pegs`, !overlap);
// Every level must be playable: a straight-ish shot hits at least one peg.
const st = createRound(lvl, { seed: 1 });
let anyHit = false;
for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) {
if (scoreShot(st, ang) > 0) { anyHit = true; break; }
}
check(`${tag}: pegs are reachable`, anyHit);
}
}
// ── Soak: full rounds at varied aims stay well-formed ────────────────────────
console.log('― soak');
{
const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8'));
let wins = 0;
let games = 0;
let invariantOk = true;
for (const entry of manifest.levels) {
const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8'));
for (let g = 0; g < 3; g++) {
games++;
const st = createRound({ ...lvl, powerId: entry.powerId }, { seed: g * 977 + entry.level });
const rng = mulberry32(g * 31 + 7);
let guard = 0;
// Generous guard: 10+ balls × up to MAX_FLIGHT_S each at 60 steps/s.
while (st.phase !== 'won' && st.phase !== 'lost' && guard++ < 60 * 60 * 15) {
if (st.phase === 'aim') {
// Aim at a random remaining unlit peg, then (like a player reading
// the aim guide) try a few nearby angles and take the best-scoring.
const targets = st.pegs.filter((p) => !p.removed && (p.color === 'orange' || rng.next() < 0.3));
const tgt = targets[Math.floor(rng.next() * targets.length)] ?? { x: 600, y: 600 };
const base = Math.atan2(tgt.x - T.LAUNCH_X, tgt.y - T.LAUNCH_Y);
let angle = base;
let bestScore = -1;
for (const off of [-0.22, -0.1, 0, 0.1, 0.22]) {
const s = scoreShot(st, base + off, { maxTime: 25 });
if (s > bestScore) { bestScore = s; angle = base + off; }
}
launchBall(st, angle);
}
stepSim(st, 1 / 60);
const cleared = st.pegs.filter((p) => p.color === 'orange' && (p.removed || p.lit)).length;
if (cleared !== st.orangeCleared) invariantOk = false;
if (st.score < 0 || st.ballsLeft < 0) invariantOk = false;
}
if (st.phase === 'won') wins++;
if (st.phase !== 'won' && st.phase !== 'lost') invariantOk = false;
}
}
check('soak: every game terminates cleanly', invariantOk);
check('soak: naive aim-at-orange wins sometimes', wins > 0, `${wins}/${games}`);
console.log(` soak result: ${wins}/${games} naive-aim wins`);
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${passes} passed, ${failures} failed`);
process.exit(failures ? 1 : 0);