1174 lines
53 KiB
JavaScript
1174 lines
53 KiB
JavaScript
// Goo Tower — regression harness. Run after every change to the sim.
|
|
//
|
|
// The physics is the game. A subtly wrong solver produces a structure that
|
|
// looks plausible and is unbuildable, and there is no way to tell by reading
|
|
// the code — so everything the sim promises gets asserted here.
|
|
//
|
|
// 1. Geometry primitives (point-in-poly, closest point, segment crossing,
|
|
// line-of-sight blocking)
|
|
// 2. Solver invariants (chain hangs at rest length, truss beats chain,
|
|
// strands snap past BREAK_RATIO, orphaned chunks stop being rooted)
|
|
// 3. Terrain (balls rest ON the ground, never inside it; slopes; friction)
|
|
// 4. Hazards (spikes kill, spikeProof survives, falling out of the world)
|
|
// 5. Stability (no NaN, no launch, stiff lattices stay bounded)
|
|
// 6. Determinism (same seed replays bit-identically; frame-rate independent)
|
|
//
|
|
// Usage: node tools/verifyGooTower.js
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
TUNING, GOO_TYPES, gooType, mulberry32,
|
|
pointInPoly, closestPointOnPoly, segmentsIntersect, lineBlocked,
|
|
createState, addBall, addStrand, breakStrand, strandById, ballById,
|
|
stepSim, settle, settlePhysics, isSettled, refreshStructure, isRooted,
|
|
strandLength, strandStress, ballSpeed, cloneState, hashState, liveStrands,
|
|
chooseAttachments, canPlace, placeBall, pickBallAt, beginDrag, dragTo, endDrag,
|
|
insideSolid, isWon, hasOCD, availableGoo,
|
|
distanceToSolid, canStickToTerrain, requiredStrands, ignite, detonate,
|
|
} from '../src/games/gootower/GooTowerLogic.js';
|
|
import { autoPlay } from '../src/games/gootower/GooTowerAuto.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
let passes = 0;
|
|
let failures = 0;
|
|
function check(name, cond, detail = '') {
|
|
if (cond) { passes += 1; console.log(` ok ${name}`); }
|
|
else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
|
|
}
|
|
function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); }
|
|
|
|
const GROUND_Y = 800;
|
|
const ground = () => ({ kind: 'solid', poly: [[0, GROUND_Y], [1600, GROUND_Y], [1600, 1000], [0, 1000]] });
|
|
const baseLevel = (over = {}) => ({
|
|
world: { w: 1600, h: 1000 },
|
|
terrain: [ground()],
|
|
balls: [], strands: [], pile: [],
|
|
required: 1, ocdTarget: 1,
|
|
...over,
|
|
});
|
|
|
|
// ── 1. Geometry ─────────────────────────────────────────────────────────────
|
|
section('1. Geometry primitives');
|
|
{
|
|
const sq = [[0, 0], [100, 0], [100, 100], [0, 100]];
|
|
check('pointInPoly centre', pointInPoly(50, 50, sq));
|
|
check('pointInPoly outside', !pointInPoly(150, 50, sq));
|
|
check('pointInPoly outside (above)', !pointInPoly(50, -10, sq));
|
|
|
|
const cp = closestPointOnPoly(50, 20, sq);
|
|
check('closestPointOnPoly picks nearest edge', Math.abs(cp.y - 0) < 1e-9 && Math.abs(cp.x - 50) < 1e-9,
|
|
`got ${cp.x},${cp.y}`);
|
|
const cp2 = closestPointOnPoly(-30, 50, sq);
|
|
check('closestPointOnPoly from outside', Math.abs(cp2.x) < 1e-9 && Math.abs(cp2.d2 - 900) < 1e-6,
|
|
`got ${cp2.x},${cp2.y} d2=${cp2.d2}`);
|
|
|
|
check('segmentsIntersect crossing', segmentsIntersect(0, 0, 10, 10, 0, 10, 10, 0));
|
|
check('segmentsIntersect parallel', !segmentsIntersect(0, 0, 10, 0, 0, 5, 10, 5));
|
|
check('segmentsIntersect disjoint', !segmentsIntersect(0, 0, 1, 1, 5, 5, 6, 6));
|
|
}
|
|
{
|
|
const st = createState(baseLevel({
|
|
terrain: [ground(), { kind: 'solid', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }],
|
|
}));
|
|
check('lineBlocked through a wall', lineBlocked(st, 300, 450, 500, 450));
|
|
check('lineBlocked clear path', !lineBlocked(st, 300, 200, 500, 200));
|
|
check('lineBlocked ignores non-solid', !lineBlocked(
|
|
createState(baseLevel({ terrain: [{ kind: 'spike', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }] })),
|
|
300, 450, 500, 450));
|
|
}
|
|
|
|
// ── 2. Solver invariants ────────────────────────────────────────────────────
|
|
section('2. Solver invariants');
|
|
{
|
|
// A chain hung from a pinned node must settle near its rest length, not
|
|
// stretch away to nothing and not collapse to zero.
|
|
const N = 6;
|
|
const balls = [{ x: 400, y: 200, type: 'common', pinned: true }];
|
|
for (let i = 1; i <= N; i += 1) balls.push({ x: 400, y: 200 + i * TUNING.STRAND_REST, type: 'common' });
|
|
const strands = [];
|
|
for (let i = 0; i < N; i += 1) strands.push([i, i + 1]);
|
|
const st = createState(baseLevel({ balls, strands }));
|
|
settle(st, 12);
|
|
|
|
check('hanging chain settles', isSettled(st));
|
|
let allInBand = true;
|
|
let worst = 0;
|
|
for (const s of liveStrands(st)) {
|
|
const ratio = strandLength(st, s) / s.rest;
|
|
worst = Math.max(worst, ratio);
|
|
if (ratio < 0.9 || ratio > TUNING.BREAK_RATIO) allInBand = false;
|
|
}
|
|
check('chain strands hang between 0.9x and break ratio', allInBand, `worst ratio ${worst.toFixed(3)}`);
|
|
check('chain does not snap under its own weight', liveStrands(st).length === N,
|
|
`${liveStrands(st).length}/${N} survived`);
|
|
check('pinned node never moves', st.balls[0].x === 400 && st.balls[0].y === 200);
|
|
check('chain hangs downward', st.balls[N].y > st.balls[0].y + N * TUNING.STRAND_REST * 0.8);
|
|
}
|
|
{
|
|
// THE core claim of the whole design: rigidity is emergent from
|
|
// triangulation. A triangulated cantilever must sag dramatically less than
|
|
// an untriangulated chain of the same span and mass.
|
|
//
|
|
// Proportions matter and are load-bearing in the literal sense: chord force
|
|
// in a cantilever goes as W*L/(2*d), so a shallow truss tears its own root
|
|
// strands off. This one is 3 bays at full depth, which common goo can
|
|
// actually carry -- the over-long case is asserted separately below.
|
|
const SPAN = 3;
|
|
const DX = TUNING.STRAND_REST;
|
|
|
|
const chainBalls = [{ x: 300, y: 300, type: 'common', pinned: true }];
|
|
const chainStrands = [];
|
|
for (let i = 1; i <= SPAN; i += 1) {
|
|
chainBalls.push({ x: 300 + i * DX, y: 300, type: 'common' });
|
|
chainStrands.push([i - 1, i]);
|
|
}
|
|
const chain = createState(baseLevel({ balls: chainBalls, strands: chainStrands }));
|
|
settle(chain, 12);
|
|
const chainSag = chain.balls[SPAN].y - 300;
|
|
|
|
// Same span, two rows, fully triangulated.
|
|
const H = 62;
|
|
const trussBalls = [];
|
|
const idx = (row, col) => row * (SPAN + 1) + col;
|
|
for (let row = 0; row < 2; row += 1) {
|
|
for (let col = 0; col <= SPAN; col += 1) {
|
|
trussBalls.push({ x: 300 + col * DX, y: 300 - row * H, type: 'common', pinned: col === 0 });
|
|
}
|
|
}
|
|
const trussStrands = [];
|
|
for (let col = 0; col < SPAN; col += 1) {
|
|
trussStrands.push([idx(0, col), idx(0, col + 1)]); // bottom chord
|
|
trussStrands.push([idx(1, col), idx(1, col + 1)]); // top chord
|
|
trussStrands.push([idx(0, col), idx(1, col + 1)]); // diagonal
|
|
trussStrands.push([idx(1, col), idx(0, col + 1)]); // counter-diagonal
|
|
}
|
|
for (let col = 0; col <= SPAN; col += 1) trussStrands.push([idx(0, col), idx(1, col)]);
|
|
const truss = createState(baseLevel({ balls: trussBalls, strands: trussStrands }));
|
|
settle(truss, 12);
|
|
const trussSag = truss.balls[idx(0, SPAN)].y - 300;
|
|
|
|
check('triangulated truss sags far less than a bare chain',
|
|
trussSag < chainSag * 0.5,
|
|
`truss ${trussSag.toFixed(1)}px vs chain ${chainSag.toFixed(1)}px`);
|
|
check('truss holds itself up', trussSag < DX,
|
|
`sag ${trussSag.toFixed(1)}px`);
|
|
check('truss keeps all its strands', liveStrands(truss).length === trussStrands.length,
|
|
`${liveStrands(truss).length}/${trussStrands.length}`);
|
|
}
|
|
{
|
|
// ...and the converse, which is just as important for the game to have any
|
|
// drama in it: overreach and the structure tears itself apart. A cantilever
|
|
// long enough to drive root chord force past the strand rating MUST fail.
|
|
const SPAN = 8, DX = TUNING.STRAND_REST, H = 54;
|
|
const balls = [];
|
|
const idx = (r, c) => r * (SPAN + 1) + c;
|
|
for (let r = 0; r < 2; r += 1)
|
|
for (let c = 0; c <= SPAN; c += 1)
|
|
balls.push({ x: 200 + c * DX, y: 300 - r * H, type: 'common', pinned: c === 0 });
|
|
const strands = [];
|
|
for (let c = 0; c < SPAN; c += 1) {
|
|
strands.push([idx(0, c), idx(0, c + 1)]);
|
|
strands.push([idx(1, c), idx(1, c + 1)]);
|
|
strands.push([idx(0, c), idx(1, c + 1)]);
|
|
strands.push([idx(1, c), idx(0, c + 1)]);
|
|
}
|
|
for (let c = 0; c <= SPAN; c += 1) strands.push([idx(0, c), idx(1, c)]);
|
|
const st = createState(baseLevel({ balls, strands }));
|
|
settle(st, 14);
|
|
check('an over-long cantilever collapses', liveStrands(st).length < strands.length,
|
|
`${liveStrands(st).length}/${strands.length} survived`);
|
|
}
|
|
{
|
|
// Tension must be the real, physical constraint force -- the whole break
|
|
// model is calibrated in ball-weights, so if this drifts, every level's
|
|
// difficulty drifts with it.
|
|
const weight = 1500; // GOO_TYPES.common.mass * TUNING.GRAVITY
|
|
const readings = [];
|
|
for (const n of [1, 2, 4]) {
|
|
const balls = [{ x: 400, y: 100, type: 'common', pinned: true }];
|
|
const strands = [];
|
|
for (let i = 1; i <= n; i += 1) {
|
|
balls.push({ x: 400, y: 100 + i * TUNING.STRAND_REST, type: 'common' });
|
|
strands.push([i - 1, i]);
|
|
}
|
|
const st = createState(baseLevel({ terrain: [], balls, strands }));
|
|
settle(st, 25);
|
|
readings.push(st.strands[0].tension / weight);
|
|
}
|
|
check('strand tension reads true static load in ball-weights',
|
|
readings.every((v, i) => Math.abs(v - (i === 0 ? 1 : i === 1 ? 2 : 4)) < 0.2),
|
|
readings.map((v) => v.toFixed(2)).join(', '));
|
|
check('strandStress is 0..1 and rises with load',
|
|
(() => {
|
|
const st = createState(baseLevel({
|
|
terrain: [],
|
|
balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }],
|
|
strands: [[0, 1]],
|
|
}));
|
|
settle(st, 20);
|
|
const s = strandStress(st.strands[0]);
|
|
return s > 0 && s < 1;
|
|
})());
|
|
}
|
|
{
|
|
// A single-frame numerical spike must not snap a strand; sustained overload
|
|
// must. This is the LOAD_TAU fatigue window.
|
|
const mk = () => createState(baseLevel({
|
|
terrain: [],
|
|
balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }],
|
|
strands: [[0, 1]],
|
|
}));
|
|
// Drive these through real positions, not by poking `tension`: substep()
|
|
// recomputes tension from lambda before applyStress ever reads it, so an
|
|
// injected value is overwritten and the test would prove nothing.
|
|
const k = 1 / TUNING.COMPLIANCE; // strand stiffness
|
|
const cap = TUNING.BREAK_FORCE; // common strand, strength 1
|
|
const stretchFor = (force) => force / k; // Hooke
|
|
const restLen = mk().strands[0].rest;
|
|
|
|
// Modest overload for a single substep: over cap, under SHOCK_FACTOR * cap.
|
|
const spike = mk();
|
|
spike.balls[1].y = spike.balls[0].y + restLen + stretchFor(cap * 1.6);
|
|
spike.balls[1].py = spike.balls[1].y;
|
|
stepSim(spike, TUNING.SUBSTEP_DT);
|
|
check('a one-frame spike below SHOCK_FACTOR does not snap a strand',
|
|
!spike.strands[0].broken, `stress ${strandStress(spike.strands[0]).toFixed(2)}`);
|
|
|
|
// Same strand, same single substep, but genuinely violent.
|
|
const shock = mk();
|
|
const violent = stretchFor(cap * (TUNING.SHOCK_FACTOR + 1));
|
|
check('the shock test stays under the length backstop', violent < restLen * (TUNING.BREAK_RATIO - 1),
|
|
`${violent.toFixed(1)}px vs ${(restLen * (TUNING.BREAK_RATIO - 1)).toFixed(1)}px`);
|
|
shock.balls[1].y = shock.balls[0].y + restLen + violent;
|
|
shock.balls[1].py = shock.balls[1].y;
|
|
stepSim(shock, TUNING.SUBSTEP_DT);
|
|
check('a violent shock cuts straight through the fatigue window', shock.strands[0].broken);
|
|
|
|
// And sustained-but-moderate overload eventually fatigues through.
|
|
const fatigue = mk();
|
|
fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6);
|
|
for (let i = 0; i < 200 && !fatigue.strands[0].broken; i += 1) {
|
|
fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6);
|
|
fatigue.balls[1].py = fatigue.balls[1].y;
|
|
stepSim(fatigue, TUNING.SUBSTEP_DT);
|
|
}
|
|
check('sustained overload fatigues a strand through', fatigue.strands[0].broken);
|
|
}
|
|
{
|
|
// A strand stretched past BREAK_RATIO must snap.
|
|
const st = createState(baseLevel({
|
|
balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 260, type: 'common' }],
|
|
strands: [[0, 1]],
|
|
}));
|
|
const s = st.strands[0];
|
|
check('strand starts intact', !s.broken);
|
|
st.balls[1].y = 200 + s.rest * (TUNING.BREAK_RATIO + 0.3);
|
|
st.balls[1].py = st.balls[1].y;
|
|
stepSim(st, 1 / 60);
|
|
check('an extreme stretch snaps a strand', s.broken, `len/rest = ${(strandLength(st, s) / s.rest).toFixed(2)}`);
|
|
}
|
|
{
|
|
// Slack strands pull but never push.
|
|
const st = createState(baseLevel({
|
|
balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 210, type: 'common', pinned: true }],
|
|
strands: [[0, 1]],
|
|
}));
|
|
const s = st.strands[0];
|
|
check('short strand is treated as slack', strandLength(st, s) < s.rest * TUNING.SLACK_RATIO,
|
|
`len ${strandLength(st, s).toFixed(1)} rest ${s.rest.toFixed(1)}`);
|
|
}
|
|
{
|
|
// Cut a structure and the orphaned half must stop counting as rooted.
|
|
const st = createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 200, type: 'common', pinned: true },
|
|
{ x: 400, y: 262, type: 'common' },
|
|
{ x: 400, y: 324, type: 'common' },
|
|
],
|
|
strands: [[0, 1], [1, 2]],
|
|
}));
|
|
refreshStructure(st);
|
|
check('whole structure is rooted via its anchor', isRooted(st, 2));
|
|
breakStrand(st, st.strands[0], null);
|
|
refreshStructure(st);
|
|
check('orphaned chunk is no longer rooted', !isRooted(st, 2));
|
|
check('orphaned chunk stays one component (chunks fall as chunks)',
|
|
st.components.get(1) === st.components.get(2));
|
|
const before = st.balls[2].y;
|
|
settle(st, 4);
|
|
check('orphaned chunk falls', st.balls[2].y > before + 50, `moved ${(st.balls[2].y - before).toFixed(1)}px`);
|
|
}
|
|
|
|
// ── 3. Terrain ──────────────────────────────────────────────────────────────
|
|
section('3. Terrain collision');
|
|
{
|
|
const st = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] }));
|
|
settle(st, 8);
|
|
const b = st.balls[0];
|
|
check('ball rests on the ground surface', Math.abs(b.y - (GROUND_Y - b.r)) < 2.5,
|
|
`y=${b.y.toFixed(2)} expected ${(GROUND_Y - b.r).toFixed(2)}`);
|
|
check('ball is never inside solid terrain', !pointInPoly(b.x, b.y, st.terrain[0].poly));
|
|
check('resting ball is marked grounded', b.grounded);
|
|
check('resting ball counts as settled', isSettled(st));
|
|
}
|
|
{
|
|
// Dropped into the middle of a solid block, a ball must be ejected, not stuck.
|
|
const st = createState(baseLevel({
|
|
terrain: [ground(), { kind: 'solid', poly: [[300, 400], [700, 400], [700, 600], [300, 600]] }],
|
|
balls: [{ x: 500, y: 500, type: 'common' }],
|
|
}));
|
|
stepSim(st, 1 / 60);
|
|
const b = st.balls[0];
|
|
check('ball ejected from inside a block', !pointInPoly(b.x, b.y, st.terrain[1].poly),
|
|
`at ${b.x.toFixed(1)},${b.y.toFixed(1)}`);
|
|
}
|
|
{
|
|
// A ball on a slope slides downhill rather than sticking or sinking.
|
|
const st = createState(baseLevel({
|
|
terrain: [{ kind: 'solid', poly: [[0, 400], [1600, 800], [1600, 1000], [0, 1000]] }],
|
|
balls: [{ x: 400, y: 300, type: 'common' }],
|
|
}));
|
|
const x0 = st.balls[0].x;
|
|
settle(st, 10);
|
|
check('ball slides down a slope', st.balls[0].x > x0 + 10, `moved ${(st.balls[0].x - x0).toFixed(1)}px`);
|
|
check('ball stays on top of the slope', !pointInPoly(st.balls[0].x, st.balls[0].y, st.terrain[0].poly));
|
|
}
|
|
|
|
section('3b. Ball-ball collision');
|
|
{
|
|
// Loose goo must pile, not merge into a single point.
|
|
const pile = [];
|
|
for (let i = 0; i < 12; i += 1) pile.push({ x: 400 + (i % 3) * 4, y: 300 - i * 6, type: 'common' });
|
|
const st = createState(baseLevel({ pile }));
|
|
settle(st, 14);
|
|
|
|
let minGap = Infinity;
|
|
for (let i = 0; i < st.balls.length; i += 1) {
|
|
for (let j = i + 1; j < st.balls.length; j += 1) {
|
|
const a = st.balls[i], b = st.balls[j];
|
|
minGap = Math.min(minGap, Math.hypot(b.x - a.x, b.y - a.y) - (a.r + b.r));
|
|
}
|
|
}
|
|
check('loose goo piles instead of collapsing to a point', minGap > -2.5,
|
|
`closest overlap ${minGap.toFixed(2)}px`);
|
|
check('nothing in the pile is inside the terrain',
|
|
st.balls.every((b) => !pointInPoly(b.x, b.y, st.terrain[0].poly)));
|
|
// On an open floor, spreading into a single layer is the CORRECT outcome --
|
|
// round balls on flat ground roll apart. Stacking only has to happen when
|
|
// something contains them, so test that in a pit.
|
|
const spread = Math.max(...st.balls.map((b) => b.x)) - Math.min(...st.balls.map((b) => b.x));
|
|
check('goo on an open floor spreads out', spread > 100, `spread ${spread.toFixed(0)}px`);
|
|
}
|
|
{
|
|
const pit = [
|
|
ground(),
|
|
{ kind: 'solid', poly: [[330, 600], [350, 600], [350, GROUND_Y], [330, GROUND_Y]] },
|
|
{ kind: 'solid', poly: [[470, 600], [490, 600], [490, GROUND_Y], [470, GROUND_Y]] },
|
|
];
|
|
const pile = [];
|
|
for (let i = 0; i < 12; i += 1) pile.push({ x: 370 + (i % 3) * 30, y: 560 - i * 8, type: 'common' });
|
|
const st = createState(baseLevel({ terrain: pit, pile }));
|
|
settle(st, 16);
|
|
const highest = Math.min(...st.balls.map((b) => b.y));
|
|
check('contained goo stacks up', highest < GROUND_Y - 3 * 14,
|
|
`highest ball at y=${highest.toFixed(0)}, floor ${GROUND_Y}`);
|
|
check('stacked goo does not interpenetrate', (() => {
|
|
for (let i = 0; i < st.balls.length; i += 1) {
|
|
for (let j = i + 1; j < st.balls.length; j += 1) {
|
|
const a = st.balls[i], b = st.balls[j];
|
|
if (Math.hypot(b.x - a.x, b.y - a.y) < a.r + b.r - 2.5) return false;
|
|
}
|
|
}
|
|
return true;
|
|
})());
|
|
|
|
// Two balls dropped at exactly the same spot must still separate.
|
|
const twin = createState(baseLevel({ pile: [{ x: 500, y: 300 }, { x: 500, y: 300 }] }));
|
|
settle(twin, 10);
|
|
const gap = Math.hypot(twin.balls[1].x - twin.balls[0].x, twin.balls[1].y - twin.balls[0].y);
|
|
check('coincident balls separate rather than dividing by zero',
|
|
Number.isFinite(gap) && gap > twin.balls[0].r, `gap ${gap.toFixed(2)}px`);
|
|
}
|
|
|
|
// ── 4. Hazards ──────────────────────────────────────────────────────────────
|
|
section('4. Hazards');
|
|
{
|
|
const spikes = { kind: 'spike', poly: [[300, 600], [700, 600], [700, 660], [300, 660]] };
|
|
const st = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'common' }] }));
|
|
let died = null;
|
|
for (let i = 0; i < 240 && !died; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = e;
|
|
}
|
|
check('spikes kill common goo', !!died && died.cause === 'spike', died ? died.cause : 'survived');
|
|
|
|
const st2 = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'skull' }] }));
|
|
let died2 = false;
|
|
for (let i = 0; i < 240; i += 1) {
|
|
for (const e of stepSim(st2, 1 / 60)) if (e.type === 'ballDie') died2 = true;
|
|
}
|
|
check('skull goo is spike-proof', !died2);
|
|
check('spike-proof flag matches the type table', GOO_TYPES.skull.spikeProof && !GOO_TYPES.common.spikeProof);
|
|
}
|
|
{
|
|
// Killing a ball must take its strands with it.
|
|
const st = createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 200, type: 'common', pinned: true },
|
|
{ x: 400, y: 262, type: 'common' },
|
|
{ x: 400, y: 324, type: 'common' },
|
|
],
|
|
strands: [[0, 1], [1, 2]],
|
|
}));
|
|
const spikes = { kind: 'spike', poly: [[350, 330], [450, 330], [450, 380], [350, 380]] };
|
|
st.terrain.push({ ...spikes, bounds: { minX: 350, minY: 330, maxX: 450, maxY: 380 } });
|
|
let died = false;
|
|
for (let i = 0; i < 240 && !died; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = true;
|
|
}
|
|
check('a dying ball drops its strands', died && liveStrands(st).length <= 1,
|
|
`${liveStrands(st).length} strands left`);
|
|
}
|
|
{
|
|
const st = createState(baseLevel({ terrain: [], balls: [{ x: 400, y: 300, type: 'common' }] }));
|
|
let cause = null;
|
|
for (let i = 0; i < 600 && !cause; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') cause = e.cause;
|
|
}
|
|
check('falling out of the world is fatal', cause === 'fell', cause || 'survived');
|
|
}
|
|
|
|
// ── 4b. Attachment rules ────────────────────────────────────────────────────
|
|
section('4b. Attachment');
|
|
{
|
|
// Two anchors 62px apart; a ball dropped above their midpoint should reach
|
|
// both and form a triangle.
|
|
const mk = () => createState(baseLevel({
|
|
balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }],
|
|
pile: [{ x: 800, y: 700, type: 'common' }],
|
|
}));
|
|
|
|
const st = mk();
|
|
const picks = chooseAttachments(st, 431, 350, 'common');
|
|
check('a ball between two anchors attaches to both', picks.length === 2, `got ${picks.length}`);
|
|
check('canPlace agrees', canPlace(st, 431, 350, 'common'));
|
|
|
|
check('out of reach attaches to nothing',
|
|
chooseAttachments(st, 431, 100, 'common').length === 0);
|
|
check('canPlace refuses out of reach', !canPlace(st, 431, 100, 'common'));
|
|
|
|
// MIN_ANGLE: the rule that forces triangles. Sitting directly in line with
|
|
// two anchors, the far one is within MIN_ANGLE of the near one and must be
|
|
// rejected -- leaving one strand, which is below common goo's minStrands.
|
|
const inline = chooseAttachments(st, 369, 400, 'common');
|
|
check('MIN_ANGLE rejects a collinear second candidate', inline.length === 1,
|
|
`got ${inline.length}`);
|
|
check('a placement below minStrands is refused', !canPlace(st, 369, 400, 'common'));
|
|
|
|
// Line of sight.
|
|
const walled = createState(baseLevel({
|
|
terrain: [ground(), { kind: 'solid', poly: [[430, 200], [440, 200], [440, 600], [430, 600]] }],
|
|
balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 470, y: 400, type: 'common', pinned: true }],
|
|
}));
|
|
const seen = chooseAttachments(walled, 400, 350, 'common');
|
|
check('a wall blocks attachment through it', !seen.includes(1), `got [${seen}]`);
|
|
check('cannot place inside solid terrain', !canPlace(walled, 435, 400, 'common'));
|
|
|
|
// Type limits.
|
|
const many = createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 400, type: 'common', pinned: true },
|
|
{ x: 462, y: 400, type: 'common', pinned: true },
|
|
{ x: 431, y: 458, type: 'common', pinned: true },
|
|
],
|
|
}));
|
|
check('common goo takes at most 2 strands',
|
|
chooseAttachments(many, 431, 400, 'common').length <= 2);
|
|
check('balloon goo takes exactly 1 strand',
|
|
chooseAttachments(many, 431, 400, 'balloon').length <= 1);
|
|
check('attachment is deterministic',
|
|
JSON.stringify(chooseAttachments(many, 431, 400, 'common')) ===
|
|
JSON.stringify(chooseAttachments(many, 431, 400, 'common')));
|
|
}
|
|
{
|
|
// Full drag round-trip.
|
|
const st = createState(baseLevel({
|
|
balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }],
|
|
pile: [{ x: 800, y: 700, type: 'common' }],
|
|
}));
|
|
const ball = st.balls[2];
|
|
check('pickBallAt finds the loose ball', pickBallAt(st, 800, 700) === ball);
|
|
check('pickBallAt ignores non-detachable attached goo', pickBallAt(st, 400, 400) === null);
|
|
|
|
const ev = [];
|
|
check('beginDrag takes the ball', beginDrag(st, ball, ev) && ball.held);
|
|
dragTo(st, ball, 431, 350);
|
|
check('endDrag sticks it to the structure', endDrag(st, ball, 431, 350, ev));
|
|
check('the placed ball is attached', ball.attached && !ball.held);
|
|
check('it gained two strands', ball.strands.length === 2, `${ball.strands.length}`);
|
|
check('it left the pile', !st.pile.includes(ball.id));
|
|
check('a place event was emitted', ev.some((e) => e.type === 'place'));
|
|
|
|
// A failed placement returns the ball to the pile rather than eating it.
|
|
const st2 = createState(baseLevel({
|
|
balls: [{ x: 400, y: 400, type: 'common', pinned: true }],
|
|
pile: [{ x: 800, y: 700, type: 'common' }],
|
|
}));
|
|
const b2 = st2.balls[1];
|
|
const ev2 = [];
|
|
beginDrag(st2, b2, ev2);
|
|
check('a placement with too few strands fails', !endDrag(st2, b2, 400, 340, ev2));
|
|
check('the failed ball returns to the pile', st2.pile.includes(b2.id) && !b2.attached);
|
|
check('a placeFailed event was emitted', ev2.some((e) => e.type === 'placeFailed'));
|
|
|
|
// Detachable goo can be lifted back off; common goo cannot.
|
|
const st3 = createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 400, type: 'common', pinned: true },
|
|
{ x: 462, y: 400, type: 'common', pinned: true },
|
|
{ x: 431, y: 350, type: 'ivy' },
|
|
],
|
|
strands: [[2, 0], [2, 1]],
|
|
}));
|
|
check('ivy goo is detachable', beginDrag(st3, st3.balls[2]));
|
|
check('detaching drops its strands', liveStrands(st3).length === 0);
|
|
const st4 = createState(baseLevel({
|
|
balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common' }],
|
|
strands: [[0, 1]],
|
|
}));
|
|
check('common goo is not detachable', !beginDrag(st4, st4.balls[1]));
|
|
}
|
|
|
|
// ── 4c. Crawling and the pipe ───────────────────────────────────────────────
|
|
section('4c. Crawlers and the pipe');
|
|
{
|
|
// A structure that reaches the pipe, with loose goo at its foot.
|
|
const build = (pileN) => {
|
|
const balls = [
|
|
{ x: 400, y: 700, type: 'common', pinned: true },
|
|
{ x: 462, y: 700, type: 'common', pinned: true },
|
|
{ x: 431, y: 645, type: 'common' },
|
|
{ x: 431, y: 590, type: 'common' },
|
|
];
|
|
const strands = [[2, 0], [2, 1], [3, 2], [3, 0], [3, 1]];
|
|
const pile = [];
|
|
for (let i = 0; i < pileN; i += 1) pile.push({ x: 420 + i * 6, y: 690 - i * 4, type: 'common' });
|
|
return createState(baseLevel({
|
|
terrain: [ground()],
|
|
balls, strands, pile,
|
|
pipe: { x: 431, y: 570, r: 46 },
|
|
required: 2, ocdTarget: 3,
|
|
}));
|
|
};
|
|
|
|
const st = build(0);
|
|
for (let i = 0; i < 30; i += 1) stepSim(st, 1 / 60);
|
|
check('the pipe opens when the structure reaches it', st.pipe.open);
|
|
|
|
const closed = createState(baseLevel({
|
|
balls: [{ x: 400, y: 700, type: 'common', pinned: true }],
|
|
pipe: { x: 431, y: 200, r: 46 },
|
|
required: 1,
|
|
}));
|
|
for (let i = 0; i < 30; i += 1) stepSim(closed, 1 / 60);
|
|
check('a pipe out of reach stays shut', !closed.pipe.open);
|
|
|
|
// Loose goo climbs the structure and gets drunk.
|
|
const run = build(4);
|
|
let collected = 0;
|
|
let climbed = false;
|
|
for (let i = 0; i < 60 * 25; i += 1) {
|
|
for (const e of stepSim(run, 1 / 60)) {
|
|
if (e.type === 'collect') collected += 1;
|
|
if (e.type === 'climb') climbed = true;
|
|
}
|
|
if (collected >= 4) break;
|
|
}
|
|
check('loose goo climbs onto the structure', climbed);
|
|
check('crawlers reach the pipe and are collected', collected > 0, `${collected} collected`);
|
|
check('state.collected agrees with the events', run.collected === collected);
|
|
check('the level is won once required is met', isWon(run), `${run.collected}/${run.required}`);
|
|
check('OCD tracks separately from the win',
|
|
hasOCD(run) === (run.collected >= run.ocdTarget));
|
|
check('collected goo leaves the pile', run.pile.length + run.collected >= 4);
|
|
|
|
// Cutting the structure strands a crawler rather than teleporting it.
|
|
const cut = build(3);
|
|
for (let i = 0; i < 60 * 4; i += 1) stepSim(cut, 1 / 60);
|
|
const walker = cut.balls.find((b) => b.walking);
|
|
if (walker) {
|
|
const s = strandById(cut, walker.onStrand);
|
|
breakStrand(cut, s, null);
|
|
stepSim(cut, 1 / 60);
|
|
check('a crawler whose strand breaks falls off', !walker.walking);
|
|
} else {
|
|
check('a crawler whose strand breaks falls off', true, 'no crawler active; skipped');
|
|
}
|
|
}
|
|
|
|
// ── 4d. Terrain-sticking goo (anchor, pokey) ────────────────────────────────
|
|
section('4d. Anchors and wall-sticking goo');
|
|
{
|
|
const wall = { kind: 'solid', poly: [[600, 200], [640, 200], [640, 700], [600, 700]] };
|
|
const st = createState(baseLevel({ terrain: [ground(), wall], pile: [{ x: 900, y: 700, type: 'anchor' }] }));
|
|
|
|
check('distanceToSolid measures the nearest surface',
|
|
Math.abs(distanceToSolid(st, 660, 400) - 20) < 1e-6, `${distanceToSolid(st, 660, 400)}`);
|
|
check('anchor goo sticks beside a wall', canStickToTerrain(st, 660, 400, 'anchor'));
|
|
check('anchor goo does not stick in mid-air', !canStickToTerrain(st, 900, 400, 'anchor'));
|
|
check('common goo never sticks to terrain', !canStickToTerrain(st, 660, 400, 'common'));
|
|
|
|
check('a stuck anchor needs no strands', requiredStrands(st, 660, 400, 'anchor') === 0);
|
|
check('an unstuck anchor still needs none by type', gooType('anchor').minStrands === 0);
|
|
check('pokey beside a wall needs no strands', requiredStrands(st, 660, 400, 'pokey') === 0);
|
|
check('pokey in mid-air needs its full quota',
|
|
requiredStrands(st, 900, 400, 'pokey') === GOO_TYPES.pokey.minStrands);
|
|
check('common goo beside a wall still needs two',
|
|
requiredStrands(st, 660, 400, 'common') === 2);
|
|
|
|
check('an anchor can be placed on a wall', canPlace(st, 660, 400, 'anchor'));
|
|
check('an anchor cannot be placed in mid-air', !canPlace(st, 900, 400, 'anchor'));
|
|
|
|
// Placing one pins it, which is what makes it an anchor point.
|
|
const ball = st.balls.find((b) => b.type === 'anchor');
|
|
const ev = [];
|
|
beginDrag(st, ball, ev);
|
|
check('placing an anchor on a wall succeeds', endDrag(st, ball, 660, 400, ev));
|
|
check('a stuck anchor is pinned', ball.pinned && ball.invMass === 0);
|
|
check('a stick event was emitted', ev.some((e) => e.type === 'stick'));
|
|
settle(st, 4);
|
|
check('a stuck anchor never moves', Math.abs(ball.x - 660) < 1e-9 && Math.abs(ball.y - 400) < 1e-9);
|
|
check('a stuck anchor roots the structure', isRooted(st, ball.id));
|
|
}
|
|
|
|
// ── 4e. Bombs ───────────────────────────────────────────────────────────────
|
|
section('4e. Bombs and fire');
|
|
{
|
|
const fire = { kind: 'fire', poly: [[380, 500], [520, 500], [520, 560], [380, 560]] };
|
|
const mk = (over = {}) => createState(baseLevel({
|
|
terrain: [ground(), fire],
|
|
balls: [
|
|
{ x: 400, y: 300, type: 'common', pinned: true },
|
|
{ x: 462, y: 300, type: 'common', pinned: true },
|
|
{ x: 431, y: 356, type: 'common' },
|
|
],
|
|
strands: [[2, 0], [2, 1], [0, 1]],
|
|
pile: [{ x: 450, y: 450, type: 'bomb' }],
|
|
...over,
|
|
}));
|
|
|
|
const st = mk();
|
|
const bomb = st.balls.find((b) => b.type === 'bomb');
|
|
let lit = false;
|
|
let blew = false;
|
|
for (let i = 0; i < 60 * 8 && !blew; i += 1) {
|
|
for (const e of stepSim(st, 1 / 60)) {
|
|
if (e.type === 'ignite') lit = true;
|
|
if (e.type === 'explode') blew = true;
|
|
}
|
|
}
|
|
check('fire lights a bomb rather than killing it', lit);
|
|
check('a lit bomb detonates', blew);
|
|
check('the fuse is not instant', TUNING.FUSE_TIME > 0);
|
|
check('the bomb is consumed by its own blast', bomb.dead);
|
|
|
|
// Blast radius is finite: goo well clear of it survives.
|
|
const far = createState(baseLevel({
|
|
terrain: [ground(), fire],
|
|
balls: [{ x: 1400, y: 300, type: 'common', pinned: true }],
|
|
pile: [{ x: 450, y: 450, type: 'bomb' }],
|
|
}));
|
|
for (let i = 0; i < 60 * 8; i += 1) stepSim(far, 1 / 60);
|
|
check('goo outside the blast radius survives', !far.balls[0].dead);
|
|
|
|
// Blast effects, driven directly so the bomb is where we put it.
|
|
const blast = createState(baseLevel({
|
|
terrain: [
|
|
ground(),
|
|
{ kind: 'solid', poly: [[470, 300], [560, 300], [560, 340], [470, 340]], destructible: true },
|
|
{ kind: 'solid', poly: [[1300, 300], [1390, 300], [1390, 340], [1300, 340]], destructible: true },
|
|
],
|
|
balls: [
|
|
{ x: 400, y: 300, type: 'common', pinned: true },
|
|
{ x: 462, y: 300, type: 'common', pinned: true },
|
|
{ x: 431, y: 356, type: 'common' },
|
|
{ x: 1500, y: 300, type: 'common', pinned: true },
|
|
],
|
|
strands: [[2, 0], [2, 1], [0, 1]],
|
|
pile: [{ x: 431, y: 400, type: 'bomb' }],
|
|
}));
|
|
const charge = blast.balls.find((b) => b.type === 'bomb');
|
|
const terrainBefore = blast.terrain.length;
|
|
const evb = [];
|
|
detonate(blast, charge, evb);
|
|
|
|
check('a blast shears strands within BLAST_R', liveStrands(blast).length < 3,
|
|
`${liveStrands(blast).length}/3 left`);
|
|
check('a blast kills goo within BLAST_KILL_R', blast.balls[2].dead);
|
|
check('a blast spares goo well outside it', !blast.balls[3].dead);
|
|
check('a blast destroys destructible terrain in range', blast.terrain.length < terrainBefore,
|
|
`${terrainBefore} -> ${blast.terrain.length}`);
|
|
check('an explode event carries the blast position',
|
|
evb.some((e) => e.type === 'explode' && e.x === 431));
|
|
|
|
const soft = blast;
|
|
check('destructible terrain out of range survives',
|
|
soft.terrain.some((t) => t.destructible), 'all destructible terrain went');
|
|
check('indestructible terrain always survives',
|
|
soft.terrain.some((t) => t.kind === 'solid' && !t.destructible));
|
|
|
|
// Chain reaction.
|
|
const chain = createState(baseLevel({
|
|
terrain: [ground(), fire],
|
|
pile: [
|
|
{ x: 450, y: 450, type: 'bomb' },
|
|
{ x: 450, y: 390, type: 'bomb' },
|
|
{ x: 450, y: 330, type: 'bomb' },
|
|
],
|
|
}));
|
|
let blasts = 0;
|
|
for (let i = 0; i < 60 * 12; i += 1) {
|
|
for (const e of stepSim(chain, 1 / 60)) if (e.type === 'explode') blasts += 1;
|
|
}
|
|
check('bombs chain-react', blasts >= 2, `${blasts} explosions`);
|
|
|
|
// Anchors are bolted to the world.
|
|
const anchored = createState(baseLevel({
|
|
terrain: [ground(), fire],
|
|
balls: [{ x: 460, y: 430, type: 'anchor', pinned: true }],
|
|
pile: [{ x: 450, y: 450, type: 'bomb' }],
|
|
}));
|
|
for (let i = 0; i < 60 * 8; i += 1) stepSim(anchored, 1 / 60);
|
|
check('anchors survive a blast', !anchored.balls[0].dead);
|
|
}
|
|
|
|
// ── 4f. Fans and gears ──────────────────────────────────────────────────────
|
|
section('4f. Fans and gears');
|
|
{
|
|
// A fan strong enough to beat gravity lifts goo inside its volume.
|
|
const st = createState(baseLevel({
|
|
fans: [{ x: 300, y: 200, w: 300, h: 600, dx: 0, dy: -1, force: 2600 }],
|
|
pile: [{ x: 450, y: 700, type: 'common' }, { x: 1200, y: 700, type: 'common' }],
|
|
}));
|
|
const inFan = st.balls[0];
|
|
const outside = st.balls[1];
|
|
const y0 = inFan.y;
|
|
const y1 = outside.y;
|
|
for (let i = 0; i < 90; i += 1) stepSim(st, 1 / 60);
|
|
check('a fan lifts goo inside its volume', inFan.y < y0 - 30,
|
|
`moved ${(inFan.y - y0).toFixed(1)}px`);
|
|
check('goo outside the fan is unaffected', outside.y >= y1 - 2,
|
|
`moved ${(outside.y - y1).toFixed(1)}px`);
|
|
check('a sideways fan pushes sideways', (() => {
|
|
const s2 = createState(baseLevel({
|
|
fans: [{ x: 300, y: 600, w: 600, h: 260, dx: 1, dy: 0, force: 2600 }],
|
|
pile: [{ x: 400, y: 800, type: 'common' }],
|
|
}));
|
|
const x0 = s2.balls[0].x;
|
|
for (let i = 0; i < 90; i += 1) stepSim(s2, 1 / 60);
|
|
return s2.balls[0].x > x0 + 30;
|
|
})());
|
|
}
|
|
{
|
|
// A gear is solid, spins, and drags what touches it.
|
|
const mkGear = (omega) => createState(baseLevel({
|
|
terrain: [ground()],
|
|
gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega }],
|
|
pile: [{ x: 500, y: 460, type: 'common' }],
|
|
}));
|
|
|
|
const spun = mkGear(3);
|
|
const gearT = spun.terrain.find((t) => t.gear);
|
|
check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid');
|
|
const a0 = gearT.gear.angle;
|
|
for (let i = 0; i < 60; i += 1) stepSim(spun, 1 / 60);
|
|
check('a gear rotates', Math.abs(gearT.gear.angle - a0) > 1,
|
|
`angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`);
|
|
check('a spinning gear stays solid (goo never sinks into it)',
|
|
!pointInPoly(spun.balls[0].x, spun.balls[0].y, gearT.poly));
|
|
|
|
// Positive omega drags the top of the wheel to the right (screen y is down).
|
|
const right = mkGear(4);
|
|
const left = mkGear(-4);
|
|
const rx = right.balls[0].x;
|
|
const lx = left.balls[0].x;
|
|
for (let i = 0; i < 150; i += 1) { stepSim(right, 1 / 60); stepSim(left, 1 / 60); }
|
|
check('a gear drags goo along its rim',
|
|
right.balls[0].x > rx + 5 && left.balls[0].x < lx - 5,
|
|
`cw ${(right.balls[0].x - rx).toFixed(1)}px, ccw ${(left.balls[0].x - lx).toFixed(1)}px`);
|
|
|
|
// Time-varying terrain must not cost determinism.
|
|
const d1 = mkGear(3);
|
|
const d2 = mkGear(3);
|
|
for (let i = 0; i < 200; i += 1) { stepSim(d1, 1 / 60); stepSim(d2, 1 / 60); }
|
|
check('gears stay deterministic', hashState(d1) === hashState(d2));
|
|
}
|
|
|
|
// ── 4g. The remaining goo types ─────────────────────────────────────────────
|
|
section('4g. Balloon, block, bit');
|
|
{
|
|
// A balloon tied to a hanging structure must visibly hold it up.
|
|
const build = (tip) => {
|
|
const balls = [
|
|
{ x: 400, y: 200, type: 'common', pinned: true },
|
|
{ x: 462, y: 200, type: 'common', pinned: true },
|
|
{ x: 431, y: 256, type: 'common' },
|
|
{ x: 431, y: 318, type: 'common' },
|
|
];
|
|
const strands = [[2, 0], [2, 1], [3, 2]];
|
|
if (tip) {
|
|
// Clear of every other ball: goo that starts overlapping is ejected hard
|
|
// by the collision solver and snaps its own strand.
|
|
balls.push({ x: 493, y: 256, type: 'balloon' });
|
|
strands.push([4, 3]);
|
|
}
|
|
return createState(baseLevel({ terrain: [], balls, strands }));
|
|
};
|
|
const plain = build(false);
|
|
const lifted = build(true);
|
|
settle(plain, 12);
|
|
settle(lifted, 12);
|
|
|
|
// Position is a weak way to measure lift: the structure is held by strands
|
|
// stiff enough that ~1.3 ball-weights of buoyancy only moves it a few px.
|
|
// The load it takes OFF the supporting strand is the real signal.
|
|
const plainLoad = plain.strands[2].tension;
|
|
const liftedLoad = lifted.strands[2].tension;
|
|
check('a balloon takes load off the strand holding the structure',
|
|
liftedLoad < plainLoad * 0.7,
|
|
`${liftedLoad.toFixed(0)} vs ${plainLoad.toFixed(0)}`);
|
|
check('a balloon lifts the structure it is tied to',
|
|
lifted.balls[3].y < plain.balls[3].y - 2,
|
|
`${lifted.balls[3].y.toFixed(2)} vs ${plain.balls[3].y.toFixed(2)}`);
|
|
check('one balloon is worth about one goo of lift',
|
|
Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) > 1
|
|
&& Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) < 3,
|
|
`${(GOO_TYPES.balloon.mass * -GOO_TYPES.balloon.buoyancy).toFixed(2)} ball-weights`);
|
|
check('balloon goo takes a single strand', GOO_TYPES.balloon.maxStrands === 1);
|
|
|
|
// Block goo is stiffer, so the same span sags less.
|
|
const span = (type) => {
|
|
const balls = [{ x: 300, y: 300, type, pinned: true }];
|
|
const strands = [];
|
|
for (let i = 1; i <= 3; i += 1) {
|
|
balls.push({ x: 300 + i * TUNING.STRAND_REST, y: 300, type });
|
|
strands.push([i - 1, i]);
|
|
}
|
|
const s = createState(baseLevel({ terrain: [], balls, strands }));
|
|
settle(s, 12);
|
|
return s.balls[3].y - 300;
|
|
};
|
|
const blockSag = span('block');
|
|
const commonSag = span('common');
|
|
check('block goo is stiffer than common goo', blockSag < commonSag,
|
|
`block ${blockSag.toFixed(1)}px vs common ${commonSag.toFixed(1)}px`);
|
|
|
|
check('bit goo is small and light',
|
|
GOO_TYPES.bit.r < GOO_TYPES.common.r && GOO_TYPES.bit.mass < GOO_TYPES.common.mass);
|
|
check('bit goo hangs off a single strand', GOO_TYPES.bit.minStrands === 1);
|
|
check('every goo type is reachable from gooType()',
|
|
Object.keys(GOO_TYPES).every((k) => gooType(k) === GOO_TYPES[k]));
|
|
}
|
|
|
|
// ── 5. Stability ────────────────────────────────────────────────────────────
|
|
section('5. Stability');
|
|
{
|
|
// A dense, stiff, over-constrained lattice is the worst case for a naive
|
|
// spring integrator. It must stay finite and bounded.
|
|
const COLS = 8, ROWS = 5, DX = TUNING.STRAND_REST;
|
|
const balls = [], strands = [];
|
|
const at = (r, c) => r * COLS + c;
|
|
for (let r = 0; r < ROWS; r += 1) {
|
|
for (let c = 0; c < COLS; c += 1) {
|
|
balls.push({ x: 300 + c * DX, y: 300 + r * DX, type: 'block', pinned: r === 0 });
|
|
}
|
|
}
|
|
for (let r = 0; r < ROWS; r += 1) {
|
|
for (let c = 0; c < COLS; c += 1) {
|
|
if (c + 1 < COLS) strands.push([at(r, c), at(r, c + 1)]);
|
|
if (r + 1 < ROWS) strands.push([at(r, c), at(r + 1, c)]);
|
|
if (r + 1 < ROWS && c + 1 < COLS) strands.push([at(r, c), at(r + 1, c + 1)]);
|
|
if (r + 1 < ROWS && c > 0) strands.push([at(r, c), at(r + 1, c - 1)]);
|
|
}
|
|
}
|
|
const st = createState(baseLevel({ balls, strands }));
|
|
settle(st, 12);
|
|
|
|
let finite = true, bounded = true, maxSpeed = 0;
|
|
for (const b of st.balls) {
|
|
if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) finite = false;
|
|
if (Math.abs(b.x) > 1e5 || Math.abs(b.y) > 1e5) bounded = false;
|
|
maxSpeed = Math.max(maxSpeed, ballSpeed(st, b));
|
|
}
|
|
check('stiff lattice stays finite (no NaN)', finite);
|
|
check('stiff lattice stays bounded (no explosion)', bounded);
|
|
check('stiff lattice comes to rest', maxSpeed < TUNING.SETTLE_SPEED, `max speed ${maxSpeed.toFixed(2)}`);
|
|
check('stiff lattice keeps most of its strands', liveStrands(st).length > strands.length * 0.9,
|
|
`${liveStrands(st).length}/${strands.length}`);
|
|
check('per-substep travel is capped', maxSpeed <= TUNING.MAX_SPEED + 1e-6);
|
|
}
|
|
{
|
|
// Buoyant goo must rise, not sink.
|
|
const st = createState(baseLevel({ balls: [{ x: 400, y: 500, type: 'balloon' }] }));
|
|
const y0 = st.balls[0].y;
|
|
for (let i = 0; i < 60; i += 1) stepSim(st, 1 / 60);
|
|
check('balloon goo rises', st.balls[0].y < y0 - 20, `moved ${(st.balls[0].y - y0).toFixed(1)}px`);
|
|
check('common goo falls', (() => {
|
|
const s2 = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] }));
|
|
const start = s2.balls[0].y;
|
|
for (let i = 0; i < 20; i += 1) stepSim(s2, 1 / 60);
|
|
return s2.balls[0].y > start + 5;
|
|
})());
|
|
}
|
|
|
|
// ── 6. Determinism ──────────────────────────────────────────────────────────
|
|
section('6. Determinism');
|
|
{
|
|
const mk = () => createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 200, type: 'common', pinned: true },
|
|
{ x: 462, y: 200, type: 'common' },
|
|
{ x: 431, y: 254, type: 'common' },
|
|
{ x: 493, y: 254, type: 'common' },
|
|
],
|
|
strands: [[0, 1], [0, 2], [1, 2], [1, 3], [2, 3]],
|
|
}), 12345);
|
|
|
|
const a = mk(), b = mk();
|
|
for (let i = 0; i < 300; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); }
|
|
check('identical states replay bit-identically', hashState(a) === hashState(b),
|
|
`${hashState(a)} vs ${hashState(b)}`);
|
|
|
|
// Frame-rate independence: one 1/60 step must equal four 1/240 steps.
|
|
const c = mk(), d = mk();
|
|
for (let i = 0; i < 300; i += 1) {
|
|
stepSim(c, 1 / 60);
|
|
for (let k = 0; k < 4; k += 1) stepSim(d, TUNING.SUBSTEP_DT);
|
|
}
|
|
check('1/60 step == 4x substep', hashState(c) === hashState(d),
|
|
`${hashState(c)} vs ${hashState(d)}`);
|
|
|
|
// A ragged frame budget must not change the outcome either.
|
|
const e = mk(), f = mk();
|
|
const raggedRng = mulberry32(999);
|
|
let tE = 0, tF = 0;
|
|
const TOTAL = 5;
|
|
while (tE < TOTAL) { const dt = TUNING.SUBSTEP_DT * 4; stepSim(e, dt); tE += dt; }
|
|
while (tF < TOTAL) {
|
|
const n = 1 + Math.floor(raggedRng() * 6);
|
|
const dt = TUNING.SUBSTEP_DT * n;
|
|
if (tF + dt > tE) break;
|
|
stepSim(f, dt); tF += dt;
|
|
}
|
|
while (tF < tE - 1e-9) { stepSim(f, TUNING.SUBSTEP_DT); tF += TUNING.SUBSTEP_DT; }
|
|
check('ragged frame pacing reaches the same state', hashState(e) === hashState(f),
|
|
`${hashState(e)} vs ${hashState(f)}`);
|
|
|
|
const g = mk();
|
|
const h = cloneState(g);
|
|
for (let i = 0; i < 120; i += 1) { stepSim(g, 1 / 60); stepSim(h, 1 / 60); }
|
|
check('cloneState produces an independent, identical sim', hashState(g) === hashState(h));
|
|
const g2 = cloneState(g);
|
|
for (let i = 0; i < 30; i += 1) stepSim(g2, 1 / 60);
|
|
check('mutating a clone does not touch the original', hashState(g) !== hashState(g2));
|
|
}
|
|
{
|
|
const rng = mulberry32(42);
|
|
const first = [rng(), rng(), rng()];
|
|
const rng2 = mulberry32(42);
|
|
const second = [rng2(), rng2(), rng2()];
|
|
check('seeded rng is reproducible', first.every((v, i) => v === second[i]));
|
|
check('rng stays in [0,1)', first.every((v) => v >= 0 && v < 1));
|
|
}
|
|
|
|
// ── 7. Type table sanity ────────────────────────────────────────────────────
|
|
section('7. Goo type table');
|
|
{
|
|
let ok = true, why = '';
|
|
for (const [name, t] of Object.entries(GOO_TYPES)) {
|
|
if (t.minStrands > t.maxStrands) { ok = false; why = `${name} min>max`; }
|
|
if (t.r <= 0) { ok = false; why = `${name} bad radius`; }
|
|
if (t.mass < 0) { ok = false; why = `${name} negative mass`; }
|
|
}
|
|
check('every goo type is coherent', ok, why);
|
|
check('gooType falls back to common', gooType('nonexistent') === GOO_TYPES.common);
|
|
check('anchor is massless and pinned in practice', GOO_TYPES.anchor.mass === 0);
|
|
check('balloon is the only single-strand lifter', GOO_TYPES.balloon.buoyancy < 0);
|
|
}
|
|
|
|
// ── 8. The shipped level bank ───────────────────────────────────────────────
|
|
section('8. Level bank');
|
|
{
|
|
const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
|
|
let manifest = null;
|
|
try {
|
|
manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8'));
|
|
} catch (e) {
|
|
check('levels.json loads', false, e.message);
|
|
}
|
|
|
|
if (manifest) {
|
|
check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length > 0);
|
|
check('manifest levels are numbered 1..N with no gaps',
|
|
manifest.levels.every((m, i) => m.level === i + 1),
|
|
manifest.levels.map((m) => m.level).join(','));
|
|
check('every manifest entry names a chapter that exists',
|
|
manifest.levels.every((m) => (manifest.chapters || []).some((c) => c.id === m.chapter)));
|
|
|
|
for (const entry of manifest.levels) {
|
|
const tag = `L${entry.level} ${entry.name}`;
|
|
let def = null;
|
|
try {
|
|
def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8'));
|
|
} catch (e) {
|
|
check(`${tag}: file loads`, false, e.message);
|
|
continue;
|
|
}
|
|
|
|
check(`${tag}: manifest matches the level file`,
|
|
def.level === entry.level && def.required === entry.required && def.ocdTarget === entry.ocdTarget);
|
|
check(`${tag}: has a pipe`, !!def.pipe);
|
|
check(`${tag}: OCD target is at least the requirement`, def.ocdTarget >= def.required);
|
|
|
|
const st = createState(def);
|
|
const pileSize = st.pile.length;
|
|
check(`${tag}: enough goo exists to meet OCD`, pileSize >= def.ocdTarget,
|
|
`${pileSize} loose vs OCD ${def.ocdTarget}`);
|
|
check(`${tag}: the starting structure is rooted`,
|
|
st.balls.some((b) => b.attached && isRooted(st, b.id)));
|
|
check(`${tag}: nothing starts inside solid terrain`,
|
|
st.balls.every((b) => !insideSolid(st, b.x, b.y)));
|
|
// Overlapping goo is ejected violently by the collision solver and can
|
|
// snap its own strands before the player touches anything.
|
|
check(`${tag}: no goo starts overlapping`, (() => {
|
|
for (let i = 0; i < st.balls.length; i += 1) {
|
|
for (let j = i + 1; j < st.balls.length; j += 1) {
|
|
const a = st.balls[i];
|
|
const b = st.balls[j];
|
|
if (Math.hypot(b.x - a.x, b.y - a.y) < (a.r + b.r) * 0.6) return false;
|
|
}
|
|
}
|
|
return true;
|
|
})());
|
|
check(`${tag}: the pipe does not start open`, !st.pipe.open);
|
|
|
|
// The level must be quiet before the player touches it. A bank level that
|
|
// spends its first seconds collapsing, shedding strands or losing goo off
|
|
// the rim is a bug in the level, not in the physics.
|
|
settle(st, 10);
|
|
check(`${tag}: settles without breaking a strand`,
|
|
liveStrands(st).length === st.strands.length,
|
|
`${liveStrands(st).length}/${st.strands.length}`);
|
|
check(`${tag}: settles without losing goo`,
|
|
st.balls.every((b) => !b.dead),
|
|
`${st.balls.filter((b) => b.dead).length} lost`);
|
|
check(`${tag}: still has OCD-many goo after settling`,
|
|
st.pile.length >= def.ocdTarget, `${st.pile.length} left`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 9. Winnability ──────────────────────────────────────────────────────────
|
|
// The gate that matters. Everything above proves the physics is sane; this
|
|
// proves the levels can actually be beaten. It caught two real level bugs on
|
|
// the first run (a level that handed out exactly enough goo to reach the pipe
|
|
// and none to feed it, and a level asking for a 677px traverse at level 3).
|
|
section('9. Winnability (greedy reference player)');
|
|
{
|
|
const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
|
|
let manifest = null;
|
|
try {
|
|
manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8'));
|
|
} catch (_) { manifest = null; }
|
|
|
|
if (manifest) {
|
|
for (const entry of manifest.levels) {
|
|
const def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8'));
|
|
const r = autoPlay(def);
|
|
check(`L${entry.level} ${entry.name}: a naive builder can win it`,
|
|
r.won,
|
|
`placed ${r.placed}, pipe ${r.pipeOpen ? 'open' : 'SHUT'}, collected ${r.collected}/${def.required}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 10. Editor contract ─────────────────────────────────────────────────────
|
|
// GooTowerEditor.js imports Phaser, so it cannot run here. What CAN be pinned
|
|
// down are the assumptions it makes about this module — the ones that would
|
|
// break it silently.
|
|
section('10. Editor contract');
|
|
{
|
|
// placeStructureBall() maps the ball ids returned by chooseAttachments
|
|
// straight onto indices of its own `balls` array. That is only valid because
|
|
// createState adds structure balls first, in order, before the pile. If that
|
|
// order ever changes, the editor would wire strands to the wrong goo.
|
|
const st = createState(baseLevel({
|
|
balls: [
|
|
{ x: 400, y: 400, type: 'common', pinned: true },
|
|
{ x: 462, y: 400, type: 'common', pinned: true },
|
|
{ x: 431, y: 350, type: 'ivy' },
|
|
],
|
|
pile: [{ x: 900, y: 700, type: 'common' }, { x: 940, y: 700, type: 'common' }],
|
|
}));
|
|
check('structure ball index === ball id',
|
|
st.balls.slice(0, 3).every((b, i) => b.id === i));
|
|
check('pile goo comes after the structure, in order',
|
|
st.pile.length === 2 && st.pile[0] === 3 && st.pile[1] === 4, `pile=[${st.pile}]`);
|
|
check('structure goo is attached, pile goo is not',
|
|
st.balls.slice(0, 3).every((b) => b.attached) && st.balls.slice(3).every((b) => !b.attached));
|
|
}
|
|
{
|
|
// The editor's Settle button writes settled positions back into the level.
|
|
// That is only worth doing if a settled level STAYS settled when reloaded --
|
|
// otherwise every export would drift.
|
|
const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
|
|
const def = JSON.parse(readFileSync(join(bankDir, 'level-001.json'), 'utf8'));
|
|
|
|
const first = createState(def);
|
|
settlePhysics(first, 14);
|
|
// Replicate the editor's write-back: structure balls, then pile, in order.
|
|
const settled = JSON.parse(JSON.stringify(def));
|
|
settled.balls.forEach((b, i) => { b.x = Math.round(first.balls[i].x); b.y = Math.round(first.balls[i].y); });
|
|
const offset = settled.balls.length;
|
|
settled.pile.forEach((p, i) => { p.x = Math.round(first.balls[offset + i].x); p.y = Math.round(first.balls[offset + i].y); });
|
|
|
|
const second = createState(settled);
|
|
const before = second.balls.map((b) => ({ x: b.x, y: b.y }));
|
|
settlePhysics(second, 14);
|
|
let maxDrift = 0;
|
|
second.balls.forEach((b, i) => {
|
|
maxDrift = Math.max(maxDrift, Math.hypot(b.x - before[i].x, b.y - before[i].y));
|
|
});
|
|
check('a settled level stays settled when reloaded', maxDrift < 12,
|
|
`max drift ${maxDrift.toFixed(1)}px`);
|
|
check('settling never breaks a strand on a good level',
|
|
liveStrands(second).length === second.strands.length);
|
|
check('settling never loses goo on a good level', second.balls.every((b) => !b.dead));
|
|
}
|
|
{
|
|
// The editor exports exactly the shape createState consumes. Round-trip a
|
|
// shipped level through that shape and it must still behave identically.
|
|
const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower');
|
|
const def = JSON.parse(readFileSync(join(bankDir, 'level-002.json'), 'utf8'));
|
|
const roundTripped = {
|
|
world: { w: 1600, h: 1000 },
|
|
level: def.level, name: def.name, chapter: def.chapter, tip: def.tip,
|
|
terrain: def.terrain.map((t) => ({ kind: t.kind, poly: t.poly.map((p) => [p[0], p[1]]) })),
|
|
balls: def.balls.map((b) => ({ x: b.x, y: b.y, type: b.type, pinned: !!b.pinned })),
|
|
strands: def.strands.map((s) => [s[0], s[1]]),
|
|
pile: def.pile.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
|
pipe: { x: def.pipe.x, y: def.pipe.y, r: def.pipe.r },
|
|
required: def.required, ocdTarget: def.ocdTarget,
|
|
};
|
|
const a = createState(def);
|
|
const b = createState(roundTripped);
|
|
for (let i = 0; i < 120; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); }
|
|
check('an editor round-trip produces an identical level', hashState(a) === hashState(b),
|
|
`${hashState(a)} vs ${hashState(b)}`);
|
|
}
|
|
|
|
console.log(`\n[verify] ${passes} passed, ${failures} failed`);
|
|
if (failures > 0) process.exit(1);
|