// Headless verification for Slot Machines. // node server/scripts/verifySlots.js [--machine=] [--spins=N] // Exits non-zero on any failure. // // 1. Fixture tests: hand-built stops through gridFromStops / evaluateLines / // feature entry points, asserting exact payouts and state transitions. // 2. Monte-Carlo per machine (default 300k spins, use --spins=1000000 for the // full run): RTP must land in the 88–97% band, every configured feature // must fire, and grid/termination invariants hold on every spin. import { MACHINES, MACHINE_BY_ID } from '../../public/src/games/slots/machines.js'; import { createSession, spin, spinIsFree, gridFromStops, evaluateLines, evaluateScatterPays, setHold, applyNudge, peekNudge, holdSpinRespin, createPickBonus, pick, randInt, } from '../../public/src/games/slots/SlotsLogic.js'; let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); return; } failures++; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // rng that plays back queued values, then falls through to a seeded stream. function queueRng(values, seed = 1) { const tail = mulberry32(seed); let i = 0; return () => (i < values.length ? values[i++] : tail()); } // rng value that makes randInt(rng, len) yield exactly `idx`. const stopVal = (idx, len) => (idx + 0.5) / len; // Find a stop for the reel whose visible window avoids all of `avoid` symbols. function findCleanStop(machine, reel, avoid) { const strip = machine.strips[reel]; const { rows } = machine.layout; for (let s = 0; s < strip.length; s++) { let ok = true; for (let r = 0; r < rows; r++) { if (avoid.includes(strip[(s + r) % strip.length])) { ok = false; break; } } if (ok) return s; } throw new Error(`no clean stop on reel ${reel}`); } const args = Object.fromEntries(process.argv.slice(2) .filter((a) => a.startsWith('--')) .map((a) => { const [k, v] = a.slice(2).split('='); return [k, v ?? true]; })); const SPINS = Number(args.spins ?? 300000); const ONLY = args.machine ?? null; // ════════════════════════════════════════════════════════════════════════════ console.log('Fixtures: Liberty Belle line pays'); { const m = MACHINE_BY_ID['liberty-belle']; const bellStops = m.strips.map((s) => s.indexOf('bell')); const ev = evaluateLines(m, gridFromStops(m, bellStops), 10); check('3 bells pay 150×bet', ev.total === 1500, `got ${ev.total}`); const pairStops = [m.strips[0].indexOf('bell'), m.strips[1].indexOf('bell'), m.strips[2].indexOf('spade')]; const ev2 = evaluateLines(m, gridFromStops(m, pairStops), 10); check('leftmost bell pair pays 12×bet', ev2.total === 120, `got ${ev2.total}`); const mixStops = [m.strips[0].indexOf('spade'), m.strips[1].indexOf('bell'), m.strips[2].indexOf('bell')]; const ev3 = evaluateLines(m, gridFromStops(m, mixStops), 10); check('non-leftmost pair pays 0 (spade pair impossible here)', ev3.wins.every((w) => w.symbol !== 'bell'), JSON.stringify(ev3.wins)); } console.log('Fixtures: Lucky Sevens wild multipliers'); { const m = MACHINE_BY_ID['lucky-sevens']; const w = (r) => m.strips[r].indexOf('wild'); const red = (r) => m.strips[r].indexOf('seven-red'); const bet = 10; const one = evaluateLines(m, gridFromStops(m, [w(0), red(1), red(2)]), bet); check('1 wild doubles red sevens (100→200)', one.total === 2000, `got ${one.total}`); check('win reports wildMult 2', one.wins[0]?.wildMult === 2); const two = evaluateLines(m, gridFromStops(m, [w(0), w(1), red(2)]), bet); check('2 wilds quadruple red sevens (100→400)', two.total === 4000, `got ${two.total}`); const three = evaluateLines(m, gridFromStops(m, [w(0), w(1), w(2)]), bet); check('3 wilds pay their own 300 line', three.total === 3000, `got ${three.total}`); const mixed = evaluateLines(m, gridFromStops(m, [red(0), m.strips[1].indexOf('seven-blue'), m.strips[2].indexOf('seven-white')]), bet); const anySevenPay = m.paytable['any-seven'][3] * bet; check('mixed sevens pay any-seven', mixed.total === anySevenPay, `got ${mixed.total}, want ${anySevenPay}`); } console.log('Fixtures: Fruit Frenzy hold & nudge'); { const m = MACHINE_BY_ID['fruit-frenzy']; const bet = 5; const state = createSession(m, mulberry32(42)); state.stops = [0, 0, 0]; state.nudgesLeft = 2; const res = applyNudge(state, 1, bet); check('nudge advances reel 1 by one step', state.stops.join(',') === '0,1,0'); check('nudge decrements remaining', res.nudgesLeft === 1 && state.nudgesLeft === 1); check('nudge grid matches gridFromStops', JSON.stringify(res.grid) === JSON.stringify(gridFromStops(m, [0, 1, 0]))); const peek = peekNudge(state, 0, bet); check('peekNudge does not move the reel', state.stops.join(',') === '0,1,0' && typeof peek.total === 'number'); // Holds: held reel keeps its stop and consumes no rng. const s2 = createSession(m, queueRng([stopVal(7, 24), stopVal(11, 24)], 9)); s2.stops = [3, 4, 5]; s2.holds.offered = true; setHold(s2, 0, true); const r2 = spin(s2, bet); check('held reel 0 kept stop 3', r2.stops[0] === 3, `got ${r2.stops[0]}`); check('unheld reels respun to queued stops', r2.stops[1] === 7 && r2.stops[2] === 11); check('holds are not offered right after a held spin', r2.holdsOffered === false); } console.log("Fixtures: Pharaoh's Fortune free spins + expanding wild"); { const m = MACHINE_BY_ID['pharaohs-fortune']; const bet = 20; const pyr = (r) => m.strips[r].indexOf('pyramid'); const clean3 = findCleanStop(m, 3, ['pyramid']); const clean4 = findCleanStop(m, 4, ['pyramid']); const state = createSession(m, queueRng([ stopVal(pyr(0), 40), stopVal(pyr(1), 40), stopVal(pyr(2), 40), stopVal(clean3, 40), stopVal(clean4, 40), ], 5)); const res = spin(state, bet); check('3 scatters award 8 free spins', res.freeSpinsAwarded === 8, `got ${res.freeSpinsAwarded}`); check('mode switches to freespins', state.mode === 'freespins'); const award = res.steps.find((s) => s.type === 'scatterAward'); check('scatter pays 2×bet', award?.credits === 40, `got ${award?.credits}`); check('free spin costs nothing flag', spinIsFree(state) === true); // Expanding wild on reel 2 during free spins. const ph2 = m.strips[2].indexOf('pharaoh'); const cleanStops = [0, 1, 3, 4].map((r) => findCleanStop(m, r, ['pyramid'])); const s2 = createSession(m, queueRng([ stopVal(cleanStops[0], 40), stopVal(cleanStops[1], 40), stopVal(ph2, 40), stopVal(cleanStops[2], 40), stopVal(cleanStops[3], 40), ], 6)); s2.mode = 'freespins'; s2.freeSpins = { remaining: 3, total: 8 }; const r2 = spin(s2, bet); check('free spin decrements remaining', r2.freeSpinsRemaining === 2); check('wild on reel 3 expands', r2.steps.some((s) => s.type === 'expandWild' && s.col === 2)); check('reel 3 is all wild after expansion', r2.grid[2].every((sym) => sym === 'pharaoh')); // No expansion in base mode. const s3 = createSession(s2.machine, queueRng([ stopVal(cleanStops[0], 40), stopVal(cleanStops[1], 40), stopVal(ph2, 40), stopVal(cleanStops[2], 40), stopVal(cleanStops[3], 40), ], 6)); const r3 = spin(s3, bet); check('no expansion outside free spins', !r3.steps.some((s) => s.type === 'expandWild')); } console.log('Fixtures: Abyssal Treasures cascade determinism'); { const m = MACHINE_BY_ID['abyssal-treasures']; const run = () => { const st = createSession(m, mulberry32(777)); const results = []; for (let i = 0; i < 200; i++) results.push(spin(st, 20)); return results; }; const a = run(); const b = run(); check('seeded runs replay identically', JSON.stringify(a.map((r) => r.steps)) === JSON.stringify(b.map((r) => r.steps))); const cascaded = a.find((r) => r.steps.filter((s) => s.type === 'tumble').length >= 2); check('multi-cascade occurred in 200 spins', !!cascaded); if (cascaded) { const winSteps = cascaded.steps.filter((s) => s.type === 'lineWins'); check('second cascade uses 2× multiplier', winSteps[1]?.multiplier === 2, `got ${winSteps[1]?.multiplier}`); } } console.log("Fixtures: Dragon's Hoard hold-and-spin"); { const m = MACHINE_BY_ID['dragons-hoard']; const bet = 10; const mkLocked = () => [0, 1, 2, 3, 4, 5].map((i) => ({ c: i % 5, r: Math.floor(i / 5), value: 1 })); // A new coin resets respins to 3 (first empty cell hits, the rest miss). const s1 = createSession(m, queueRng([0.0, 0.0, ...Array(20).fill(0.99)])); s1.mode = 'holdspin'; s1.holdSpin = { locked: mkLocked(), respinsLeft: 1 }; const r1 = holdSpinRespin(s1, bet); check('new coin resets respins to 3', !r1.finished && r1.respinsLeft === 3, `got ${r1.respinsLeft}`); check('new coin is locked', s1.holdSpin.locked.length === 7, `got ${s1.holdSpin.locked.length}`); // Burning out three respins finishes and pays the locked coins. const s2 = createSession(m, () => 0.99); // never hits s2.mode = 'holdspin'; s2.holdSpin = { locked: mkLocked(), respinsLeft: 3 }; let last = null; for (let i = 0; i < 10 && !last?.finished; i++) last = holdSpinRespin(s2, bet); check('respins terminate', last?.finished === true); check('award sums coin values ×bet', last?.award?.credits === 60, `got ${last?.award?.credits}`); check('mode returns to base', s2.mode === 'base'); // Filling the whole board hits the Grand. const s3 = createSession(m, () => 0.0); // every cell hits s3.mode = 'holdspin'; s3.holdSpin = { locked: mkLocked(), respinsLeft: 3 }; const r3 = holdSpinRespin(s3, bet); check('full board finishes immediately', r3.finished === true); check('full board flags the Grand', r3.award?.grandHit === true); } console.log('Fixtures: Gold Rush sticky wilds + pick bonus'); { const m = MACHINE_BY_ID['gold-rush']; const bet = 10; const banditIdx = m.strips[1].indexOf('bandit'); const len = m.strips.map((s) => s.length); const state = createSession(m, queueRng([ stopVal(0, len[0]), stopVal(banditIdx, len[1]), stopVal(2, len[2]), stopVal(4, len[3]), stopVal(6, len[4]), ], 11)); spin(state, bet); const sticky = state.stickyWilds.find((s) => s.c === 1 && s.r === 0); check('landed bandit becomes sticky for 3 spins', sticky?.spinsLeft === 3); const r2 = spin(state, bet); check('sticky wild overrides the next spin', r2.grid[1][0] === 'bandit'); check('sticky ages after the spin', state.stickyWilds.find((s) => s.c === 1 && s.r === 0)?.spinsLeft <= 3); const rng = mulberry32(99); const bonus = createPickBonus(m, bet, rng); check('bonus deals 9 carts', bonus.prizes.length === 9 && bonus.prizes.every((p) => p >= 2 * bet)); const p1 = pick(bonus, 0); const p2 = pick(bonus, 4); const p3 = pick(bonus, 8); check('three picks then done', p1 && p2 && p3?.done === true); check('total accumulates picks', bonus.total === bonus.prizes[0] + bonus.prizes[4] + bonus.prizes[8]); check('re-picking a cart is rejected', pick(bonus, 0) === null); } console.log('Fixtures: Sugar Spin scatter pays'); { const m = MACHINE_BY_ID['sugar-spin']; const bet = 50; const others = ['chocolate', 'candy-cane', 'gummy', 'lollipop', 'jelly', 'mint', 'berry']; const build = (nSugar) => { const grid = []; let placed = 0; let o = 0; for (let c = 0; c < 6; c++) { grid[c] = []; for (let r = 0; r < 5; r++) { if (placed < nSugar) { grid[c][r] = 'sugar'; placed++; } else { grid[c][r] = others[o % others.length]; o++; } } } return grid; }; const tiers = m.paytable.sugar; const win = evaluateScatterPays(m, build(8), bet); const want8 = Math.round(tiers.find((t) => 8 >= t.min).pay * bet); check('8 anywhere pays the base tier', win.total === want8, `got ${win.total}, want ${want8}`); check('winning cells are every copy', win.wins[0]?.cells.length === 8); const lose = evaluateScatterPays(m, build(7), bet); check('7 anywhere pays nothing', lose.total === 0, `got ${lose.total}`); const big = evaluateScatterPays(m, build(12), bet); const want12 = Math.round(tiers.find((t) => 12 >= t.min).pay * bet); check('12 anywhere pays the top tier', big.total === want12, `got ${big.total}, want ${want12}`); } // ════════════════════════════════════════════════════════════════════════════ // Monte-Carlo simulation with naive-but-reasonable feature auto-play. function validSymbols(machine) { const set = new Set(); for (const strip of machine.strips) for (const s of strip) set.add(s); if (machine.wild) set.add(machine.wild.symbol); if (machine.features.rainbow) set.add(machine.features.rainbow.symbol); return set; } function autoNudge(state, bet) { // Greedy: take the first 1-step nudge that wins; otherwise nudge reel 0. let settled = 0; while (state.nudgesLeft > 0) { let bestReel = 0; let bestTotal = -1; for (let i = 0; i < state.machine.strips.length; i++) { const peek = peekNudge(state, i, bet); if (peek.total > bestTotal) { bestTotal = peek.total; bestReel = i; } } const res = applyNudge(state, bestReel, bet); settled = res.totalWin; if (settled > 0) break; } return settled; } function autoHold(state) { // Hold reels whose centre row shares a symbol with another reel's centre row. const center = Math.floor(state.machine.layout.rows / 2); const syms = state.grid.map((col) => col[center]); for (let i = 0; i < syms.length; i++) { const shared = syms.some((s, j) => j !== i && s === syms[i]); if (shared && state.machine.paytable[syms[i]]) setHold(state, i, true); } } function simulate(machine, spins, seed) { const rng = mulberry32(seed); const state = createSession(machine, rng); const bet = machine.betLevels[1]; // a mid-low bet exercises rounding const grand = machine.features.holdSpin?.grand ?? null; let jackpot = grand ? grand.seed : 0; const stats = { bet, totalBet: 0, totalReturn: 0, hits: 0, maxWin: 0, freeSpinTriggers: 0, freeSpinsPlayed: 0, cascades2: 0, holdSpins: 0, grands: 0, jackpotTiers: { mini: 0, minor: 0, major: 0 }, holdsOffered: 0, nudgeRounds: 0, pickBonuses: 0, stickyPlacements: 0, rainbowBoosts: 0, invariantFailures: 0, }; const okSymbols = validSymbols(machine); const checkGrid = (grid) => { for (const col of grid) { if (col.length !== machine.layout.rows) return false; for (const s of col) if (!okSymbols.has(s)) return false; } return grid.length === machine.strips.length; }; for (let i = 0; i < spins; i++) { stats.totalBet += bet; let res = spin(state, bet); if (!checkGrid(res.grid) || res.totalWin < 0) stats.invariantFailures++; let ret = res.totalWin; jackpot += res.progressiveContribution; if (res.nudgesAwarded > 0) { stats.nudgeRounds++; ret += autoNudge(state, bet); } if (res.holdsOffered) { stats.holdsOffered++; autoHold(state); } if (res.steps.some((s) => s.type === 'tumble' && s.cascadeIndex >= 1)) stats.cascades2++; if (res.steps.some((s) => s.type === 'rainbow')) stats.rainbowBoosts++; if (res.steps.some((s) => s.type === 'stickyWilds' && s.placed.length > 0)) stats.stickyPlacements++; if (res.holdSpinTriggered) { stats.holdSpins++; let guard = 0; let out = null; while (!(out = holdSpinRespin(state, bet)).finished && ++guard < 1000) { /* respin */ } if (guard >= 1000) stats.invariantFailures++; ret += out.award.credits; for (const j of out.award.jackpots) stats.jackpotTiers[j.tier]++; if (out.award.grandHit) { stats.grands++; ret += Math.floor(jackpot); jackpot = grand.seed; } } if (res.pickBonusTriggered) { stats.pickBonuses++; const bonus = createPickBonus(machine, bet, rng); while (bonus.picksLeft > 0) { let idx = randInt(rng, bonus.prizes.length); while (bonus.picked.includes(idx)) idx = (idx + 1) % bonus.prizes.length; pick(bonus, idx); } ret += bonus.total; } if (res.freeSpinsAwarded > 0) stats.freeSpinTriggers++; // Play out free spins (cost 0) inside the same purchased spin. let fsGuard = 0; while (spinIsFree(state) && ++fsGuard < 10000) { const fr = spin(state, bet); stats.freeSpinsPlayed++; if (!checkGrid(fr.grid) || fr.totalWin < 0) stats.invariantFailures++; ret += fr.totalWin; } if (fsGuard >= 10000) stats.invariantFailures++; stats.totalReturn += ret; if (ret > 0) stats.hits++; if (ret > stats.maxWin) stats.maxWin = ret; } stats.rtp = stats.totalReturn / stats.totalBet; stats.hitRate = stats.hits / spins; return stats; } const toRun = MACHINES.filter((m) => !ONLY || m.id === ONLY); if (ONLY && toRun.length === 0) { console.error(`unknown machine id '${ONLY}'`); process.exit(1); } for (const machine of toRun) { console.log(`\nMonte-Carlo: ${machine.name} (${SPINS.toLocaleString()} spins @ bet ${machine.betLevels[1]})`); const s = simulate(machine, SPINS, 0xC0FFEE ^ machine.id.length); console.log(` RTP ${(s.rtp * 100).toFixed(2)}% hit-rate ${(s.hitRate * 100).toFixed(1)}% max win ${s.maxWin.toLocaleString()}`); const feats = []; if (s.freeSpinTriggers) feats.push(`free-spin triggers ${s.freeSpinTriggers} (${s.freeSpinsPlayed} spins)`); if (s.cascades2) feats.push(`2+ cascades ${s.cascades2}`); if (s.holdSpins) feats.push(`hold&spins ${s.holdSpins} (grand ${s.grands}, mini/minor/major ${s.jackpotTiers.mini}/${s.jackpotTiers.minor}/${s.jackpotTiers.major})`); if (s.holdsOffered || s.nudgeRounds) feats.push(`holds ${s.holdsOffered}, nudge rounds ${s.nudgeRounds}`); if (s.pickBonuses) feats.push(`pick bonuses ${s.pickBonuses}`); if (s.stickyPlacements) feats.push(`sticky wilds ${s.stickyPlacements}`); if (s.rainbowBoosts) feats.push(`rainbow boosts ${s.rainbowBoosts}`); if (feats.length) console.log(` features: ${feats.join(' · ')}`); check(`${machine.id}: RTP in 88–97% band`, s.rtp >= 0.88 && s.rtp <= 0.97, `${(s.rtp * 100).toFixed(2)}%`); check(`${machine.id}: no invariant failures`, s.invariantFailures === 0, String(s.invariantFailures)); if (machine.scatter?.freeSpins) { check(`${machine.id}: free spins trigger`, s.freeSpinTriggers > 0); } if (machine.features.cascade || machine.features.tumble) { check(`${machine.id}: multi-cascades occur`, s.cascades2 > 0); } if (machine.features.holdSpin) { check(`${machine.id}: hold-and-spin triggers`, s.holdSpins > 0); check(`${machine.id}: each jackpot tier hits`, s.jackpotTiers.mini > 0 && s.jackpotTiers.minor > 0 && s.jackpotTiers.major > 0, JSON.stringify(s.jackpotTiers)); } if (machine.features.holdNudge) { check(`${machine.id}: holds offered`, s.holdsOffered > 0); check(`${machine.id}: nudges awarded`, s.nudgeRounds > 0); } if (machine.features.pickBonus) { check(`${machine.id}: pick bonus triggers`, s.pickBonuses > 0); } if (machine.features.stickyWilds) { check(`${machine.id}: sticky wilds placed`, s.stickyPlacements > 0); } if (machine.features.rainbow) { check(`${machine.id}: rainbow boosts occur`, s.rainbowBoosts > 0); } } console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL CHECKS PASSED'); process.exit(failures ? 1 : 0);