883 lines
38 KiB
JavaScript
883 lines
38 KiB
JavaScript
// Headless verification for Angry Birds.
|
|
// node tools/verifyAngryBirds.js
|
|
// Exits non-zero on any failure.
|
|
//
|
|
// 1. Physics invariants — mass properties, resting contacts, stack stability,
|
|
// friction, restitution, anti-tunnel bound, explosion falloff.
|
|
// 2. Determinism — seeded replay, frame-rate independence, clone independence.
|
|
// 3. Robustness — random-impulse monkey test for NaN / overspeed / sinking.
|
|
//
|
|
// Rendering, slingshot feel, camera 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 {
|
|
PHYS, createWorld, addBox, addPoly, addCircle, removeBody,
|
|
step, substep, settle, isSettled, applyImpulse, applyExplosion,
|
|
cloneWorld, hashWorld, contactImpulses,
|
|
} from '../src/games/angrybirds/AngryBirdsPhysics.js';
|
|
import {
|
|
TUNING, MATERIALS, PIG, BIRDS, SCORING,
|
|
createState, slingAnchor, clampDraw, drawToVelocity, currentBird, birdsRemaining,
|
|
launch, useAbility, stepSim, starsFor, cloneState, hashState, simulateShot,
|
|
} from '../src/games/angrybirds/AngryBirdsLogic.js';
|
|
|
|
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;
|
|
|
|
function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); }
|
|
|
|
// Deterministic RNG for the monkey test (never Math.random — see the header
|
|
// contract in AngryBirdsPhysics.js).
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a = (a + 0x6d2b79f5) >>> 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
/** Ground plane spanning the test area. */
|
|
function withGround(world, y = 800) {
|
|
addBox(world, { x: 600, y: y + 50, w: 4000, h: 100, isStatic: true, friction: 0.7 });
|
|
return world;
|
|
}
|
|
|
|
// ── 1. Mass properties ──────────────────────────────────────────────────────
|
|
|
|
section('1. Mass properties');
|
|
{
|
|
const w = createWorld();
|
|
const b = addBox(w, { x: 0, y: 0, w: 40, h: 20, density: 2 });
|
|
const expectMass = 2 * 40 * 20;
|
|
check('box mass = density x w x h', near(b.mass, expectMass, 1e-6), `${b.mass} vs ${expectMass}`);
|
|
|
|
const expectI = (expectMass * (40 * 40 + 20 * 20)) / 12;
|
|
check('box inertia = m(w^2+h^2)/12', near(1 / b.invI, expectI, expectI * 1e-6),
|
|
`${1 / b.invI} vs ${expectI}`);
|
|
|
|
const c = addCircle(w, { x: 0, y: 0, r: 10, density: 3 });
|
|
const cMass = 3 * Math.PI * 100;
|
|
check('circle mass = density x pi r^2', near(c.mass, cMass, 1e-6), `${c.mass} vs ${cMass}`);
|
|
|
|
const s = addBox(w, { x: 0, y: 0, w: 10, h: 10, isStatic: true });
|
|
check('static body has zero inverse mass', s.invMass === 0 && s.invI === 0);
|
|
}
|
|
{
|
|
// Winding must be normalized: a CW polygon must produce the same body as CCW.
|
|
const w = createWorld();
|
|
const ccw = addPoly(w, { x: 0, y: 0, verts: [[-10, -10], [10, -10], [10, 10], [-10, 10]], density: 1 });
|
|
const cw = addPoly(w, { x: 0, y: 0, verts: [[-10, 10], [10, 10], [10, -10], [-10, -10]], density: 1 });
|
|
check('polygon winding normalized', near(ccw.mass, cw.mass, 1e-9) && near(ccw.invI, cw.invI, 1e-12),
|
|
`${ccw.mass}/${cw.mass}`);
|
|
|
|
// Outward normals must point away from the centroid.
|
|
const outward = ccw.normals.every(([nx, ny], i) => {
|
|
const [vx, vy] = ccw.verts[i];
|
|
return vx * nx + vy * ny > 0;
|
|
});
|
|
check('polygon normals point outward', outward);
|
|
}
|
|
{
|
|
// Verts are recentred on the centroid, so an off-centre polygon still
|
|
// rotates about its true centre of mass.
|
|
const w = createWorld();
|
|
const b = addPoly(w, { x: 0, y: 0, verts: [[0, 0], [40, 0], [40, 20], [0, 20]], density: 1 });
|
|
const cx = b.verts.reduce((s, v) => s + v[0], 0) / b.verts.length;
|
|
const cy = b.verts.reduce((s, v) => s + v[1], 0) / b.verts.length;
|
|
check('polygon recentred on centroid', near(cx, 0, 1e-9) && near(cy, 0, 1e-9), `${cx},${cy}`);
|
|
}
|
|
|
|
// ── 2. Anti-tunnelling bound ────────────────────────────────────────────────
|
|
|
|
section('2. Anti-tunnelling');
|
|
{
|
|
const travel = PHYS.MAX_SPEED * PHYS.SUBSTEP_DT;
|
|
check('MAX_SPEED x SUBSTEP_DT < MIN_HALF_EXTENT',
|
|
travel < PHYS.MIN_HALF_EXTENT, `${travel.toFixed(2)} !< ${PHYS.MIN_HALF_EXTENT}`);
|
|
}
|
|
{
|
|
// A fast circle fired at a thin static wall must not pass through it.
|
|
const w = createWorld();
|
|
addBox(w, { x: 400, y: 300, w: 20, h: 400, isStatic: true });
|
|
const ball = addCircle(w, { x: 100, y: 300, r: 12, density: 1 });
|
|
ball.vx = PHYS.MAX_SPEED;
|
|
w.gravity = 0;
|
|
for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('fast body does not tunnel through a wall', ball.x < 400, `x=${ball.x.toFixed(1)}`);
|
|
}
|
|
|
|
// ── 3. Resting contacts and stack stability (THE WAVE 0 GATE) ───────────────
|
|
|
|
section('3. Resting contacts and stacks');
|
|
{
|
|
const w = withGround(createWorld());
|
|
const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 });
|
|
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
// Ground top is y=800; a 60-tall box rests with its centre at 770.
|
|
check('single box rests on ground', near(b.y, 770, PHYS.SLOP + 0.5), `y=${b.y.toFixed(3)}`);
|
|
check('resting box does not sink', b.y < 772, `y=${b.y.toFixed(3)}`);
|
|
check('resting box falls asleep', b.sleeping, `timer=${b.sleepTimer.toFixed(2)}`);
|
|
}
|
|
{
|
|
// THE GATE: a 10-box tower must settle, sleep, and not drift.
|
|
const w = withGround(createWorld());
|
|
const boxes = [];
|
|
// Ground top is y=800, boxes are 40 tall, so box i rests centred at 780-40i.
|
|
// Spawning them exactly at rest isolates solver sag from free-fall settling.
|
|
for (let i = 0; i < 10; i += 1) {
|
|
boxes.push(addBox(w, { x: 600, y: 780 - i * 40, w: 60, h: 40, density: 1, friction: 0.6 }));
|
|
}
|
|
const startX = boxes.map((b) => b.x);
|
|
|
|
let sleptAt = -1;
|
|
for (let i = 0; i < 720; i += 1) { // 3 simulated seconds at 1/240
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
if (sleptAt < 0 && isSettled(w)) sleptAt = i;
|
|
}
|
|
check('10-box tower settles within 3s', sleptAt >= 0, `never settled`);
|
|
check('10-box tower is fully asleep', boxes.every((b) => b.sleeping),
|
|
`${boxes.filter((b) => !b.sleeping).length} awake`);
|
|
|
|
const drift = Math.max(...boxes.map((b, i) => Math.abs(b.x - startX[i])));
|
|
check('tower horizontal drift < 1px', drift < 1, `max drift ${drift.toFixed(3)}px`);
|
|
|
|
// Total sag is bounded by SLOP per contact — the solver deliberately stops
|
|
// correcting once penetration is inside the slop band, so 10 stacked
|
|
// contacts can each give up to SLOP. Anything beyond that is real sag.
|
|
const sag = boxes[9].y - (780 - 9 * 40);
|
|
const sagBudget = PHYS.SLOP * 10;
|
|
check('tower sag within the slop budget', Math.abs(sag) < sagBudget,
|
|
`sag ${sag.toFixed(3)}px vs budget ${sagBudget}px`);
|
|
|
|
const tilt = Math.max(...boxes.map((b) => Math.abs(b.angle)));
|
|
check('tower stays upright', tilt < 0.02, `max |angle| ${tilt.toFixed(4)} rad`);
|
|
}
|
|
{
|
|
// A pyramid is the harder stacking case: contacts are shared sideways.
|
|
const w = withGround(createWorld());
|
|
const bodies = [];
|
|
for (let row = 0; row < 5; row += 1) {
|
|
const n = 5 - row;
|
|
for (let i = 0; i < n; i += 1) {
|
|
bodies.push(addBox(w, {
|
|
x: 600 - (n - 1) * 35 + i * 70,
|
|
y: 770 - row * 40,
|
|
w: 64, h: 40, density: 1, friction: 0.6,
|
|
}));
|
|
}
|
|
}
|
|
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('pyramid settles and sleeps', bodies.every((b) => b.sleeping),
|
|
`${bodies.filter((b) => !b.sleeping).length} awake`);
|
|
const maxTilt = Math.max(...bodies.map((b) => Math.abs(b.angle)));
|
|
check('pyramid stays upright', maxTilt < 0.05, `max |angle| ${maxTilt.toFixed(4)}`);
|
|
}
|
|
|
|
// ── 4. Friction ─────────────────────────────────────────────────────────────
|
|
|
|
section('4. Friction');
|
|
{
|
|
// 15 degrees, mu = 0.8 -> tan(15) = 0.27 < 0.8, so the box must not creep.
|
|
const w = createWorld();
|
|
const slope = 15 * Math.PI / 180;
|
|
addBox(w, { x: 600, y: 800, w: 2000, h: 60, angle: slope, isStatic: true, friction: 0.9 });
|
|
const b = addBox(w, { x: 600, y: 745, w: 60, h: 40, angle: slope, density: 1, friction: 0.9 });
|
|
const x0 = b.x;
|
|
for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('box on 15deg slope does not creep', Math.abs(b.x - x0) < 2,
|
|
`moved ${(b.x - x0).toFixed(2)}px`);
|
|
check('box on slope sleeps', b.sleeping);
|
|
}
|
|
{
|
|
// 40 degrees, mu = 0.2 -> tan(40) = 0.84 > 0.2, so it must slide.
|
|
const w = createWorld();
|
|
const slope = 40 * Math.PI / 180;
|
|
addBox(w, { x: 600, y: 800, w: 3000, h: 60, angle: slope, isStatic: true, friction: 0.2 });
|
|
const b = addBox(w, { x: 400, y: 800 - 200 * Math.tan(slope) - 52, w: 60, h: 40, angle: slope, density: 1, friction: 0.2 });
|
|
const x0 = b.x;
|
|
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('low-friction box slides down a steep slope', b.x - x0 > 20,
|
|
`moved ${(b.x - x0).toFixed(2)}px`);
|
|
}
|
|
|
|
// ── 5. Restitution ──────────────────────────────────────────────────────────
|
|
|
|
section('5. Restitution');
|
|
{
|
|
const w = withGround(createWorld());
|
|
const ball = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0.8 });
|
|
let peakUp = 0;
|
|
for (let i = 0; i < 400; i += 1) {
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
if (ball.vy < peakUp) peakUp = ball.vy;
|
|
}
|
|
check('bouncy ball rebounds upward', peakUp < -100, `peak vy ${peakUp.toFixed(1)}`);
|
|
}
|
|
{
|
|
const w = withGround(createWorld());
|
|
const dead = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0 });
|
|
for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('zero-restitution ball comes to rest', dead.sleeping && near(dead.y, 780, 1.5),
|
|
`y=${dead.y.toFixed(2)} sleeping=${dead.sleeping}`);
|
|
}
|
|
|
|
// ── 6. Shape-pair coverage ──────────────────────────────────────────────────
|
|
|
|
section('6. Shape pairs');
|
|
{
|
|
const w = createWorld();
|
|
w.gravity = 0;
|
|
const a = addCircle(w, { x: 100, y: 300, r: 20, density: 1 });
|
|
const b = addCircle(w, { x: 200, y: 300, r: 20, density: 1 });
|
|
a.vx = 200;
|
|
for (let i = 0; i < 200; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('circle-circle transfers momentum', b.vx > 50 && a.vx < 200, `a=${a.vx.toFixed(1)} b=${b.vx.toFixed(1)}`);
|
|
}
|
|
{
|
|
const w = createWorld();
|
|
w.gravity = 0;
|
|
const c = addCircle(w, { x: 100, y: 300, r: 20, density: 1 });
|
|
const p = addBox(w, { x: 300, y: 300, w: 60, h: 60, density: 1 });
|
|
c.vx = 300;
|
|
for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('circle-poly transfers momentum', p.vx > 20, `box vx=${p.vx.toFixed(1)}`);
|
|
check('circle-poly does not overlap after impact',
|
|
Math.hypot(c.x - p.x, c.y - p.y) > 40, `dist ${Math.hypot(c.x - p.x, c.y - p.y).toFixed(1)}`);
|
|
}
|
|
{
|
|
// A circle dropped into a closed V must wedge, not squeeze through the seam.
|
|
// Two slabs tilted toward each other, overlapping at the bottom so there is
|
|
// no gap for the ball to slip through.
|
|
const w = createWorld();
|
|
addBox(w, { x: 480, y: 780, w: 400, h: 40, angle: -0.6, isStatic: true, friction: 0.6 });
|
|
addBox(w, { x: 720, y: 780, w: 400, h: 40, angle: 0.6, isStatic: true, friction: 0.6 });
|
|
const ball = addCircle(w, { x: 600, y: 300, r: 25, density: 1 });
|
|
for (let i = 0; i < 1800; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('circle wedges in a V without escaping', ball.y < 820 && ball.sleeping,
|
|
`y=${ball.y.toFixed(1)} sleeping=${ball.sleeping}`);
|
|
}
|
|
|
|
// ── 7. Sleeping and waking ──────────────────────────────────────────────────
|
|
|
|
section('7. Sleeping');
|
|
{
|
|
const w = withGround(createWorld());
|
|
const stack = [];
|
|
for (let i = 0; i < 4; i += 1) stack.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 }));
|
|
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('stack asleep before impact', stack.every((b) => b.sleeping));
|
|
|
|
// A projectile must wake the whole island, not just the box it touches.
|
|
const shot = addCircle(w, { x: 200, y: 700, r: 16, density: 4 });
|
|
shot.vx = 1200;
|
|
let allAwake = false;
|
|
for (let i = 0; i < 240; i += 1) {
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
if (stack.every((b) => !b.sleeping)) { allAwake = true; break; }
|
|
}
|
|
check('impact wakes the whole island', allAwake,
|
|
`${stack.filter((b) => b.sleeping).length} still asleep`);
|
|
}
|
|
{
|
|
const w = withGround(createWorld());
|
|
const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 });
|
|
settle(w, 10);
|
|
check('settle() reaches rest', isSettled(w) && b.sleeping);
|
|
const yRest = b.y;
|
|
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
check('sleeping body does not drift', near(b.y, yRest, 1e-9), `${b.y} vs ${yRest}`);
|
|
}
|
|
|
|
// ── 8. Explosions ───────────────────────────────────────────────────────────
|
|
|
|
section('8. Explosions');
|
|
{
|
|
const w = withGround(createWorld());
|
|
const near1 = addBox(w, { x: 620, y: 700, w: 40, h: 40, density: 1 });
|
|
const far1 = addBox(w, { x: 900, y: 700, w: 40, h: 40, density: 1 });
|
|
const outside = addBox(w, { x: 1400, y: 700, w: 40, h: 40, density: 1 });
|
|
const hit = applyExplosion(w, 600, 700, 400, 600);
|
|
check('explosion hits bodies inside the radius', hit.length === 2, `hit ${hit.length}`);
|
|
check('explosion falls off with distance',
|
|
Math.hypot(near1.vx, near1.vy) > Math.hypot(far1.vx, far1.vy),
|
|
`${Math.hypot(near1.vx, near1.vy).toFixed(1)} vs ${Math.hypot(far1.vx, far1.vy).toFixed(1)}`);
|
|
check('explosion spares bodies outside the radius',
|
|
outside.vx === 0 && outside.vy === 0);
|
|
check('explosion pushes away from the centre', near1.vx > 0 && far1.vx > 0);
|
|
check('explosion wakes sleeping bodies', !near1.sleeping);
|
|
}
|
|
|
|
// ── 9. Determinism ──────────────────────────────────────────────────────────
|
|
|
|
section('9. Determinism');
|
|
function scene() {
|
|
const w = withGround(createWorld());
|
|
for (let i = 0; i < 6; i += 1) addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 });
|
|
addBox(w, { x: 660, y: 730, w: 30, h: 120, density: 0.8 });
|
|
const ball = addCircle(w, { x: 200, y: 600, r: 16, density: 4 });
|
|
ball.vx = 900; ball.vy = -120;
|
|
return w;
|
|
}
|
|
{
|
|
const a = scene();
|
|
const b = scene();
|
|
for (let i = 0; i < 300; i += 1) { substep(a, PHYS.SUBSTEP_DT); substep(b, PHYS.SUBSTEP_DT); }
|
|
check('identical scenes replay bit-identically', hashWorld(a) === hashWorld(b),
|
|
`${hashWorld(a)} vs ${hashWorld(b)}`);
|
|
}
|
|
{
|
|
// Frame-rate independence: one 1/60 frame must equal exactly N substeps.
|
|
const perFrame = Math.round((1 / 60) / PHYS.SUBSTEP_DT);
|
|
const c = scene();
|
|
const d = scene();
|
|
for (let i = 0; i < 300; i += 1) {
|
|
step(c, 1 / 60);
|
|
for (let k = 0; k < perFrame; k += 1) substep(d, PHYS.SUBSTEP_DT);
|
|
}
|
|
check(`1/60 step == ${perFrame}x substep`, hashWorld(c) === hashWorld(d),
|
|
`${hashWorld(c)} vs ${hashWorld(d)}`);
|
|
}
|
|
{
|
|
// Ragged frame pacing: the accumulator only ever runs WHOLE substeps, so N
|
|
// substeps reached through jittery frame times must be bit-identical to N
|
|
// substeps reached evenly. (Total elapsed time can differ by up to one
|
|
// substep's worth of carry — that leftover is the accumulator's whole job —
|
|
// so the comparison is on substeps run, not on wall time.)
|
|
const e = scene();
|
|
const f = scene();
|
|
const rng = mulberry32(99);
|
|
let ran = 0;
|
|
for (let i = 0; i < 400; i += 1) ran += step(e, 0.004 + rng() * 0.02);
|
|
for (let i = 0; i < ran; i += 1) substep(f, PHYS.SUBSTEP_DT);
|
|
check('ragged frame pacing matches even pacing', hashWorld(e) === hashWorld(f),
|
|
`${ran} substeps: ${hashWorld(e)} vs ${hashWorld(f)}`);
|
|
}
|
|
{
|
|
const src = scene();
|
|
for (let i = 0; i < 60; i += 1) substep(src, PHYS.SUBSTEP_DT);
|
|
const before = hashWorld(src);
|
|
const copy = cloneWorld(src);
|
|
check('clone starts hash-identical', before === hashWorld(copy));
|
|
|
|
// Stepping the clone must not disturb the original by any route — including
|
|
// through the body references cached on contacts. simulatePreview and the
|
|
// winnability bot both depend on this being airtight.
|
|
for (let i = 0; i < 120; i += 1) substep(copy, PHYS.SUBSTEP_DT);
|
|
check('stepping a clone leaves the original untouched', hashWorld(src) === before,
|
|
`${hashWorld(src)} vs ${before}`);
|
|
check('clone diverges from a stationary original', hashWorld(copy) !== before);
|
|
|
|
for (let i = 0; i < 120; i += 1) substep(src, PHYS.SUBSTEP_DT);
|
|
check('clone and original converge when stepped equally', hashWorld(src) === hashWorld(copy),
|
|
`${hashWorld(src)} vs ${hashWorld(copy)}`);
|
|
}
|
|
{
|
|
// Sequential impulses are Gauss-Seidel, so solve order genuinely affects the
|
|
// answer — bit-identical results across creation orders are NOT achievable
|
|
// and not required. What matters is that the dependence stays sub-pixel, so
|
|
// an author reordering blocks in the editor can't change whether a level
|
|
// works. Exact reproducibility of a GIVEN scene is covered above.
|
|
const build = (reverse) => {
|
|
const w = withGround(createWorld());
|
|
const spec = [];
|
|
for (let i = 0; i < 5; i += 1) spec.push({ x: 600, y: 770 - i * 40 });
|
|
const order = reverse ? [...spec].reverse() : spec;
|
|
for (const s of order) addBox(w, { ...s, w: 60, h: 40, density: 1 });
|
|
return w;
|
|
};
|
|
const fwd = build(false);
|
|
const rev = build(true);
|
|
for (let i = 0; i < 480; i += 1) { substep(fwd, PHYS.SUBSTEP_DT); substep(rev, PHYS.SUBSTEP_DT); }
|
|
const fy = [...fwd.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b);
|
|
const ry = [...rev.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b);
|
|
const worst = Math.max(...fy.map((v, i) => Math.abs(v - ry[i])));
|
|
check('creation order shifts results by under 1px', worst < 1, `worst ${worst.toFixed(3)}px`);
|
|
}
|
|
|
|
// ── 10. Robustness monkey test ──────────────────────────────────────────────
|
|
|
|
section('10. Robustness');
|
|
{
|
|
const seeds = Number((process.argv.find((a) => a.startsWith('--seeds=')) ?? '--seeds=12').split('=')[1]);
|
|
let bad = 0;
|
|
let sank = 0;
|
|
let overspeed = 0;
|
|
for (let s = 0; s < seeds; s += 1) {
|
|
const rng = mulberry32(1000 + s);
|
|
const w = withGround(createWorld());
|
|
const bodies = [];
|
|
for (let i = 0; i < 14; i += 1) {
|
|
const b = rng() < 0.3
|
|
? addCircle(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, r: 10 + rng() * 18, density: 0.5 + rng() })
|
|
: addBox(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, w: 24 + rng() * 60, h: 24 + rng() * 60, angle: rng() * Math.PI, density: 0.5 + rng() });
|
|
bodies.push(b);
|
|
}
|
|
for (let i = 0; i < 900; i += 1) {
|
|
if (i % 60 === 0) {
|
|
const b = bodies[Math.floor(rng() * bodies.length)];
|
|
applyImpulse(b, (rng() - 0.5) * 4e5, (rng() - 0.5) * 4e5, b.x, b.y);
|
|
}
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
for (const b of bodies) {
|
|
if (!Number.isFinite(b.x) || !Number.isFinite(b.y) || !Number.isFinite(b.angle)
|
|
|| !Number.isFinite(b.vx) || !Number.isFinite(b.vy) || !Number.isFinite(b.omega)) bad += 1;
|
|
if (Math.hypot(b.vx, b.vy) > PHYS.MAX_SPEED + 1e-6) overspeed += 1;
|
|
if (b.y > 1000) sank += 1;
|
|
}
|
|
}
|
|
}
|
|
check(`no NaN across ${seeds} monkey seeds`, bad === 0, `${bad} non-finite samples`);
|
|
check('nothing exceeds MAX_SPEED', overspeed === 0, `${overspeed} samples`);
|
|
check('nothing sinks through the ground', sank === 0, `${sank} samples`);
|
|
}
|
|
{
|
|
// Bodies must be removable mid-sim without leaving dangling contacts.
|
|
const w = withGround(createWorld());
|
|
const boxes = [];
|
|
for (let i = 0; i < 5; i += 1) boxes.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 }));
|
|
for (let i = 0; i < 120; i += 1) substep(w, PHYS.SUBSTEP_DT);
|
|
removeBody(w, boxes[2]);
|
|
let threw = false;
|
|
try { for (let i = 0; i < 480; i += 1) substep(w, PHYS.SUBSTEP_DT); } catch (e) { threw = true; }
|
|
check('removing a mid-stack body is safe', !threw);
|
|
check('stack recovers after a removal', boxes.filter((b, i) => i !== 2).every((b) => b.sleeping),
|
|
`${boxes.filter((b, i) => i !== 2 && !b.sleeping).length} awake`);
|
|
}
|
|
{
|
|
// contactImpulses is the damage signal the rules layer reads — a hard hit
|
|
// must report a much larger impulse than a resting contact.
|
|
const w = withGround(createWorld());
|
|
const target = addBox(w, { x: 600, y: 770, w: 60, h: 60, density: 1 });
|
|
settle(w, 5);
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
const resting = contactImpulses(w).get(target.id) ?? 0;
|
|
|
|
const shot = addCircle(w, { x: 200, y: 740, r: 16, density: 6 });
|
|
shot.vx = 1800;
|
|
let peak = 0;
|
|
for (let i = 0; i < 240; i += 1) {
|
|
substep(w, PHYS.SUBSTEP_DT);
|
|
peak = Math.max(peak, contactImpulses(w).get(target.id) ?? 0);
|
|
}
|
|
check('impact impulse exceeds resting impulse', peak > resting * 3,
|
|
`peak ${peak.toFixed(0)} vs resting ${resting.toFixed(0)}`);
|
|
}
|
|
|
|
// ── 11. Materials and damage ────────────────────────────────────────────────
|
|
|
|
section('11. Materials and damage');
|
|
{
|
|
const order = ['ice', 'wood', 'stone'];
|
|
const dens = order.map((m) => MATERIALS[m].density);
|
|
const hps = order.map((m) => MATERIALS[m].hp);
|
|
const thr = order.map((m) => MATERIALS[m].threshold);
|
|
check('material densities are distinct and ordered ice<wood<stone',
|
|
dens[0] < dens[1] && dens[1] < dens[2], dens.join(' < '));
|
|
check('material hp ordered ice<wood<stone', hps[0] < hps[1] && hps[1] < hps[2], hps.join(' < '));
|
|
check('damage thresholds ordered ice<wood<stone', thr[0] < thr[1] && thr[1] < thr[2], thr.join(' < '));
|
|
check('ice is the most slippery', MATERIALS.ice.friction < MATERIALS.wood.friction);
|
|
check('TNT is fragile and carries a blast',
|
|
MATERIALS.tnt.hp < MATERIALS.wood.hp && !!MATERIALS.tnt.blast);
|
|
}
|
|
{
|
|
// THE self-crush guard. A tall stone tower left alone must take no damage —
|
|
// the reason damage reads impactImpulse and not the accumulated normal
|
|
// impulse. Regressing this makes towers quietly disintegrate at rest.
|
|
const blocks = [];
|
|
for (let i = 0; i < 10; i += 1) blocks.push({ x: 1200, y: 880 - i * 40, w: 120, h: 40, material: 'stone' });
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: ['red'], blocks, pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3],
|
|
});
|
|
const hp0 = [...st.blocks.values()].map((b) => b.hp);
|
|
for (let i = 0; i < 300; i += 1) stepSim(st, 1 / 60);
|
|
const hp1 = [...st.blocks.values()].map((b) => b.hp);
|
|
check('a tall stone tower does not damage itself at rest',
|
|
hp1.length === hp0.length && hp1.every((h, i) => h === hp0[i]),
|
|
`${hp0.length} blocks -> ${hp1.length}, min hp ${Math.min(...hp1).toFixed(1)}`);
|
|
|
|
const restPeak = Math.max(0, ...st.world.contactList.map((c) => c.impactImpulse));
|
|
check('settled resting impact impulse is below every threshold',
|
|
restPeak < MATERIALS.ice.threshold,
|
|
`${restPeak.toExponential(2)} vs ice ${MATERIALS.ice.threshold.toExponential(2)}`);
|
|
}
|
|
{
|
|
// Damage must be graded: the same hit shatters ice, hurts wood, barely
|
|
// marks stone. This is what makes material choice matter to a level author.
|
|
const results = {};
|
|
for (const material of ['ice', 'wood', 'stone']) {
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: ['red'],
|
|
blocks: [{ x: 1000, y: 850, w: 120, h: 100, material }],
|
|
pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3],
|
|
});
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 90, a.y);
|
|
const bird = st.world.byId.get(st.activeBirds[0]);
|
|
bird.x = 700; bird.y = 850; bird.vx = 900; bird.vy = 0;
|
|
for (let i = 0; i < 200 && st.phase === 'flight'; i += 1) stepSim(st, 1 / 60);
|
|
const rec = [...st.blocks.values()][0];
|
|
results[material] = rec ? rec.hp / rec.maxHp : 0;
|
|
}
|
|
check('one medium hit destroys ice', results.ice === 0, `ice left ${results.ice}`);
|
|
check('the same hit leaves stone standing', results.stone > 0, `stone left ${results.stone.toFixed(2)}`);
|
|
check('stone survives better than wood', results.stone > results.wood,
|
|
`stone ${results.stone.toFixed(2)} vs wood ${results.wood.toFixed(2)}`);
|
|
}
|
|
{
|
|
// Pigs must die to debris, not only to direct hits — the collapse doing the
|
|
// killing is the core feel of the game.
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: ['red'],
|
|
blocks: [{ x: 1200, y: 600, w: 200, h: 60, material: 'stone' }],
|
|
pigs: [{ x: 1200, y: 878, r: 22 }],
|
|
stars: [1, 2, 3],
|
|
});
|
|
let killed = false;
|
|
for (let i = 0; i < 400; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) if (e.t === 'pigKilled') killed = true;
|
|
if (killed) break;
|
|
}
|
|
check('a stone slab dropped on a pig kills it', killed);
|
|
}
|
|
{
|
|
// Crack stages must fire in order as hp falls.
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: ['red', 'red', 'red'],
|
|
blocks: [{ x: 1000, y: 850, w: 140, h: 100, material: 'stone' }],
|
|
pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3],
|
|
});
|
|
const stages = [];
|
|
for (let shot = 0; shot < 3; shot += 1) {
|
|
const a = slingAnchor(st);
|
|
if (!launch(st, a.x - 60, a.y)) break;
|
|
const bird = st.world.byId.get(st.activeBirds[0]);
|
|
bird.x = 780; bird.y = 850; bird.vx = 780; bird.vy = 0;
|
|
for (let i = 0; i < 300 && st.phase === 'flight'; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) {
|
|
if (e.t === 'blockCracked') stages.push(e.stage);
|
|
if (e.t === 'blockDestroyed') stages.push(3);
|
|
}
|
|
}
|
|
}
|
|
check('crack stages fire in increasing order',
|
|
stages.length > 0 && stages.every((s, i) => i === 0 || s >= stages[i - 1]), stages.join(','));
|
|
}
|
|
|
|
// ── 12. Birds ───────────────────────────────────────────────────────────────
|
|
|
|
section('12. Birds');
|
|
{
|
|
check('all 8 birds are defined', Object.keys(BIRDS).length === 8, Object.keys(BIRDS).join(','));
|
|
check('Terence is the heaviest', Object.values(BIRDS).every((b) => b.density <= BIRDS.terence.density));
|
|
check('every ability is implemented',
|
|
Object.values(BIRDS).every((b) => ['none', 'dart', 'split', 'blast', 'egg', 'boomerang', 'inflate'].includes(b.ability)));
|
|
}
|
|
function flightState(birdId) {
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: [birdId],
|
|
blocks: [{ x: 1400, y: 850, w: 120, h: 100, material: 'wood' }],
|
|
pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3],
|
|
});
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 100, a.y - 40);
|
|
return st;
|
|
}
|
|
{
|
|
const st = flightState('chuck');
|
|
const b = st.world.byId.get(st.activeBirds[0]);
|
|
const before = Math.hypot(b.vx, b.vy);
|
|
useAbility(st);
|
|
const after = Math.hypot(b.vx, b.vy);
|
|
check('Chuck darts faster', after > before * 2, `${before.toFixed(0)} -> ${after.toFixed(0)}`);
|
|
}
|
|
{
|
|
const st = flightState('blue');
|
|
const before = st.activeBirds.length;
|
|
useAbility(st);
|
|
check('Blue splits into three', st.activeBirds.length === before + 2, `${before} -> ${st.activeBirds.length}`);
|
|
}
|
|
{
|
|
const st = flightState('bomb');
|
|
useAbility(st);
|
|
const evs = st.events.map((e) => e.t);
|
|
check('Bomb detonates', evs.includes('explosion'), evs.join(','));
|
|
check('Bomb consumes itself', st.activeBirds.length === 0);
|
|
}
|
|
{
|
|
const st = flightState('matilda');
|
|
const b = st.world.byId.get(st.activeBirds[0]);
|
|
const vy0 = b.vy;
|
|
useAbility(st);
|
|
check('Matilda kicks upward', b.vy < vy0, `${vy0.toFixed(0)} -> ${b.vy.toFixed(0)}`);
|
|
check('Matilda drops an egg', st.events.some((e) => e.t === 'explosion'));
|
|
}
|
|
{
|
|
const st = flightState('hal');
|
|
const b = st.world.byId.get(st.activeBirds[0]);
|
|
const vx0 = b.vx;
|
|
useAbility(st);
|
|
check('Hal reverses direction', Math.sign(b.vx) === -Math.sign(vx0), `${vx0.toFixed(0)} -> ${b.vx.toFixed(0)}`);
|
|
}
|
|
{
|
|
const st = flightState('bubbles');
|
|
const r0 = st.world.byId.get(st.activeBirds[0]).radius;
|
|
useAbility(st);
|
|
const r1 = st.world.byId.get(st.activeBirds[0]).radius;
|
|
check('Bubbles inflates', r1 > r0 * 2, `${r0} -> ${r1}`);
|
|
}
|
|
{
|
|
const st = flightState('red');
|
|
check('Red has no ability to use', useAbility(st) === false);
|
|
}
|
|
{
|
|
const st = flightState('chuck');
|
|
check('ability fires once', useAbility(st) === true);
|
|
check('ability cannot fire twice in one shot', useAbility(st) === false);
|
|
}
|
|
|
|
// ── 13. Rules and scoring ───────────────────────────────────────────────────
|
|
|
|
section('13. Rules and scoring');
|
|
function winnableLevel(birds = ['red', 'red', 'red']) {
|
|
return {
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds,
|
|
blocks: [{ x: 1000, y: 850, w: 40, h: 100, material: 'ice' }],
|
|
pigs: [{ x: 1060, y: 878, r: 22 }],
|
|
stars: [5000, 12000, 20000],
|
|
};
|
|
}
|
|
{
|
|
const st = createState(winnableLevel());
|
|
check('starts in aim phase', st.phase === 'aim');
|
|
check('birds remaining matches the queue', birdsRemaining(st) === 3);
|
|
|
|
const a = slingAnchor(st);
|
|
check('a tap-sized draw does not launch', launch(st, a.x - 3, a.y) === false);
|
|
check('a full draw launches', launch(st, a.x - 150, a.y - 60) === true);
|
|
check('launching consumes a bird', birdsRemaining(st) === 2);
|
|
check('phase is flight after launch', st.phase === 'flight');
|
|
}
|
|
{
|
|
// Draw is clamped to MAX_DRAW in every direction.
|
|
const st = createState(winnableLevel());
|
|
const a = slingAnchor(st);
|
|
const c = clampDraw(st, a.x - 9000, a.y - 9000);
|
|
check('draw clamps to MAX_DRAW', near(Math.hypot(c.x - a.x, c.y - a.y), TUNING.MAX_DRAW, 1e-6),
|
|
`${Math.hypot(c.x - a.x, c.y - a.y).toFixed(2)}`);
|
|
const v = drawToVelocity(st, a.x - 150, a.y);
|
|
check('bird flies opposite the pull', v.vx > 0, `vx=${v.vx.toFixed(0)}`);
|
|
check('launch speed matches SPEED_PER_DRAW',
|
|
near(Math.hypot(v.vx, v.vy), TUNING.MAX_DRAW * TUNING.SPEED_PER_DRAW, 1),
|
|
`${Math.hypot(v.vx, v.vy).toFixed(0)}`);
|
|
}
|
|
{
|
|
// A shot that clears the only pig wins, and unused birds pay a bonus.
|
|
const st = createState(winnableLevel());
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 150, a.y - 30);
|
|
const bird = st.world.byId.get(st.activeBirds[0]);
|
|
bird.x = 900; bird.y = 860; bird.vx = 1500; bird.vy = 0;
|
|
let won = false;
|
|
let bonus = 0;
|
|
for (let i = 0; i < 900; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) {
|
|
if (e.t === 'won') { won = true; bonus = e.birdBonus; }
|
|
}
|
|
if (won) break;
|
|
}
|
|
check('clearing every pig wins', won, `phase=${st.phase}`);
|
|
check('unused birds pay 10,000 each', bonus === 2 * SCORING.BIRD_LEFT, `bonus ${bonus}`);
|
|
check('score includes the pig', st.score >= SCORING.PIG + bonus, `score ${st.score}`);
|
|
check('stars awarded on a win', starsFor(st) >= 1, `stars ${starsFor(st)}`);
|
|
}
|
|
{
|
|
// Running out of birds with a pig alive loses.
|
|
const st = createState({
|
|
world: { w: 1920, h: 1080, groundY: 900 },
|
|
birds: ['red'],
|
|
blocks: [],
|
|
pigs: [{ x: 1700, y: 878, r: 22 }],
|
|
stars: [5000, 12000, 20000],
|
|
});
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 20, a.y); // deliberately feeble
|
|
let lost = false;
|
|
for (let i = 0; i < 2000; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) if (e.t === 'lost') lost = true;
|
|
if (lost || st.phase === 'won') break;
|
|
}
|
|
check('running out of birds loses', lost, `phase=${st.phase}`);
|
|
check('no stars on a loss', starsFor(st) === 0);
|
|
}
|
|
{
|
|
const st = createState(winnableLevel());
|
|
st.score = 13000;
|
|
st.phase = 'won';
|
|
check('star cuts are thresholds', starsFor(st) === 2, `${starsFor(st)}`);
|
|
st.score = 99999;
|
|
check('stars cap at 3', starsFor(st) === 3);
|
|
}
|
|
{
|
|
// Shot bookkeeping: the trail is retained for the next shot's aiming ghost.
|
|
const st = createState(winnableLevel());
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 150, a.y - 60);
|
|
for (let i = 0; i < 1200 && st.phase === 'flight'; i += 1) stepSim(st, 1 / 60);
|
|
check('a resolved shot records a trail', st.trails.length === 1, `${st.trails.length}`);
|
|
check('a resolved shot returns to aim', ['aim', 'won', 'lost'].includes(st.phase), st.phase);
|
|
}
|
|
|
|
// ── 14. Rules-layer determinism ─────────────────────────────────────────────
|
|
|
|
section('14. Rules determinism');
|
|
{
|
|
const mk = () => {
|
|
const st = createState(winnableLevel(['red', 'chuck', 'bomb']));
|
|
const a = slingAnchor(st);
|
|
launch(st, a.x - 140, a.y - 50);
|
|
return st;
|
|
};
|
|
const a1 = mk();
|
|
const b1 = mk();
|
|
for (let i = 0; i < 300; i += 1) { stepSim(a1, 1 / 60); stepSim(b1, 1 / 60); }
|
|
check('rules state replays bit-identically', hashState(a1) === hashState(b1),
|
|
`${hashState(a1)} vs ${hashState(b1)}`);
|
|
}
|
|
{
|
|
const st = createState(winnableLevel(['red', 'red']));
|
|
const before = hashState(st);
|
|
const a = slingAnchor(st);
|
|
const res = simulateShot(st, a.x - 150, a.y - 40);
|
|
check('simulateShot returns a result', !!res);
|
|
check('simulateShot does not touch the live state', hashState(st) === before,
|
|
`${hashState(st)} vs ${before}`);
|
|
check('simulateShot reports damage done', res.pigsKilled + res.blocksDestroyed >= 0);
|
|
|
|
const res2 = simulateShot(st, a.x - 150, a.y - 40);
|
|
check('simulateShot is repeatable', res.score === res2.score && res.pigsKilled === res2.pigsKilled,
|
|
`${res.score} vs ${res2.score}`);
|
|
}
|
|
{
|
|
// cloneState must deep-copy the damage bookkeeping too, not just the world.
|
|
const st = createState(winnableLevel());
|
|
const copy = cloneState(st);
|
|
const firstBlock = [...copy.blocks.keys()][0];
|
|
copy.blocks.get(firstBlock).hp = 1;
|
|
check('cloneState deep-copies block hp',
|
|
st.blocks.get(firstBlock).hp !== 1, `${st.blocks.get(firstBlock).hp}`);
|
|
}
|
|
|
|
// ── 15. Level bank ──────────────────────────────────────────────────────────
|
|
|
|
section('15. Level bank');
|
|
{
|
|
const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'gamedata', 'angrybirds');
|
|
const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8'));
|
|
const entries = manifest.levels ?? [];
|
|
check('manifest lists levels', entries.length > 0, `${entries.length}`);
|
|
check('level numbers are contiguous from 1',
|
|
entries.every((m, i) => m.level === i + 1), entries.map((m) => m.level).join(','));
|
|
|
|
const defs = [];
|
|
let missing = 0;
|
|
for (const m of entries) {
|
|
try { defs.push(JSON.parse(readFileSync(join(DATA_DIR, m.file), 'utf8'))); } catch (_) { missing += 1; }
|
|
}
|
|
check('every manifest entry has a level file', missing === 0, `${missing} missing`);
|
|
check('level files agree with the manifest',
|
|
defs.every((d, i) => d.level === entries[i].level && d.name === entries[i].name));
|
|
|
|
// Every episode range must cover real levels.
|
|
for (const ep of manifest.episodes ?? []) {
|
|
const inRange = entries.filter((m) => m.level >= ep.from && m.level <= ep.to);
|
|
check(`episode "${ep.name}" covers its range`, inRange.length === ep.to - ep.from + 1,
|
|
`${inRange.length} of ${ep.to - ep.from + 1}`);
|
|
}
|
|
|
|
// Data lint: materials, birds and star cuts must all be real.
|
|
let badMat = 0; let badBird = 0; let badStars = 0; let thin = 0;
|
|
for (const d of defs) {
|
|
for (const b of d.blocks ?? []) {
|
|
if (!MATERIALS[b.material]) badMat += 1;
|
|
if (Math.min(b.w, b.h) / 2 < PHYS.MIN_HALF_EXTENT) thin += 1;
|
|
}
|
|
for (const id of d.birds ?? []) if (!BIRDS[id]) badBird += 1;
|
|
const s = d.stars ?? [];
|
|
if (s.length !== 3 || s[0] >= s[1] || s[1] >= s[2]) badStars += 1;
|
|
}
|
|
check('every block uses a known material', badMat === 0, `${badMat} bad`);
|
|
check('every bird id is known', badBird === 0, `${badBird} bad`);
|
|
check('star cuts are three ascending values', badStars === 0, `${badStars} bad`);
|
|
check('no block is thinner than the anti-tunnel bound', thin === 0,
|
|
`${thin} blocks under ${PHYS.MIN_HALF_EXTENT * 2}px`);
|
|
|
|
// Structural integrity: a level must stand on its own. If a structure
|
|
// collapses or damages itself on load, the author saw something different
|
|
// from what the player gets.
|
|
let unstable = [];
|
|
let selfDamaged = [];
|
|
for (const d of defs) {
|
|
const st = createState(d);
|
|
const hp0 = [...st.blocks.values()].map((b) => b.hp);
|
|
const nBlocks = st.blocks.size;
|
|
const nPigs = st.pigs.size;
|
|
for (let i = 0; i < 240; i += 1) stepSim(st, 1 / 60);
|
|
if (st.blocks.size !== nBlocks || st.pigs.size !== nPigs) unstable.push(d.level);
|
|
else if ([...st.blocks.values()].some((b, i) => b.hp < hp0[i])) selfDamaged.push(d.level);
|
|
}
|
|
check('every level stands up unaided', unstable.length === 0, `levels ${unstable.join(',')}`);
|
|
check('no level damages itself on load', selfDamaged.length === 0, `levels ${selfDamaged.join(',')}`);
|
|
|
|
// Winnability: a coarse aim sweep must find a shot that clears every pig
|
|
// within the bird budget. One-directional gate — a sweep failure means
|
|
// "redesign or hand-verify", never "impossible".
|
|
const unwinnable = [];
|
|
for (const d of defs) {
|
|
let st = createState(d);
|
|
const a = slingAnchor(st);
|
|
let guard = 0;
|
|
while (st.phase === 'aim' && guard < 12) {
|
|
guard += 1;
|
|
let best = null;
|
|
for (let ang = -85; ang <= 15; ang += 5) {
|
|
for (const pw of [1, 0.85, 0.7, 0.55]) {
|
|
const r = (Math.PI * ang) / 180;
|
|
const res = simulateShot(st, a.x - Math.cos(r) * TUNING.MAX_DRAW * pw,
|
|
a.y - Math.sin(r) * TUNING.MAX_DRAW * pw);
|
|
if (!res) continue;
|
|
if (!best || res.score > best.score) best = res;
|
|
if (res.won) { best = res; break; }
|
|
}
|
|
if (best?.won) break;
|
|
}
|
|
if (!best) break;
|
|
st = best.state;
|
|
}
|
|
if (st.phase !== 'won') unwinnable.push(d.level);
|
|
}
|
|
check('a greedy aim sweep clears every level', unwinnable.length === 0,
|
|
`levels ${unwinnable.join(',')}`);
|
|
}
|
|
|
|
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
|
|
console.log(`\n${passes} passed, ${failures} failed`);
|
|
process.exit(failures ? 1 : 0);
|