import type { GameDefinition, Grid, WinLine } from "../types"; import { reelTables } from "../reels"; export interface EvalContext { bet: number; /** Cell multiplicity (quantum splits); undefined = 1 everywhere. */ multiplicity?: number[][]; /** Wild multiplier per cell (from multiplier wilds). */ wildMultipliers?: Map; } const key = (r: number, y: number) => `${r}:${y}`; function isWild(def: GameDefinition, id: string): boolean { return def.wild?.id === id; } /** Exact (unrounded) credit amount for a pay multiple; rounding happens once per step. */ export function credits(bet: number, payMultiple: number, scale: number): number { return bet * payMultiple * scale; } function combineWildMultipliers(def: GameDefinition, positions: [number, number][], ctx: EvalContext): number { if (!ctx.wildMultipliers || ctx.wildMultipliers.size === 0 || !def.wild?.multiplier) return 1; let acc = def.wild.multiplier.combine === "add" ? 0 : 1; let any = false; for (const [r, y] of positions) { const m = ctx.wildMultipliers.get(key(r, y)); if (m && m > 1) { any = true; acc = def.wild.multiplier.combine === "add" ? acc + m : acc * m; } } if (!any) return 1; const cap = def.wild.maxCombinedMultiplier ?? Infinity; return Math.min(acc, cap); } /** Ways evaluation (left to right, adjacent reels). */ export function evaluateWays(def: GameDefinition, grid: Grid, ctx: EvalContext): WinLine[] { const tables = reelTables(def); const wins: WinLine[] = []; const reels = grid.length; const wildId = def.wild?.id; // Candidate symbols = regular symbols present on reel 0 (or wild on reel 0 → any symbol). const candidates = new Set(); for (const id of grid[0]) { if (id === wildId) { for (const s of tables.regularIds) candidates.add(s); } else if (tables.byId.get(id)?.pays) candidates.add(id); } for (const symbol of candidates) { const sym = tables.byId.get(symbol); if (!sym?.pays) continue; let ways = 1; let count = 0; const positions: [number, number][] = []; for (let r = 0; r < reels; r++) { let matches = 0; for (let y = 0; y < grid[r].length; y++) { const id = grid[r][y]; if (id === symbol || (wildId && id === wildId)) { matches += ctx.multiplicity?.[r]?.[y] ?? 1; positions.push([r, y]); } } if (matches === 0) break; ways *= matches; count++; } const pay = sym.pays[count]; if (count >= 2 && pay) { const wm = combineWildMultipliers(def, positions, ctx); const raw = (credits(ctx.bet, pay, def.payScale) * ways) / def.betDivisor; wins.push({ symbol, count, ways, positions, base: Math.round(raw), wildMultiplier: wm, payout: Math.round(raw * wm), raw: raw * wm }); } } return wins; } /** Payline evaluation. `lines[i][reel] = row`. */ export function evaluateLines(def: GameDefinition, grid: Grid, ctx: EvalContext): WinLine[] { if (def.payModel.type !== "lines") return []; const tables = reelTables(def); const wildId = def.wild?.id; const wins: WinLine[] = []; const rows = grid[0].length; def.payModel.lines.forEach((line, li) => { // Determine target symbol: first non-wild along the line. let symbol: string | null = null; for (let r = 0; r < grid.length; r++) { const row = Math.min(line[r] ?? 0, rows - 1); const id = grid[r][row]; if (id !== wildId) { symbol = id; break; } } // All-wild line pays as the best regular symbol. const evaluateFor = (sym: string): WinLine | null => { const s = tables.byId.get(sym); if (!s?.pays) return null; let count = 0; const positions: [number, number][] = []; for (let r = 0; r < grid.length; r++) { const row = Math.min(line[r] ?? 0, rows - 1); const id = grid[r][row]; if (id === sym || id === wildId) { count++; positions.push([r, row]); } else break; } const pay = s.pays[count]; if (!pay) return null; const wm = combineWildMultipliers(def, positions, ctx); const raw = credits(ctx.bet, pay, def.payScale) / def.betDivisor; return { symbol: sym, count, line: li, positions, base: Math.round(raw), wildMultiplier: wm, payout: Math.round(raw * wm), raw: raw * wm }; }; if (symbol === null) { let best: WinLine | null = null; for (const sym of tables.regularIds) { const w = evaluateFor(sym); if (w && (!best || w.payout > best.payout)) best = w; } if (best) wins.push(best); } else { const w = evaluateFor(symbol); if (w) wins.push(w); } }); return wins; } export function evaluate(def: GameDefinition, grid: Grid, ctx: EvalContext): WinLine[] { return def.payModel.type === "ways" ? evaluateWays(def, grid, ctx) : evaluateLines(def, grid, ctx); } /** Scatter pays (anywhere on the grid). */ export function scatterWin(def: GameDefinition, grid: Grid, bet: number): { count: number; positions: [number, number][]; win: number } { const sid = def.scatter?.id; const positions: [number, number][] = []; if (!sid) return { count: 0, positions, win: 0 }; for (let r = 0; r < grid.length; r++) for (let y = 0; y < grid[r].length; y++) if (grid[r][y] === sid) positions.push([r, y]); const sym = reelTables(def).byId.get(sid); const pay = sym?.scatterPays?.[positions.length] ?? 0; return { count: positions.length, positions, win: pay ? credits(bet, pay, def.payScale) : 0 }; } /** Exact sum of wins (unrounded). */ export function sumWins(wins: WinLine[]): number { let t = 0; for (const w of wins) t += w.raw; return t; } /** Standard payline sets. */ export const LINES_10: number[][] = [ [1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [2, 2, 2, 2, 2], [0, 1, 2, 1, 0], [2, 1, 0, 1, 2], [0, 0, 1, 2, 2], [2, 2, 1, 0, 0], [1, 0, 1, 2, 1], [1, 2, 1, 0, 1], [0, 1, 1, 1, 0], ]; export const LINES_20: number[][] = [ ...LINES_10, [2, 1, 1, 1, 2], [0, 1, 0, 1, 0], [2, 1, 2, 1, 2], [1, 1, 0, 1, 1], [1, 1, 2, 1, 1], [0, 0, 1, 0, 0], [2, 2, 1, 2, 2], [1, 0, 0, 0, 1], [1, 2, 2, 2, 1], [0, 2, 0, 2, 0], ]; export const LINES_25: number[][] = [ ...LINES_20, [2, 0, 2, 0, 2], [0, 2, 2, 2, 0], [2, 0, 0, 0, 2], [1, 0, 2, 0, 1], [1, 2, 0, 2, 1], ];