import test from 'node:test'; import assert from 'node:assert/strict'; import { Game, PHYSICS_DT, PULL_TIME } from '../public/js/game.js'; import { segmentsCross } from '../public/js/physics.js'; import { SHOOTER_X, LANE_LEFT } from '../public/js/table.js'; function seeded(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; } function run(game, seconds, onStep) { const steps = Math.round(seconds / PHYSICS_DT); for (let i = 0; i < steps; i++) { game.step(); if (onStep && onStep() === false) return; } } function newGame(seed = 1) { const game = new Game({ random: seeded(seed) }); game.setInput('launch', true); // starts the game game.setInput('launch', false); return game; } function plunge(game, seconds) { game.setInput('launch', true); run(game, seconds); game.setInput('launch', false); } test('space starts a game with the first ball in the shooter lane', () => { const game = new Game({ random: seeded(1) }); assert.equal(game.state, 'attract'); game.setInput('launch', true); game.setInput('launch', false); assert.equal(game.state, 'play'); assert.equal(game.ballNumber, 1); assert.ok(game.ball.x > LANE_LEFT); }); test('pulling the plunger back never drains the ball', () => { const game = newGame(); game.setInput('launch', true); run(game, 1.5); assert.equal(game.state, 'play'); assert.equal(game.stats.drains, 0); }); test('holding space longer launches the ball harder', () => { const speeds = [0, 0.3, 0.6, 0.9].map((hold) => { const game = newGame(); game.setInput('launch', true); run(game, hold); const pull = game.plunger.pull; game.setInput('launch', false); assert.ok(Math.abs(pull - Math.min(1, hold / PULL_TIME)) < 0.01, `pull after ${hold}s was ${pull}`); return -game.ball.vy; }); for (let i = 1; i < speeds.length; i++) assert.ok(speeds[i] > speeds[i - 1], speeds.join(', ')); }); test('every plunger strength gets the ball out of the shooter lane', () => { for (const hold of [0, 0.2, 0.45, 0.9]) { const game = newGame(); plunge(game, hold); run(game, 1.5, () => !game.inPlay); assert.equal(game.inPlay, true, `hold ${hold}s`); } }); test('a ball dropped on a raised flipper cradles, and flipping sends it up the table', () => { const game = newGame(); game.setInput('left', true); run(game, 0.1); game.ball.place(190, 820); game.inPlay = true; run(game, 3); assert.ok(game.ball.speed < 20, 'ball should come to rest'); assert.equal(game.state, 'play'); game.setInput('left', false); run(game, 0.25); game.setInput('left', true); let highest = Infinity; run(game, 1.2, () => { highest = Math.min(highest, game.ball.y); }); assert.ok(highest < 300, `ball only reached y=${highest}`); }); test('three drains end the game and the bonus is paid per ball', () => { const game = newGame(); for (let ballNumber = 1; ballNumber <= 3; ballNumber++) { assert.equal(game.ballNumber, ballNumber); game.bonus = 2000; game.multiplier = 3; const before = game.score; game.ball.place(238, 900); // straight down the middle game.inPlay = true; run(game, 4, () => game.state === 'play' || game.state === 'bonus'); assert.equal(game.score - before, 6000); } assert.equal(game.state, 'gameover'); assert.equal(game.highScore, 18000); game.setInput('launch', true); assert.equal(game.state, 'play'); assert.equal(game.score, 0); }); test('completing the top lanes raises the bonus multiplier', () => { const game = newGame(); game.inPlay = true; game.skillShotLive = false; for (const r of game.table.rollovers.filter((r) => r.kind === 'top')) game.hitRollover(r); assert.equal(game.multiplier, 2); assert.deepEqual(game.topLit, [false, false, false]); }); test('the flipper buttons shift the lit top lanes', () => { const game = newGame(); game.topLit = [true, false, false]; game.setInput('right', true); assert.deepEqual(game.topLit, [false, true, false]); game.setInput('right', true); // holding the button does not shift again assert.deepEqual(game.topLit, [false, true, false]); game.setInput('left', true); assert.deepEqual(game.topLit, [true, false, false]); game.setInput('left', false); game.setInput('left', true); assert.deepEqual(game.topLit, [false, false, true]); }); test('the flippers steer the blinking skill-shot lane while it is live', () => { const game = newGame(); game.skillShotLane = 1; game.setInput('left', true); assert.equal(game.skillShotLane, 0); game.setInput('left', false); game.setInput('right', true); assert.equal(game.skillShotLane, 1); }); test('a soft plunge usually drops into the top lanes and a full plunge orbits', () => { const firstSwitch = (hold) => { const game = newGame(); plunge(game, hold); game.takeEvents(); let first = null; run(game, 8, () => { for (const e of game.takeEvents()) { if (first) break; if (e.type === 'rollover') first = e.kind; else if (['bumper', 'kicker', 'sling', 'drop', 'standup', 'drain'].includes(e.type)) first = e.type; } return first === null; }); return first; }; let lanes = 0; let drains = 0; let count = 0; for (let ms = 0; ms <= 500; ms += 20, count++) { const first = firstSwitch(ms / 1000); if (first === 'top') lanes++; if (first === 'drain') drains++; } assert.ok(lanes / count >= 0.7, `only ${lanes}/${count} soft plunges reached a top lane`); assert.equal(drains, 0); // A full plunge sends the ball all the way around the orbit and down the outer lane, where it meets // the drop-target bank (flanking the pop bumpers) before reaching the kicker bulge further down. assert.equal(firstSwitch(0.9), 'drop'); }); test('knocking down every drop target lights super bumpers and the bank resets', () => { const game = newGame(); game.ball.place(300, 300); for (const d of game.table.dropTargets) game.hitDropTarget(d); assert.ok(game.superBumpers > 0); run(game, 2); assert.ok(game.table.dropTargets.every((d) => !d.down)); }); function shootScoop(game) { game.table.lock.cooldown = 0; // the scoop briefly refuses back-to-back hits; tests fire it on demand const ball = game.ball; ball.x = game.table.lock.x; ball.y = game.table.lock.y; ball.vx = 0; ball.vy = 1000; // comfortably above the scoop's capture speed run(game, 0.05); } test('locking a ball does not count as a drain or advance the ball number', () => { const game = newGame(); const ballNumberBefore = game.ballNumber; shootScoop(game); assert.equal(game.locked, 1); assert.equal(game.ballNumber, ballNumberBefore); assert.equal(game.stats.drains, 0); assert.equal(game.state, 'play'); assert.equal(game.balls.length, 1); // the locked ball was removed and a fresh one served assert.ok(game.ball.x > LANE_LEFT, 'the fresh ball should be back on the plunger'); }); test('locking two balls then shooting the scoop a third time starts 3-ball multiball', () => { const game = newGame(); shootScoop(game); shootScoop(game); assert.equal(game.locked, 2); shootScoop(game); assert.equal(game.state, 'multiball'); assert.equal(game.locked, 0); assert.equal(game.balls.length, 3); assert.equal(game.jackpot.left, true); assert.equal(game.jackpot.right, true); assert.equal(game.jackpot.super, false); assert.equal(game.stats.multiballs, 1); for (const ball of game.balls) { assert.ok(ball.x >= 0 && ball.x <= 514, `spawned ball should be on the table: x=${ball.x}`); } }); test('a ramp shot during multiball collects its jackpot, and collecting both lights the super jackpot', () => { const game = newGame(); game.state = 'multiball'; const ball = game.spawnBall(300, 500); game.balls = [ball]; game.jackpot.left = true; game.jackpot.right = true; game.jackpot.value = 25000; const before = game.score; const leftTrack = game.table.ramps.find((r) => r.track.side === 'left').track; ball.track = leftTrack; ball.s = leftTrack.length - 1; ball.v = 500; run(game, 0.02); assert.equal(game.jackpot.left, false); assert.equal(game.score - before, 25000); assert.equal(game.jackpot.super, false); // only one side collected so far const rightTrack = game.table.ramps.find((r) => r.track.side === 'right').track; ball.track = rightTrack; ball.s = rightTrack.length - 1; ball.v = 500; run(game, 0.02); assert.equal(game.jackpot.right, false); assert.equal(game.jackpot.super, true); }); test('collecting the super jackpot serves a fresh ball and relights both jackpots', () => { const game = newGame(); game.state = 'multiball'; const ball = game.spawnBall(game.table.lock.x, game.table.lock.y); game.balls = [ball]; game.jackpot.super = true; game.jackpot.left = false; game.jackpot.right = false; ball.vx = 0; ball.vy = 1000; const before = game.score; run(game, 0.05); assert.equal(game.jackpot.super, false); assert.equal(game.jackpot.left, true); assert.equal(game.jackpot.right, true); assert.equal(game.score - before, 100000); assert.equal(game.balls.length, 1); // the old ball was removed and a fresh one served assert.notEqual(game.balls[0], ball); }); test('multiball continues until the very last ball drains, then ends as a normal ball loss', () => { const game = newGame(); game.state = 'multiball'; const a = game.spawnBall(200, 500); const b = game.spawnBall(300, 500); const c = game.spawnBall(250, 900); game.balls = [a, b, c]; game.bonus = 1000; game.multiplier = 2; a.x = 100; a.y = 1050; a.vx = 0; a.vy = 10; run(game, 0.01); assert.equal(game.state, 'multiball'); assert.equal(game.balls.length, 2); b.x = 100; b.y = 1050; b.vx = 0; b.vy = 10; run(game, 0.01); assert.equal(game.state, 'multiball'); // one ball is still in play assert.equal(game.balls.length, 1); assert.equal(game.balls[0], c); c.x = 100; c.y = 1050; c.vx = 0; c.vy = 10; run(game, 0.01); assert.equal(game.state, 'bonus'); // the last ball of the turn: a normal drain assert.equal(game.balls.length, 0); assert.equal(game.bonusAward, 1000 * 2); }); test('long random play never tunnels through walls or escapes the table', () => { const game = newGame(7); const rand = seeded(99); let holdLeft = 0; let holdRight = 0; let tunnels = 0; for (let i = 0; i < 180 / PHYSICS_DT; i++) { if (game.state === 'gameover') game.setInput('launch', true), game.setInput('launch', false); if ( (game.state === 'play' || game.state === 'multiball') && !game.inPlay && game.ball && game.ball.x === SHOOTER_X && Math.abs(game.ball.vy) < 1 && !game.plunger.pulling ) { plunge(game, rand() * 0.9); } for (const ball of game.balls) { for (const [index, f] of game.table.flippers.entries()) { if (Math.hypot(ball.x - f.x, ball.y - f.y) < 85 && ball.vy > -50 && rand() < 0.02) { if (index === 0) holdLeft = 0.18; else holdRight = 0.18; } } } holdLeft -= PHYSICS_DT; holdRight -= PHYSICS_DT; game.setInput('left', holdLeft > 0); game.setInput('right', holdRight > 0); // Snapshot each ball's track state before the step: a ball riding a ramp legitimately passes // over other playfield elements (that's the point of being elevated), so it's exempt below. const before = game.balls.map((b) => ({ ball: b, hadTrack: Boolean(b.track) })); game.step(); for (const { ball: b, hadTrack } of before) { if (hadTrack || b.track) continue; for (const w of game.table.walls) { if (!w.enabled || !segmentsCross(b.prevX, b.prevY, b.x, b.y, w.ax, w.ay, w.bx, w.by)) continue; const fromBelowGate = w.tag === 'gate' && (b.prevX - w.ax) * w.nx + (b.prevY - w.ay) * w.ny < 0; if (!fromBelowGate) tunnels++; } } game.takeEvents(); } assert.equal(tunnels, 0); assert.equal(game.stats.rescues, 0); assert.ok(game.stats.drains > 0); // Multiball itself is covered deterministically above; a random bot's luck at reaching it within any // one seeded run is not something this stress test should assert on. });