import type { GameDefinition, Grid, PlayerGameState, SpinOutcome, SpinStep, WinLine } from "./types"; import { emptyPlayerState } from "./types"; import type { Rng } from "./rng"; import { CryptoRng } from "./rng"; import { applyStackedWilds, cloneGrid, countSymbol, drawGrid, reelTables } from "./reels"; import { evaluate, scatterWin, sumWins } from "./evaluate"; import { applyExpandingWilds, applyFloatingWilds, assignWildMultipliers, explodeWilds, refillGrid, shiftMovingWilds, stampWilds, upgradeLowSymbols, wildPositions, } from "./features/wilds"; import { boostMystery, quantumSplit, resolveHoldRespin, resolvePickBonus, revealMystery } from "./features/special"; const key = (r: number, y: number) => `${r}:${y}`; const MAX_FREE_SPINS_PER_ROUND = 60; interface SpinMode { free: boolean; label?: string; fsMultiplier: number; gridSize: { reels: number; rows: number }; sticky?: Set; moving?: [number, number][]; /** Shared ladder index for persistent multipliers (cascade / collect) across free spins. */ ladder: { index: number }; /** Number of collect symbols accumulated in the feature so far. */ collectCount: { n: number }; freeSpinsLeft?: number; freeSpinsTotal?: number; extraWildChance?: number; forcedSpinMultiplier?: number; } interface SingleSpinResult { steps: SpinStep[]; win: number; scatterCount: number; scatterPositions: [number, number][]; chargeCount: number; initialGrid: Grid; newWilds: [number, number][]; } /** Run one "spin sequence": draw + features + cascades. Does not handle free-spin triggering. */ function playSpin(def: GameDefinition, bet: number, rng: Rng, mode: SpinMode, stepIndexRef: { i: number }, runningRef: { total: number }, features: string[]): SingleSpinResult { const steps: SpinStep[] = []; const { reels, rows } = mode.gridSize; const tables = reelTables(def); const grid = drawGrid(def, rng, mode.free, reels, rows); const meta: SpinStep["meta"] = {}; if (def.dynamicGrid && mode.free) meta.gridSize = { reels, rows }; // Sticky / moving wilds are stamped before anything else. if (mode.sticky && mode.sticky.size) { const pos = [...mode.sticky].map((k) => k.split(":").map(Number) as [number, number]); stampWilds(def, grid, pos); meta.stickyWilds = pos; } if (mode.moving && mode.moving.length) { stampWilds(def, grid, mode.moving); meta.movingWilds = mode.moving.slice(); } // Random feature drop. let spinMultiplier = mode.forcedSpinMultiplier ?? 1; const rf = def.randomFeatures; if (rf) { const chance = mode.free ? (rf.freeSpinsChance ?? rf.chance) : rf.chance; if (rng.chance(chance) && rf.features.length) { const f = rf.features[rng.weighted(rf.features.map((x) => x.weight))]; meta.randomFeature = f.label; features.push(f.label); if (f.type === "addWilds") meta.addedWilds = applyFloatingWilds(def, grid, rng, mode.free, { min: f.min, max: f.max }); else if (f.type === "spinMultiplier") spinMultiplier *= f.values[rng.weighted(f.weights)]; else if (f.type === "upgradeLow") upgradeLowSymbols(def, grid, rng); else if (f.type === "mysteryBoost") boostMystery(def, grid, rng, f.count); } } if (spinMultiplier > 1) meta.spinMultiplier = spinMultiplier; // Stacked wilds. const stacked = applyStackedWilds(def, grid, rng); if (stacked.length) meta.expandedReels = stacked; // Extra wild chance from meter bonus modes. if (mode.extraWildChance && rng.chance(mode.extraWildChance)) { const added = applyFloatingWilds(def, grid, rng, true, { min: 1, max: 2 }); meta.addedWilds = [...(meta.addedWilds ?? []), ...added]; } // Floating wilds. const floating = applyFloatingWilds(def, grid, rng, mode.free); if (floating.length) meta.addedWilds = [...(meta.addedWilds ?? []), ...floating]; // Mystery reveal. const reveal = revealMystery(def, grid, rng); if (reveal) meta.mysteryReveal = reveal; // Expanding wilds. const expanded = applyExpandingWilds(def, grid, mode.free); if (expanded.length) meta.expandedReels = [...new Set([...(meta.expandedReels ?? []), ...expanded])]; // Quantum split. const q = quantumSplit(def, grid, rng); if (q) { meta.quantumSplits = q.splits; features.push("Quantum Split"); } // Wild multipliers. const wildMults = new Map(); const assigned = assignWildMultipliers(def, grid, rng, mode.free, wildMults); if (assigned.length) meta.wildMultipliers = assigned; // Scatters (initial grid only). const sc = scatterWin(def, grid, bet); if (sc.count) meta.scatterPositions = sc.positions; // Charge symbols for meters (initial grid only). const chargeCount = def.meter ? countSymbol(grid, def.meter.symbolId).length : 0; // Spin-collect ladder (Neon Vault style). let collectMultiplier = 1; if (def.spinCollect && (!def.spinCollect.freeSpinsOnly || mode.free)) { const persist = mode.free && !!def.freeSpinsPersistentMultiplier; if (!persist) mode.collectCount.n = 0; const n = countSymbol(grid, def.spinCollect.symbolId).length; mode.collectCount.n += n; if (mode.collectCount.n > 0) { const idx = Math.min(mode.collectCount.n, def.spinCollect.ladder.length) - 1; collectMultiplier = def.spinCollect.ladder[idx]; if (n > 0) { meta.collected = { symbol: def.spinCollect.symbolId, count: mode.collectCount.n, ladderIndex: idx }; features.push("Collect"); } } } const initialGrid = cloneGrid(grid); const newWildsAll: [number, number][] = wildPositions(def, grid).filter(([r, y]) => !mode.sticky?.has(key(r, y))); // Cascade loop. let cascadeIndex = 0; let newWilds = newWildsAll.slice(); let spinWin = 0; const cascadeCfg = def.cascades; const ladder = cascadeCfg ? (mode.free && cascadeCfg.freeSpinMultipliers ? cascadeCfg.freeSpinMultipliers : cascadeCfg.multipliers) : null; const persistent = !!cascadeCfg?.persistInFreeSpins && mode.free; if (!persistent) mode.ladder.index = 0; const maxCascades = cascadeCfg?.maxCascades ?? 30; const protectedCells = mode.sticky ? new Set(mode.sticky) : undefined; for (;;) { const ctx = { bet, multiplicity: cascadeIndex === 0 ? q?.multiplicity : undefined, wildMultipliers: wildMults }; const wins: WinLine[] = evaluate(def, grid, ctx); const ladderMult = ladder ? ladder[Math.min(mode.ladder.index, ladder.length - 1)] : 1; const stepMultiplier = ladderMult * mode.fsMultiplier * collectMultiplier * spinMultiplier; const baseWin = sumWins(wins) + (cascadeIndex === 0 ? sc.win : 0); const stepWin = Math.round(baseWin * stepMultiplier); spinWin += stepWin; runningRef.total += stepWin; const exploded = explodeWilds(def, grid, newWilds); const step: SpinStep = { type: cascadeIndex === 0 ? (mode.free ? "freespin" : "base") : "cascade", index: stepIndexRef.i++, grid: cloneGrid(grid), multiplicity: cascadeIndex === 0 ? q?.multiplicity : undefined, wins, multiplier: stepMultiplier, win: stepWin, runningTotal: runningRef.total, meta: cascadeIndex === 0 ? { ...meta, label: mode.label, scatterWin: sc.win ? Math.round(sc.win * stepMultiplier) : undefined } : { label: "Cascade" }, }; if (mode.free) { step.meta.freeSpinsLeft = mode.freeSpinsLeft; step.meta.freeSpinsTotal = mode.freeSpinsTotal; } if (exploded.length) { step.meta.exploded = exploded; if (cascadeIndex === 0) features.push("Exploding Wild"); } const removable = new Set(); if (cascadeCfg || exploded.length) { for (const w of wins) for (const [r, y] of w.positions) removable.add(key(r, y)); for (const [r, y] of exploded) removable.add(key(r, y)); } steps.push(step); if (!cascadeCfg && exploded.length === 0) break; if (removable.size === 0 || cascadeIndex >= maxCascades) break; const removed = [...removable].map((k) => k.split(":").map(Number) as [number, number]); step.meta.removed = removed; // Wild multipliers of removed wilds are cleared. for (const k of removable) wildMults.delete(k); refillGrid(def, grid, removed, rng, mode.free, protectedCells); if (cascadeCfg && wins.length) mode.ladder.index++; cascadeIndex++; // Newly landed wilds after refill. const before = new Set(step.grid.flatMap((c, r) => c.map((s, y) => (s === def.wild?.id ? key(r, y) : ""))).filter(Boolean)); newWilds = wildPositions(def, grid).filter(([r, y]) => !before.has(key(r, y)) || removable.has(key(r, y))); assignWildMultipliers(def, grid, rng, mode.free, wildMults); if (cascadeIndex > 0) features.push("Cascade"); } return { steps, win: spinWin, scatterCount: sc.count, scatterPositions: sc.positions, chargeCount, initialGrid, newWilds: newWildsAll }; } export interface RunSpinOptions { rng?: Rng; state?: PlayerGameState; } /** Resolve a full round for `bet` credits. Pure: no I/O. */ export function runSpin(def: GameDefinition, bet: number, opts: RunSpinOptions = {}): SpinOutcome { const rng = opts.rng ?? new CryptoRng(); const stateBefore: PlayerGameState = opts.state ? structuredClone(opts.state) : emptyPlayerState(); const state: PlayerGameState = structuredClone(stateBefore); const features: string[] = []; const stepIndex = { i: 0 }; const running = { total: 0 }; const steps: SpinStep[] = []; let freeSpinsTriggered = false; let bonusTriggered = false; let jackpot: { tier: string; amount: number } | null = null; // Meter multiplier level applied to all wins this round. let levelMultiplier = 1; if (def.meter?.onFull.type === "multiplierLevel") { const lvl = state.meterLevels[def.meter.id] ?? 0; levelMultiplier = def.meter.onFull.ladder[Math.min(lvl, def.meter.onFull.ladder.length - 1)]; } // Heat multiplier. let heatMultiplier = 1; if (def.heat) { const band = Math.min(Math.floor(state.heat / def.heat.bandSize), def.heat.ladder.length - 1); heatMultiplier = def.heat.ladder[band]; } const baseMode: SpinMode = { free: false, fsMultiplier: levelMultiplier * heatMultiplier, gridSize: def.grid, ladder: { index: 0 }, collectCount: { n: 0 }, }; const base = playSpin(def, bet, rng, baseMode, stepIndex, running, features); if (def.heat) { base.steps[0].meta.heat = state.heat; base.steps[0].meta.heatMax = def.heat.max; } steps.push(...base.steps); // Hold & respin. if (def.holdRespin) { const hr = resolveHoldRespin(def, base.initialGrid, bet, rng); if (hr) { bonusTriggered = true; features.push("Hold & Respin"); hr.steps.forEach((s, i) => { const isLast = i === hr.steps.length - 1; const win = isLast ? hr.total : 0; running.total += win; steps.push({ type: "respin", index: stepIndex.i++, grid: s.grid, wins: [], multiplier: 1, win, runningTotal: running.total, meta: { label: i === 0 ? "Lock & Respin" : "Respin", coins: s.coins, respinsLeft: s.respinsLeft, jackpot: isLast && hr.jackpot ? hr.jackpot : undefined }, }); }); if (hr.jackpot) { jackpot = hr.jackpot; features.push(`Jackpot ${hr.jackpot.tier}`); } } } // Free spins from scatters. const runFreeSpins = (count: number, label: string, fsMultiplier: number, extraWildChance?: number, entryBonusId?: string) => { freeSpinsTriggered = true; features.push(label); if (entryBonusId) { const cfg = def.pickBonuses?.find((b) => b.id === entryBonusId); if (cfg) { const res = resolvePickBonus(def, cfg, bet, rng); running.total += res.total; bonusTriggered = true; features.push(cfg.name); steps.push({ type: "bonus", index: stepIndex.i++, grid: base.initialGrid, wins: [], multiplier: 1, win: res.total, runningTotal: running.total, meta: { label: cfg.name, bonusName: cfg.name, picks: res.picks }, }); } } let left = count; let total = count; let played = 0; const sticky = def.wild?.sticky ? new Set() : undefined; let moving: [number, number][] | undefined = def.wild?.moving ? [] : undefined; const ladder = { index: 0 }; const collectCount = { n: baseMode.collectCount.n }; const fsMult = fsMultiplier * levelMultiplier; while (left > 0 && played < MAX_FREE_SPINS_PER_ROUND) { const gridSize = def.dynamicGrid ? { reels: def.dynamicGrid.sequence[Math.min(played, def.dynamicGrid.sequence.length - 1)][0], rows: def.dynamicGrid.sequence[Math.min(played, def.dynamicGrid.sequence.length - 1)][1] } : def.grid; const mode: SpinMode = { free: true, label: played === 0 ? label : undefined, fsMultiplier: fsMult, gridSize, sticky, moving, ladder, collectCount, freeSpinsLeft: left - 1, freeSpinsTotal: total, extraWildChance, }; const res = playSpin(def, bet, rng, mode, stepIndex, running, features); steps.push(...res.steps); left--; played++; // Sticky: remember newly landed wilds. if (sticky) for (const [r, y] of res.newWilds) sticky.add(key(r, y)); // Moving: track wilds and shift them left for the next spin. if (moving !== undefined) { const current = wildPositions(def, res.initialGrid); moving = shiftMovingWilds(current); } // Retrigger. if (def.scatter?.retrigger && res.scatterCount > 0) { const extra = def.scatter.triggers[res.scatterCount] ?? 0; if (extra > 0) { left += extra; total += extra; res.steps[0].meta.freeSpinsAwarded = extra; res.steps[0].meta.freeSpinsLeft = left; res.steps[0].meta.freeSpinsTotal = total; features.push("Retrigger"); } } // Meters keep charging in free spins. if (def.meter && res.chargeCount) state.meters[def.meter.id] = (state.meters[def.meter.id] ?? 0) + res.chargeCount; } }; if (def.scatter && base.scatterCount > 0) { const awarded = def.scatter.triggers[base.scatterCount] ?? (base.scatterCount > Math.max(...Object.keys(def.scatter.triggers).map(Number)) ? Math.max(...Object.values(def.scatter.triggers)) : 0); if (awarded > 0) { base.steps[0].meta.freeSpinsAwarded = awarded; runFreeSpins(awarded, "Free Spins", def.scatter.freeSpinMultiplier ?? 1, undefined, def.scatter.entryBonus); if (def.meter?.resetOnBonus) state.meterLevels[def.meter.id] = 0; } } // Persistent meter. if (def.meter) { const m = def.meter; const before = state.meters[m.id] ?? 0; let value = before + base.chargeCount; if (base.chargeCount > 0) base.steps[0].meta.meter = { id: m.id, value: Math.min(value, m.max), max: m.max, gained: base.chargeCount }; if (value >= m.max) { value -= m.max; features.push(m.onFull.label); if (m.onFull.type === "freeSpins") { runFreeSpins(m.onFull.spins, m.onFull.label, m.onFull.multiplier ?? 1, m.onFull.extraWildChance); } else if (m.onFull.type === "pickBonus") { const cfg = def.pickBonuses?.find((b) => b.id === (m.onFull as { bonusId: string }).bonusId); if (cfg) { const res = resolvePickBonus(def, cfg, bet, rng); running.total += res.total; bonusTriggered = true; steps.push({ type: "bonus", index: stepIndex.i++, grid: base.initialGrid, wins: [], multiplier: 1, win: res.total, runningTotal: running.total, meta: { label: cfg.name, bonusName: cfg.name, picks: res.picks }, }); } } else if (m.onFull.type === "multiplierLevel") { const lvl = (state.meterLevels[m.id] ?? 0) + 1; state.meterLevels[m.id] = Math.min(lvl, m.onFull.ladder.length - 1); steps.push({ type: "feature", index: stepIndex.i++, grid: base.initialGrid, wins: [], multiplier: m.onFull.ladder[state.meterLevels[m.id]], win: 0, runningTotal: running.total, meta: { label: m.onFull.label, meter: { id: m.id, value, max: m.max, gained: 0 } }, }); } } state.meters[m.id] = value; } // Heat. if (def.heat) { const h = def.heat; const won = base.win > 0; state.heat = Math.max(0, state.heat + (won ? h.gainOnWin : h.gainOnLoss)); if (state.heat >= h.max) { state.heat = 0; runFreeSpins(h.meltdown.spins, h.meltdown.label, h.meltdown.multiplier); } } // Random jackpot. if (def.jackpot && !jackpot) { const p = def.jackpot.chancePerSpin * (bet / def.maxBet); if (rng.chance(p)) { const tier = def.jackpot.tiers[rng.weighted(def.jackpot.tiers.map((t) => t.weight))]; const amount = Math.round(tier.multiplier * bet); jackpot = { tier: tier.id, amount }; running.total += amount; features.push(`Jackpot ${tier.id}`); steps.push({ type: "jackpot", index: stepIndex.i++, grid: base.initialGrid, wins: [], multiplier: 1, win: amount, runningTotal: running.total, meta: { label: `${tier.id.toUpperCase()} JACKPOT`, jackpot }, }); } } // Cap. let totalWin = running.total; let capped = false; const cap = Math.round(def.maxMultiplier * bet); if (totalWin > cap) { totalWin = cap; capped = true; const last = steps[steps.length - 1]; if (last) last.meta.capped = true; } state.rounds += 1; return { game: def.slug, version: def.version, bet, totalWin, multiplier: bet > 0 ? totalWin / bet : 0, capped, steps, features: [...new Set(features)], freeSpinsTriggered, bonusTriggered, jackpot, stateBefore, stateAfter: state, rngReference: rng.reference(), }; }