fertig-classic-games/tools/verifyPeggle.js

896 lines
39 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, bucketWidth, tessellateCurves,
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);
// Peg ids run over circles then tessellated bricks, in order.
const defs = [...lvl.pegs, ...tessellateCurves(lvl.curves ?? [])];
check('orange pegs only on eligible slots',
st.pegs.every((p) => p.color !== 'orange' || defs[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 three more balls', st.balls.length === 4 && evs.some((e) => e.type === 'multiball'));
check('multiball mirrors vx', st.balls[1].vx === -320 && st.balls[1].vy === -100);
check('multiball clones preserve speed', [2, 3].every((i) => {
const b = st.balls[i];
return Math.abs(Math.hypot(b.vx, b.vy) - Math.hypot(320, 100)) < 1e-9;
}));
const mbEvent = evs.find((e) => e.type === 'multiball');
check('multiball event carries source position', mbEvent?.x === 500 && mbEvent?.y === 400);
}
{
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');
check('zen ball grants 3 stacking charges', st.zenNext === 3);
const evs = launchBall(st, rawAngle);
check('zen launch consumes one charge and fires', st.zenNext === 2 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball'));
check('zen launch sets cosmetic ball.zen flag', st.balls[0]?.zen === true);
run(st, 10);
launchBall(st, rawAngle);
run(st, 10);
launchBall(st, rawAngle);
check('zen charges deplete after 3 shots', st.zenNext === 0);
}
// ── 3a. Brick curves ─────────────────────────────────────────────────────────
console.log('― brick curves');
{
// Straight segment: evenly spaced bricks along the chord, angle ≈ 0.
const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 560, y: 400 }], bends: [0], orangeEligible: true }]);
check('straight curve brick count', bricks.length === Math.floor(360 / T.BRICK_LEN), `${bricks.length}`);
check('straight curve bricks level and flat',
bricks.every((b) => near(b.y, 400, 0.5) && near(b.angle, 0, 0.01)));
const gaps = bricks.slice(1).map((b, i) => b.x - bricks[i].x);
check('straight curve spacing = BRICK_LEN', gaps.every((g) => near(g, T.BRICK_LEN, 0.5)), gaps.join(','));
check('curve bricks inherit eligibility', bricks.every((b) => b.orangeEligible));
}
{
// Bent segment: the middle brick bows toward the bend side (+perp is +y
// for a left→right chord), ends stay near the anchors.
const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 800, y: 400 }], bends: [100], orangeEligible: false }]);
const mid = bricks[Math.floor(bricks.length / 2)];
check('bent curve bows at the middle', mid.y > 435 && mid.y < 465, `${mid.y}`);
check('bent curve stays anchored at the ends', bricks[0].y < 420 && bricks[bricks.length - 1].y < 420);
check('bent curve bricks rotate along the tangent',
bricks[0].angle > 0.05 && bricks[bricks.length - 1].angle < -0.05,
`${bricks[0].angle} .. ${bricks[bricks.length - 1].angle}`);
}
{
// Ball bounces off a horizontal brick face like a peg (lights + restitution).
const lvl = mkLevel([], {
curves: [{ anchors: [{ x: 450, y: 500 }, { x: 750, y: 500 }], bends: [0], orangeEligible: true }],
});
const st = createRound(lvl, { seed: 7 });
check('bricks join the peg list', st.pegs.length >= 8 && st.pegs.every((p) => p.shape === 'brick'));
st.orangeTotal = 99; // keep the round alive
inject(st, { x: 600, y: 430, vx: 0, vy: 200 });
let vyBefore = null;
let hit = null;
for (let i = 0; i < 600 && !hit; i++) {
vyBefore = st.balls[0]?.vy;
hit = stepSim(st, 1 / 240).find((e) => e.type === 'pegHit') ?? null;
}
check('brick hit registered and lit', !!hit && st.pegs[hit.pegId].lit);
check('brick bounce reflects upward with peg restitution',
st.balls[0].vy < 0 && near(Math.abs(st.balls[0].vy / vyBefore), T.PEG_RESTITUTION, 0.06),
`${Math.abs(st.balls[0]?.vy / vyBefore)}`);
}
{
// A 45° brick wall deflects a vertical drop sideways.
const lvl = mkLevel([], {
curves: [{ anchors: [{ x: 480, y: 380 }, { x: 720, y: 620 }], bends: [0], orangeEligible: false }],
});
const st = createRound(lvl, { seed: 7 });
st.orangeTotal = 99;
inject(st, { x: 600, y: 420, vx: 0, vy: 300 });
let hit = false;
for (let i = 0; i < 600 && !hit; i++) {
hit = stepSim(st, 1 / 240).some((e) => e.type === 'pegHit');
}
check('45° brick deflects the ball sideways', hit && Math.abs(st.balls[0].vx) > 60, `vx ${st.balls[0]?.vx}`);
}
{
// Round integration: brick curves join orange assignment and the fever sweep.
const lvl = mkLevel([], {
orangeCount: 5,
curves: [{ anchors: [{ x: 300, y: 500 }, { x: 900, y: 500 }], bends: [-60], orangeEligible: true }],
});
const st = createRound(lvl, { seed: 11 });
const orange = st.pegs.filter((p) => p.color === 'orange').length;
check('bricks receive orange assignment', orange === 5, `${orange}`);
check('bricks host the purple bonus', st.purpleId != null && st.pegs[st.purpleId].color === 'purple');
// Determinism replay over a level with curves.
const logOf = () => {
const s = createRound(lvl, { seed: 999 });
const log = [...launchBall(s, 0.3)];
for (let t = 0; t < 25 && s.phase === 'flight'; t += 1 / 60) log.push(...stepSim(s, 1 / 60));
return log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|');
};
check('curved levels replay deterministically', logOf() === logOf());
}
// ── 3b. New powers (for the next wave of friends) ────────────────────────────
console.log('― powers (new wave)');
{
// Body Slam: big heavy ball that keeps its speed off pegs.
const st = mkState([{ x: 600, y: 500 }]);
applyPower(st, 'bodyslam');
check('bodyslam charges', st.heavyNext === 1);
launchBall(st, 0);
const ball = st.balls[0];
check('slam ball is oversized', ball.heavy && ball.r === T.BALL_R * POWERS.bodyslam.params.radiusScale);
check('slam charge consumed', st.heavyNext === 0);
let vyBefore = null;
let vyAfter = null;
for (let i = 0; i < 2000 && vyAfter == null; i++) {
vyBefore = st.balls[0]?.vy;
const evs = stepSim(st, 1 / 240);
if (evs.some((e) => e.type === 'pegHit')) vyAfter = st.balls[0]?.vy;
}
check('slam ball keeps ≈95% speed off pegs',
vyAfter != null && near(Math.abs(vyAfter / vyBefore), POWERS.bodyslam.params.restitution, 0.06),
`${Math.abs(vyAfter / vyBefore)}`);
}
{
// Laser Sweep: lights exactly the green peg's row.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 200, y: 505 }, { x: 1000, y: 495 }, // same row
{ x: 600, y: 300 }, // off row
], { powerId: 'lasergrid' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const laser = log.find((e) => e.type === 'laserRow');
check('laser lights exactly its row', !!laser && laser.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
laser && JSON.stringify(laser.pegIds));
}
{
// Beam Up: a draining ball returns from the top once, then drains normally.
const st = mkState([]);
inject(st, { x: 300, y: 700, vx: 0, vy: 400 });
applyPower(st, 'beamup', { ball: st.balls[0] });
check('beam up arms the ball', st.balls[0].spooky === 1);
let beamed = false;
let lost = false;
for (let i = 0; i < 4000 && !lost; i++) {
const evs = stepSim(st, 1 / 60);
for (const e of evs) {
if (e.type === 'beamUp') {
beamed = true;
check('beamed ball re-enters at the top, same x',
near(st.balls[0].x, 300, 1) && st.balls[0].y < 50, `x ${st.balls[0]?.x} y ${st.balls[0]?.y}`);
}
if (e.type === 'ballLost') lost = true;
}
}
check('beam up fires once then the ball drains', beamed && lost && st.balls.length === 0);
}
{
// Meteor Strike: lights exactly the green peg's column.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 640, y: 250 }, { x: 570, y: 700 }, // same column
{ x: 900, y: 500 }, // off column
], { powerId: 'meteor' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const met = log.find((e) => e.type === 'meteorColumn');
check('meteor lights exactly its column', !!met && met.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
met && JSON.stringify(met.pegIds));
}
{
// Overclock: doubles peg scoring for the rest of the shot only. An orange
// far off the path keeps the round alive; force the target peg blue (the
// round's purple assignment would otherwise land on it).
const st = mkState([{ x: 600, y: 500 }, { x: 100, y: 780, orangeEligible: true }], { orangeCount: 1 });
st.pegs[0].color = 'blue';
st.purpleId = null;
applyPower(st, 'overclock');
check('overclock active', st.overclockShot === true);
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 10);
const hit = log.find((e) => e.type === 'pegHit');
check('overclocked blue peg scores 20', hit && hit.points === SCORING.PEG_BASE.blue * 2, JSON.stringify(hit));
launchBall(st, 0);
check('overclock expires at next launch', st.overclockShot === false);
}
{
// Extreme Ball: faster launch, lossless wall bounces.
const st = mkState([]);
applyPower(st, 'extremeball');
launchBall(st, 0.4);
const b = st.balls[0];
const speed = Math.hypot(b.vx, b.vy);
check('extreme ball launches at scaled speed',
near(speed, T.LAUNCH_SPEED * POWERS.extremeball.params.speedScale, 1), `${speed}`);
const st2 = mkState([]);
inject(st2, { x: 100, y: 300, vx: -900, vy: 0, extreme: true, r: T.BALL_R });
let vxAfter = null;
for (let i = 0; i < 200 && vxAfter == null; i++) {
if (stepSim(st2, 1 / 240).some((e) => e.type === 'wallHit')) vxAfter = st2.balls[0].vx;
}
check('extreme ball loses nothing to walls', vxAfter != null && near(vxAfter, 900, 1), `${vxAfter}`);
}
{
// Cannonball: plows through exactly 3 fresh pegs.
const pegs = [{ x: 600, y: 350 }, { x: 600, y: 430 }, { x: 600, y: 510 }, { x: 600, y: 590 }];
const st = mkState(pegs);
inject(st, { x: 600, y: 280, vx: 0, vy: 400 });
applyPower(st, 'cannonball', { ball: st.balls[0] });
check('cannonball arms 3 pierces', st.balls[0].pierce === 3);
run(st, 4);
const litOrRemoved = st.pegs.filter((p) => p.lit || p.removed).length;
check('cannonball pierces the first 3 then bounces', litOrRemoved >= 3 && st.pegs[0].lit !== undefined,
`${litOrRemoved} pegs lit`);
}
{
// Beaver Dam: wider opening catches wide, expires after 3 launches.
const st = mkState([]);
applyPower(st, 'beaverdam');
check('dam charges 3 shots', st.wideBucketShots === 3);
check('dam widens the bucket', bucketWidth(st) === T.BUCKET_W * POWERS.beaverdam.params.widthScale);
st.time = 0;
const bx = bucketX(st);
const wideX = bx + T.BUCKET_W / 2 + 20; // outside normal opening, inside dam
st.phase = 'flight';
st.wideBucketShots = 3;
st.balls.push({ x: wideX, y: T.BUCKET_Y - 30, vx: 0, vy: 300, stuckFor: 0, fireball: false, r: T.BALL_R, pierce: 0, spooky: 0 });
let caught = false;
for (let i = 0; i < 60 && !caught; i++) {
caught = stepSim(st, 1 / 240).some((e) => e.type === 'bucketCatch');
}
check('dam catches outside the normal opening', caught);
const st3 = mkState([]);
applyPower(st3, 'beaverdam');
launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim';
launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim';
launchBall(st3, 0);
check('dam expires after 3 launches', st3.wideBucketShots === 0 && bucketWidth(st3) === T.BUCKET_W);
}
{
// Shadow Slash: lights the diagonals, not the straights.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 700, y: 600 }, { x: 480, y: 620 }, // on the two diagonals
{ x: 720, y: 500 }, // straight right — off
], { powerId: 'shadowslash' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const slash = log.find((e) => e.type === 'shadowSlash');
check('slash lights only the diagonals', !!slash && slash.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
slash && JSON.stringify(slash.pegIds));
}
{
// Tailwind: floaty ball falls measurably slower.
const stA = mkState([]);
applyPower(stA, 'tailwind');
launchBall(stA, 0);
const stB = mkState([]);
launchBall(stB, 0);
for (let i = 0; i < 30; i++) { stepSim(stA, 1 / 60); stepSim(stB, 1 / 60); }
check('tailwind ball hangs higher', (stA.balls[0]?.y ?? 9999) < (stB.balls[0]?.y ?? 0),
`floaty y ${stA.balls[0]?.y} vs normal y ${stB.balls[0]?.y}`);
}
{
// Cosmic Bloom: exactly 2 blues become green, and they're real power pegs.
const st = mkState([
{ x: 200, y: 300 }, { x: 400, y: 300 }, { x: 600, y: 300 }, { x: 800, y: 300 }, { x: 1000, y: 300 },
], { powerId: 'cosmicbloom' });
const evs = applyPower(st, 'cosmicbloom');
const conv = evs.find((e) => e.type === 'pegsConverted');
const greens = st.pegs.filter((p) => p.color === 'green');
check('bloom converts exactly 2 blues to green',
!!conv && conv.pegIds.length === POWERS.cosmicbloom.params.blooms && greens.length >= 2,
`${greens.length} greens`);
}
{
// Rewind: a drained ball is refunded once.
const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 });
applyPower(st, 'rewind');
check('rewind charges', st.rewindCharges === 1);
const before = st.ballsLeft;
launchBall(st, 0); // misses the lone corner peg
const log = run(st, 10);
check('rewind refunds the drained ball',
log.some((e) => e.type === 'rewind') && log.some((e) => e.type === 'freeBall' && e.reason === 'rewind'));
check('ballsLeft restored, charge spent', st.ballsLeft === before && st.rewindCharges === 0,
`ballsLeft ${st.ballsLeft} vs ${before}`);
}
{
// Every power id survives applyPower on a bare state.
let ok = true;
for (const id of Object.keys(POWERS)) {
try {
const st = mkState([{ x: 600, y: 500 }]);
applyPower(st, id, { peg: st.pegs[0] });
} catch (err) { ok = false; console.error(` applyPower(${id}) threw: ${err.message}`); }
}
check('all POWERS ids apply cleanly', ok);
check('all POWERS entries have name/desc/trigger',
Object.values(POWERS).every((p) => p.name && p.desc && p.trigger));
}
// ── 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);
const bricks = tessellateCurves(lvl.curves ?? []);
check(`${tag}: enough orange-eligible pegs`,
lvl.pegs.filter((p) => p.orangeEligible).length
+ bricks.filter((b) => b.orangeEligible).length >= lvl.orangeCount);
check(`${tag}: enough pegs for orange+green+purple`,
lvl.pegs.length + bricks.length >= lvl.orangeCount + lvl.greenCount + 1);
if (lvl.curves?.length) {
check(`${tag}: curve schema valid`,
lvl.curves.every((c) => (c.anchors?.length ?? 0) >= 2 && (c.bends?.length ?? -1) === c.anchors.length - 1));
const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2);
check(`${tag}: bricks in bounds and clear of launcher/bucket lanes`,
bricks.every((b) => b.x >= boundR && b.x <= T.BOARD_W - boundR && b.y >= 140 && b.y <= 820),
bricks.filter((b) => !(b.y >= 140 && b.y <= 820)).map((b) => `${Math.round(b.x)},${Math.round(b.y)}`).join(' '));
const clash = lvl.pegs.some((p) => bricks.some((b) => {
const min = (p.r ?? T.PEG_R) + T.BRICK_WID / 2;
return (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min;
}));
check(`${tag}: bricks clear of circle pegs`, !clash);
}
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);