TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { GameDefinition, Grid, PlayerGameState, SpinOutcome, SpinStep, WinLine } from "./types";2import { emptyPlayerState } from "./types";3import type { Rng } from "./rng";4import { CryptoRng } from "./rng";5import { applyStackedWilds, cloneGrid, countSymbol, drawGrid, reelTables } from "./reels";6import { evaluate, scatterWin, sumWins } from "./evaluate";7import {8 applyExpandingWilds,9 applyFloatingWilds,10 assignWildMultipliers,11 explodeWilds,12 refillGrid,13 shiftMovingWilds,14 stampWilds,15 upgradeLowSymbols,16 wildPositions,17} from "./features/wilds";18import { boostMystery, quantumSplit, resolveHoldRespin, resolvePickBonus, revealMystery } from "./features/special";1920const key = (r: number, y: number) => `${r}:${y}`;21const MAX_FREE_SPINS_PER_ROUND = 60;2223interface SpinMode {24 free: boolean;25 label?: string;26 fsMultiplier: number;27 gridSize: { reels: number; rows: number };28 sticky?: Set<string>;29 moving?: [number, number][];30 /** Shared ladder index for persistent multipliers (cascade / collect) across free spins. */31 ladder: { index: number };32 /** Number of collect symbols accumulated in the feature so far. */33 collectCount: { n: number };34 freeSpinsLeft?: number;35 freeSpinsTotal?: number;36 extraWildChance?: number;37 forcedSpinMultiplier?: number;38}3940interface SingleSpinResult {41 steps: SpinStep[];42 win: number;43 scatterCount: number;44 scatterPositions: [number, number][];45 chargeCount: number;46 initialGrid: Grid;47 newWilds: [number, number][];48}4950/** Run one "spin sequence": draw + features + cascades. Does not handle free-spin triggering. */51function playSpin(def: GameDefinition, bet: number, rng: Rng, mode: SpinMode, stepIndexRef: { i: number }, runningRef: { total: number }, features: string[]): SingleSpinResult {52 const steps: SpinStep[] = [];53 const { reels, rows } = mode.gridSize;54 const tables = reelTables(def);55 const grid = drawGrid(def, rng, mode.free, reels, rows);56 const meta: SpinStep["meta"] = {};57 if (def.dynamicGrid && mode.free) meta.gridSize = { reels, rows };5859 // Sticky / moving wilds are stamped before anything else.60 if (mode.sticky && mode.sticky.size) {61 const pos = [...mode.sticky].map((k) => k.split(":").map(Number) as [number, number]);62 stampWilds(def, grid, pos);63 meta.stickyWilds = pos;64 }65 if (mode.moving && mode.moving.length) {66 stampWilds(def, grid, mode.moving);67 meta.movingWilds = mode.moving.slice();68 }6970 // Random feature drop.71 let spinMultiplier = mode.forcedSpinMultiplier ?? 1;72 const rf = def.randomFeatures;73 if (rf) {74 const chance = mode.free ? (rf.freeSpinsChance ?? rf.chance) : rf.chance;75 if (rng.chance(chance) && rf.features.length) {76 const f = rf.features[rng.weighted(rf.features.map((x) => x.weight))];77 meta.randomFeature = f.label;78 features.push(f.label);79 if (f.type === "addWilds") meta.addedWilds = applyFloatingWilds(def, grid, rng, mode.free, { min: f.min, max: f.max });80 else if (f.type === "spinMultiplier") spinMultiplier *= f.values[rng.weighted(f.weights)];81 else if (f.type === "upgradeLow") upgradeLowSymbols(def, grid, rng);82 else if (f.type === "mysteryBoost") boostMystery(def, grid, rng, f.count);83 }84 }85 if (spinMultiplier > 1) meta.spinMultiplier = spinMultiplier;8687 // Stacked wilds.88 const stacked = applyStackedWilds(def, grid, rng);89 if (stacked.length) meta.expandedReels = stacked;9091 // Extra wild chance from meter bonus modes.92 if (mode.extraWildChance && rng.chance(mode.extraWildChance)) {93 const added = applyFloatingWilds(def, grid, rng, true, { min: 1, max: 2 });94 meta.addedWilds = [...(meta.addedWilds ?? []), ...added];95 }9697 // Floating wilds.98 const floating = applyFloatingWilds(def, grid, rng, mode.free);99 if (floating.length) meta.addedWilds = [...(meta.addedWilds ?? []), ...floating];100101 // Mystery reveal.102 const reveal = revealMystery(def, grid, rng);103 if (reveal) meta.mysteryReveal = reveal;104105 // Expanding wilds.106 const expanded = applyExpandingWilds(def, grid, mode.free);107 if (expanded.length) meta.expandedReels = [...new Set([...(meta.expandedReels ?? []), ...expanded])];108109 // Quantum split.110 const q = quantumSplit(def, grid, rng);111 if (q) {112 meta.quantumSplits = q.splits;113 features.push("Quantum Split");114 }115116 // Wild multipliers.117 const wildMults = new Map<string, number>();118 const assigned = assignWildMultipliers(def, grid, rng, mode.free, wildMults);119 if (assigned.length) meta.wildMultipliers = assigned;120121 // Scatters (initial grid only).122 const sc = scatterWin(def, grid, bet);123 if (sc.count) meta.scatterPositions = sc.positions;124125 // Charge symbols for meters (initial grid only).126 const chargeCount = def.meter ? countSymbol(grid, def.meter.symbolId).length : 0;127128 // Spin-collect ladder (Neon Vault style).129 let collectMultiplier = 1;130 if (def.spinCollect && (!def.spinCollect.freeSpinsOnly || mode.free)) {131 const persist = mode.free && !!def.freeSpinsPersistentMultiplier;132 if (!persist) mode.collectCount.n = 0;133 const n = countSymbol(grid, def.spinCollect.symbolId).length;134 mode.collectCount.n += n;135 if (mode.collectCount.n > 0) {136 const idx = Math.min(mode.collectCount.n, def.spinCollect.ladder.length) - 1;137 collectMultiplier = def.spinCollect.ladder[idx];138 if (n > 0) {139 meta.collected = { symbol: def.spinCollect.symbolId, count: mode.collectCount.n, ladderIndex: idx };140 features.push("Collect");141 }142 }143 }144145 const initialGrid = cloneGrid(grid);146 const newWildsAll: [number, number][] = wildPositions(def, grid).filter(([r, y]) => !mode.sticky?.has(key(r, y)));147148 // Cascade loop.149 let cascadeIndex = 0;150 let newWilds = newWildsAll.slice();151 let spinWin = 0;152 const cascadeCfg = def.cascades;153 const ladder = cascadeCfg ? (mode.free && cascadeCfg.freeSpinMultipliers ? cascadeCfg.freeSpinMultipliers : cascadeCfg.multipliers) : null;154 const persistent = !!cascadeCfg?.persistInFreeSpins && mode.free;155 if (!persistent) mode.ladder.index = 0;156 const maxCascades = cascadeCfg?.maxCascades ?? 30;157 const protectedCells = mode.sticky ? new Set(mode.sticky) : undefined;158159 for (;;) {160 const ctx = { bet, multiplicity: cascadeIndex === 0 ? q?.multiplicity : undefined, wildMultipliers: wildMults };161 const wins: WinLine[] = evaluate(def, grid, ctx);162 const ladderMult = ladder ? ladder[Math.min(mode.ladder.index, ladder.length - 1)] : 1;163 const stepMultiplier = ladderMult * mode.fsMultiplier * collectMultiplier * spinMultiplier;164 const baseWin = sumWins(wins) + (cascadeIndex === 0 ? sc.win : 0);165 const stepWin = Math.round(baseWin * stepMultiplier);166 spinWin += stepWin;167 runningRef.total += stepWin;168 const exploded = explodeWilds(def, grid, newWilds);169 const step: SpinStep = {170 type: cascadeIndex === 0 ? (mode.free ? "freespin" : "base") : "cascade",171 index: stepIndexRef.i++,172 grid: cloneGrid(grid),173 multiplicity: cascadeIndex === 0 ? q?.multiplicity : undefined,174 wins,175 multiplier: stepMultiplier,176 win: stepWin,177 runningTotal: runningRef.total,178 meta: cascadeIndex === 0 ? { ...meta, label: mode.label, scatterWin: sc.win ? Math.round(sc.win * stepMultiplier) : undefined } : { label: "Cascade" },179 };180 if (mode.free) {181 step.meta.freeSpinsLeft = mode.freeSpinsLeft;182 step.meta.freeSpinsTotal = mode.freeSpinsTotal;183 }184 if (exploded.length) {185 step.meta.exploded = exploded;186 if (cascadeIndex === 0) features.push("Exploding Wild");187 }188 const removable = new Set<string>();189 if (cascadeCfg || exploded.length) {190 for (const w of wins) for (const [r, y] of w.positions) removable.add(key(r, y));191 for (const [r, y] of exploded) removable.add(key(r, y));192 }193 steps.push(step);194 if (!cascadeCfg && exploded.length === 0) break;195 if (removable.size === 0 || cascadeIndex >= maxCascades) break;196 const removed = [...removable].map((k) => k.split(":").map(Number) as [number, number]);197 step.meta.removed = removed;198 // Wild multipliers of removed wilds are cleared.199 for (const k of removable) wildMults.delete(k);200 refillGrid(def, grid, removed, rng, mode.free, protectedCells);201 if (cascadeCfg && wins.length) mode.ladder.index++;202 cascadeIndex++;203 // Newly landed wilds after refill.204 const before = new Set(step.grid.flatMap((c, r) => c.map((s, y) => (s === def.wild?.id ? key(r, y) : ""))).filter(Boolean));205 newWilds = wildPositions(def, grid).filter(([r, y]) => !before.has(key(r, y)) || removable.has(key(r, y)));206 assignWildMultipliers(def, grid, rng, mode.free, wildMults);207 if (cascadeIndex > 0) features.push("Cascade");208 }209210 return { steps, win: spinWin, scatterCount: sc.count, scatterPositions: sc.positions, chargeCount, initialGrid, newWilds: newWildsAll };211}212213export interface RunSpinOptions {214 rng?: Rng;215 state?: PlayerGameState;216}217218/** Resolve a full round for `bet` credits. Pure: no I/O. */219export function runSpin(def: GameDefinition, bet: number, opts: RunSpinOptions = {}): SpinOutcome {220 const rng = opts.rng ?? new CryptoRng();221 const stateBefore: PlayerGameState = opts.state ? structuredClone(opts.state) : emptyPlayerState();222 const state: PlayerGameState = structuredClone(stateBefore);223 const features: string[] = [];224 const stepIndex = { i: 0 };225 const running = { total: 0 };226 const steps: SpinStep[] = [];227 let freeSpinsTriggered = false;228 let bonusTriggered = false;229 let jackpot: { tier: string; amount: number } | null = null;230231 // Meter multiplier level applied to all wins this round.232 let levelMultiplier = 1;233 if (def.meter?.onFull.type === "multiplierLevel") {234 const lvl = state.meterLevels[def.meter.id] ?? 0;235 levelMultiplier = def.meter.onFull.ladder[Math.min(lvl, def.meter.onFull.ladder.length - 1)];236 }237 // Heat multiplier.238 let heatMultiplier = 1;239 if (def.heat) {240 const band = Math.min(Math.floor(state.heat / def.heat.bandSize), def.heat.ladder.length - 1);241 heatMultiplier = def.heat.ladder[band];242 }243244 const baseMode: SpinMode = {245 free: false,246 fsMultiplier: levelMultiplier * heatMultiplier,247 gridSize: def.grid,248 ladder: { index: 0 },249 collectCount: { n: 0 },250 };251 const base = playSpin(def, bet, rng, baseMode, stepIndex, running, features);252 if (def.heat) {253 base.steps[0].meta.heat = state.heat;254 base.steps[0].meta.heatMax = def.heat.max;255 }256 steps.push(...base.steps);257258 // Hold & respin.259 if (def.holdRespin) {260 const hr = resolveHoldRespin(def, base.initialGrid, bet, rng);261 if (hr) {262 bonusTriggered = true;263 features.push("Hold & Respin");264 hr.steps.forEach((s, i) => {265 const isLast = i === hr.steps.length - 1;266 const win = isLast ? hr.total : 0;267 running.total += win;268 steps.push({269 type: "respin",270 index: stepIndex.i++,271 grid: s.grid,272 wins: [],273 multiplier: 1,274 win,275 runningTotal: running.total,276 meta: { label: i === 0 ? "Lock & Respin" : "Respin", coins: s.coins, respinsLeft: s.respinsLeft, jackpot: isLast && hr.jackpot ? hr.jackpot : undefined },277 });278 });279 if (hr.jackpot) {280 jackpot = hr.jackpot;281 features.push(`Jackpot ${hr.jackpot.tier}`);282 }283 }284 }285286 // Free spins from scatters.287 const runFreeSpins = (count: number, label: string, fsMultiplier: number, extraWildChance?: number, entryBonusId?: string) => {288 freeSpinsTriggered = true;289 features.push(label);290 if (entryBonusId) {291 const cfg = def.pickBonuses?.find((b) => b.id === entryBonusId);292 if (cfg) {293 const res = resolvePickBonus(def, cfg, bet, rng);294 running.total += res.total;295 bonusTriggered = true;296 features.push(cfg.name);297 steps.push({298 type: "bonus",299 index: stepIndex.i++,300 grid: base.initialGrid,301 wins: [],302 multiplier: 1,303 win: res.total,304 runningTotal: running.total,305 meta: { label: cfg.name, bonusName: cfg.name, picks: res.picks },306 });307 }308 }309 let left = count;310 let total = count;311 let played = 0;312 const sticky = def.wild?.sticky ? new Set<string>() : undefined;313 let moving: [number, number][] | undefined = def.wild?.moving ? [] : undefined;314 const ladder = { index: 0 };315 const collectCount = { n: baseMode.collectCount.n };316 const fsMult = fsMultiplier * levelMultiplier;317 while (left > 0 && played < MAX_FREE_SPINS_PER_ROUND) {318 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;319 const mode: SpinMode = {320 free: true,321 label: played === 0 ? label : undefined,322 fsMultiplier: fsMult,323 gridSize,324 sticky,325 moving,326 ladder,327 collectCount,328 freeSpinsLeft: left - 1,329 freeSpinsTotal: total,330 extraWildChance,331 };332 const res = playSpin(def, bet, rng, mode, stepIndex, running, features);333 steps.push(...res.steps);334 left--;335 played++;336 // Sticky: remember newly landed wilds.337 if (sticky) for (const [r, y] of res.newWilds) sticky.add(key(r, y));338 // Moving: track wilds and shift them left for the next spin.339 if (moving !== undefined) {340 const current = wildPositions(def, res.initialGrid);341 moving = shiftMovingWilds(current);342 }343 // Retrigger.344 if (def.scatter?.retrigger && res.scatterCount > 0) {345 const extra = def.scatter.triggers[res.scatterCount] ?? 0;346 if (extra > 0) {347 left += extra;348 total += extra;349 res.steps[0].meta.freeSpinsAwarded = extra;350 res.steps[0].meta.freeSpinsLeft = left;351 res.steps[0].meta.freeSpinsTotal = total;352 features.push("Retrigger");353 }354 }355 // Meters keep charging in free spins.356 if (def.meter && res.chargeCount) state.meters[def.meter.id] = (state.meters[def.meter.id] ?? 0) + res.chargeCount;357 }358 };359360 if (def.scatter && base.scatterCount > 0) {361 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);362 if (awarded > 0) {363 base.steps[0].meta.freeSpinsAwarded = awarded;364 runFreeSpins(awarded, "Free Spins", def.scatter.freeSpinMultiplier ?? 1, undefined, def.scatter.entryBonus);365 if (def.meter?.resetOnBonus) state.meterLevels[def.meter.id] = 0;366 }367 }368369 // Persistent meter.370 if (def.meter) {371 const m = def.meter;372 const before = state.meters[m.id] ?? 0;373 let value = before + base.chargeCount;374 if (base.chargeCount > 0) base.steps[0].meta.meter = { id: m.id, value: Math.min(value, m.max), max: m.max, gained: base.chargeCount };375 if (value >= m.max) {376 value -= m.max;377 features.push(m.onFull.label);378 if (m.onFull.type === "freeSpins") {379 runFreeSpins(m.onFull.spins, m.onFull.label, m.onFull.multiplier ?? 1, m.onFull.extraWildChance);380 } else if (m.onFull.type === "pickBonus") {381 const cfg = def.pickBonuses?.find((b) => b.id === (m.onFull as { bonusId: string }).bonusId);382 if (cfg) {383 const res = resolvePickBonus(def, cfg, bet, rng);384 running.total += res.total;385 bonusTriggered = true;386 steps.push({387 type: "bonus",388 index: stepIndex.i++,389 grid: base.initialGrid,390 wins: [],391 multiplier: 1,392 win: res.total,393 runningTotal: running.total,394 meta: { label: cfg.name, bonusName: cfg.name, picks: res.picks },395 });396 }397 } else if (m.onFull.type === "multiplierLevel") {398 const lvl = (state.meterLevels[m.id] ?? 0) + 1;399 state.meterLevels[m.id] = Math.min(lvl, m.onFull.ladder.length - 1);400 steps.push({401 type: "feature",402 index: stepIndex.i++,403 grid: base.initialGrid,404 wins: [],405 multiplier: m.onFull.ladder[state.meterLevels[m.id]],406 win: 0,407 runningTotal: running.total,408 meta: { label: m.onFull.label, meter: { id: m.id, value, max: m.max, gained: 0 } },409 });410 }411 }412 state.meters[m.id] = value;413 }414415 // Heat.416 if (def.heat) {417 const h = def.heat;418 const won = base.win > 0;419 state.heat = Math.max(0, state.heat + (won ? h.gainOnWin : h.gainOnLoss));420 if (state.heat >= h.max) {421 state.heat = 0;422 runFreeSpins(h.meltdown.spins, h.meltdown.label, h.meltdown.multiplier);423 }424 }425426 // Random jackpot.427 if (def.jackpot && !jackpot) {428 const p = def.jackpot.chancePerSpin * (bet / def.maxBet);429 if (rng.chance(p)) {430 const tier = def.jackpot.tiers[rng.weighted(def.jackpot.tiers.map((t) => t.weight))];431 const amount = Math.round(tier.multiplier * bet);432 jackpot = { tier: tier.id, amount };433 running.total += amount;434 features.push(`Jackpot ${tier.id}`);435 steps.push({436 type: "jackpot",437 index: stepIndex.i++,438 grid: base.initialGrid,439 wins: [],440 multiplier: 1,441 win: amount,442 runningTotal: running.total,443 meta: { label: `${tier.id.toUpperCase()} JACKPOT`, jackpot },444 });445 }446 }447448 // Cap.449 let totalWin = running.total;450 let capped = false;451 const cap = Math.round(def.maxMultiplier * bet);452 if (totalWin > cap) {453 totalWin = cap;454 capped = true;455 const last = steps[steps.length - 1];456 if (last) last.meta.capped = true;457 }458 state.rounds += 1;459460 return {461 game: def.slug,462 version: def.version,463 bet,464 totalWin,465 multiplier: bet > 0 ? totalWin / bet : 0,466 capped,467 steps,468 features: [...new Set(features)],469 freeSpinsTriggered,470 bonusTriggered,471 jackpot,472 stateBefore,473 stateAfter: state,474 rngReference: rng.reference(),475 };476}477