use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; use crate::slots::{SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; use rand::Rng; use std::collections::HashMap; pub use crate::slots::{PAYLINES, REELS, ROWS, SYMBOLS}; #[derive(Clone, Copy)] struct Payout { three: u64, four: u64, five: u64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LineWin { pub line: usize, /// Column-major cell indices of the matched symbols. pub cells: Vec, } pub struct SlotsGame { payouts: HashMap, /// Deterministic grid for tests and demos, in column-major order. fixed_grid: Option>, } impl SlotsGame { pub fn new() -> Self { // payout = bet * symbol multiplier * shape multiplier let payouts = SYMBOL_MULTIPLIERS .iter() .map(|&(symbol, symbol_multiplier)| { ( symbol, Payout { three: symbol_multiplier * SHAPE_MULTIPLIERS[0].1, four: symbol_multiplier * SHAPE_MULTIPLIERS[1].1, five: symbol_multiplier * SHAPE_MULTIPLIERS[2].1, }, ) }) .collect(); Self { payouts, fixed_grid: None, } } /// Build a slots game that always spins the given 15-symbol grid. /// The string is column-major: reel 0 top/middle/bottom, then reel 1... pub fn with_grid(grid: &str) -> Self { let mut game = Self::new(); game.fixed_grid = Some(grid.chars().take(REELS * ROWS).collect()); game } fn spin_grid(&self) -> Vec { if let Some(grid) = &self.fixed_grid { return grid.clone(); } let mut rng = rand::thread_rng(); (0..REELS * ROWS) .map(|_| SYMBOLS[rng.gen_range(0..SYMBOLS.len())]) .collect() } /// Evaluate one spin. Returns the total win and the winning lines with /// the column-major cells that should be highlighted. pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec) { let mut total = 0u64; let mut wins = Vec::new(); for (index, pattern) in PAYLINES.iter().enumerate() { let Some(&first) = grid.get(pattern[0]) else { continue; }; let mut run = 1; for (col, row) in pattern.iter().enumerate().skip(1) { let position = col * ROWS + row; if grid.get(position) == Some(&first) { run += 1; } else { break; } } if run < 3 { continue; } let Some(payout) = self.payouts.get(&first) else { continue; }; let multiplier = match run { 3 => payout.three, 4 => payout.four, _ => payout.five, }; total = total.saturating_add(bet.saturating_mul(multiplier)); let cells = pattern .iter() .enumerate() .take(run) .map(|(col, row)| col * ROWS + row) .collect(); wins.push(LineWin { line: index + 1, cells, }); } (total, wins) } /// Exact theoretical return to player for the current paytable. pub fn theoretical_rtp(&self) -> f64 { // For one reel line and one symbol: exactly 3, 4 or 5 of a kind. let p3 = 5.0 / 1296.0; let p4 = 5.0 / 7776.0; let p5 = 1.0 / 7776.0; let symbol_sum: f64 = SYMBOL_MULTIPLIERS .iter() .map(|(_, multiplier)| *multiplier as f64) .sum(); let shape_ev = p3 * SHAPE_MULTIPLIERS[0].1 as f64 + p4 * SHAPE_MULTIPLIERS[1].1 as f64 + p5 * SHAPE_MULTIPLIERS[2].1 as f64; symbol_sum * shape_ev * PAYLINES.len() as f64 } } impl Game for SlotsGame { fn game_type(&self) -> &'static str { "slots" } fn get_name(&self) -> &'static str { "Slots" } fn get_description(&self) -> &'static str { "5 reels x 3 rows, 5 paylines. Match 3, 4 or 5 symbols from the left." } fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { let mut data = HashMap::new(); data.insert("username".to_string(), username.to_string()); data.insert("status".to_string(), "ready".to_string()); GameState { session_id: String::new(), game_type: "slots".to_string(), bet_amount, phase: GamePhase::WaitingToStart, data, } } fn process_action( &self, state: &GameState, action: &str, _params: &[&str], ) -> Result { if state.phase == GamePhase::Finished { return Err(GameError::GameOver); } match action { "spin" => { if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { return Err(GameError::InvalidAction( "Cannot spin in current phase".to_string(), )); } let grid = self.spin_grid(); let reels: String = grid.iter().collect(); let mut new_data = state.data.clone(); new_data.insert("reels".to_string(), reels); new_data.insert("status".to_string(), "spun".to_string()); Ok(GameState { session_id: state.session_id.clone(), game_type: state.game_type.clone(), bet_amount: state.bet_amount, phase: GamePhase::Finished, data: new_data, }) } _ => Err(GameError::InvalidAction(format!( "Unknown action: {}", action ))), } } fn calculate_result(&self, state: &GameState) -> Result { if state.phase != GamePhase::Finished { return Err(GameError::InternalError("Game not finished".to_string())); } let reel_string = state .data .get("reels") .ok_or(GameError::InternalError("No reels found".to_string()))?; let grid: Vec = reel_string.chars().collect(); if grid.len() != REELS * ROWS { return Err(GameError::InternalError(format!( "Expected {} symbols, got {}", REELS * ROWS, grid.len() ))); } let (winnings, wins) = self.calculate_payout(&grid, state.bet_amount); let win_type = classify_win(winnings, state.bet_amount); let win_lines = if wins.is_empty() { "-".to_string() } else { wins.iter() .map(|win| win.line.to_string()) .collect::>() .join(",") }; let mut cells: Vec = wins .iter() .flat_map(|win| win.cells.iter().copied()) .collect(); cells.sort_unstable(); cells.dedup(); let win_cells = if cells.is_empty() { "-".to_string() } else { cells .iter() .map(usize::to_string) .collect::>() .join(",") }; let mut result_data = HashMap::new(); result_data.insert("reels".to_string(), reel_string.clone()); result_data.insert("win_type".to_string(), win_type.to_string()); result_data.insert("win_lines".to_string(), win_lines); result_data.insert("win_cells".to_string(), win_cells); Ok(GameResult { session_id: state.session_id.clone(), game_type: state.game_type.clone(), bet_amount: state.bet_amount, winnings, final_balance: 0, result_data, }) } } fn classify_win(winnings: u64, bet: u64) -> &'static str { if winnings == 0 { "none" } else if bet > 0 && winnings >= bet.saturating_mul(50) { "mega" } else if bet > 0 && winnings >= bet.saturating_mul(10) { "big" } else { "win" } }