fertig-classic-games/server/scripts/verifySlots.js

449 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Headless verification for Slot Machines.
// node server/scripts/verifySlots.js [--machine=<id>] [--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 8897% 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 8897% 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);