564 lines
24 KiB
JavaScript
564 lines
24 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,
|
||
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);
|