901 lines
39 KiB
JavaScript
901 lines
39 KiB
JavaScript
// 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',
|
||
'smasher', 'maurice', 'schooner', 'terry', 'gerome', 'blackwind',
|
||
'steve', 'nicole', 'dv-8-2303', 'kage', 'aiko', 'nadia',
|
||
];
|
||
check('85 levels in 17 friend blocks', manifest.levels.length === BLOCK_ORDER.length * 5);
|
||
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);
|
||
const expectedIntros = BLOCK_ORDER.map((_, b) => b * 5 + 1).join(',');
|
||
check('masterId-change rule marks intros at each block start', introLevels.join(',') === expectedIntros, 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);
|