SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
17.7 KB · 477 lines typescript
Raw Blame History
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