449 lines
19 KiB
JavaScript
449 lines
19 KiB
JavaScript
// 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 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);
|