// 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 grinds up anything that 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; let events = []; for (let i = 0; i < 60; i += 1) events = events.concat(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('touching a gear kills the ball', spun.balls[0].dead); check('a gear kill fires a ballDie event with cause "gear"', events.some((e) => e.type === 'ballDie' && e.cause === 'gear')); // A ball that never comes near the gear is unaffected. const safe = createState(baseLevel({ terrain: [ground()], gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega: 3 }], pile: [{ x: 1200, y: 460, type: 'common' }], })); for (let i = 0; i < 90; i += 1) stepSim(safe, 1 / 60); check('a ball far from the gear survives', !safe.balls[0].dead); // 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); // Buoyancy is a property of ATTACHED goo only. Loose balloons that drift // away take every balloon in a level's heap with them -- they float up, // hit whatever ceiling the level has, and die before the player has // touched anything, leaving the level unwinnable. const heap = createState(baseLevel({ terrain: [ground(), { kind: 'spike', poly: [[300, 300], [1300, 300], [1300, 360], [300, 360]] }], // Resting ON the ground: GROUND_Y here is 800, not the generator's 850. pile: [ { x: 600, y: GROUND_Y - 16, type: 'balloon' }, { x: 640, y: GROUND_Y - 16, type: 'balloon' }, { x: 680, y: GROUND_Y - 16, type: 'common' }, ], })); settle(heap, 12); check('loose balloons stay in the heap', heap.balls.every((b) => !b.dead), `${heap.balls.filter((b) => b.dead).length} floated off and died`); check('loose balloons rest on the ground', heap.balls.filter((b) => b.type === 'balloon').every((b) => b.y > GROUND_Y - 40), heap.balls.filter((b) => b.type === 'balloon').map((b) => b.y.toFixed(0)).join(',')); // 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])); } // ── 4h. Sleeping goo ───────────────────────────────────────────────────────── section('4h. Sleeping goo'); { // One pinned structure ball on the ground and one loose ball resting // nearby, with or without a wall between them blocking straight-line sight // (the same lineBlocked test section 1 exercises directly). const buildSleeper = (walled) => { const terrain = [ground()]; if (walled) { terrain.push({ kind: 'solid', poly: [[520, GROUND_Y - 200], [540, GROUND_Y - 200], [540, GROUND_Y], [520, GROUND_Y]] }); } return createState(baseLevel({ terrain, balls: [{ x: 400, y: GROUND_Y - 14, type: 'common', pinned: true }], pile: [{ x: 600, y: GROUND_Y - 14, type: 'common', asleep: true }], })); }; const walled = buildSleeper(true); const wSleeper = walled.balls[1]; check('a pile ball authored asleep starts asleep', wSleeper.asleep); const startX = wSleeper.x, startY = wSleeper.y; for (let i = 0; i < 60 * 3; i += 1) stepSim(walled, 1 / 60); check('a sleeper walled off from the structure stays asleep', wSleeper.asleep); const drift = Math.hypot(wSleeper.x - startX, wSleeper.y - startY); check('an asleep ball does not wander off on its own', drift < 3, `moved ${drift.toFixed(2)}px`); check('beginDrag refuses to pick up a sleeping ball', !beginDrag(walled, wSleeper, null)); check('a refused pickup never sets held', !wSleeper.held); const clear = buildSleeper(false); const cSleeper = clear.balls[1]; for (let i = 0; i < 60 * 3; i += 1) stepSim(clear, 1 / 60); check('a sleeper with a clear line to the structure wakes on its own', !cSleeper.asleep); check('once awake it can be dragged like ordinary loose goo', beginDrag(clear, cSleeper, null)); // Same three seconds either side -- the wall is what gates waking, not time. check('the wake condition is the wall, not the clock', wSleeper.asleep && !cSleeper.asleep); } // ── 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);