use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; use rand::Rng; use std::collections::HashMap; pub const REELS: usize = 5; pub const ROWS: usize = 3; pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; /// Five paylines, each entry is the row used on reel 0..4. pub const PAYLINES: [[usize; REELS]; 5] = [ [0, 0, 0, 0, 0], // line 1: top [1, 1, 1, 1, 1], // line 2: middle [2, 2, 2, 2, 2], // line 3: bottom [0, 1, 2, 1, 0], // line 4: V [2, 1, 0, 1, 2], // line 5: inverted V ]; #[derive(Clone, Copy)] struct Payout { three: u64, four: u64, five: u64, } pub struct SlotsGame { payouts: HashMap, /// Deterministic grid for tests and demos, in column-major order. fixed_grid: Option>, } impl SlotsGame { pub fn new() -> Self { let mut payouts = HashMap::new(); // RTP = 425/432 = 0.9838 (1.62% house edge) with five paylines. payouts.insert('C', Payout { three: 2, four: 5, five: 15 }); payouts.insert('L', Payout { three: 3, four: 7, five: 20 }); payouts.insert('O', Payout { three: 3, four: 8, five: 25 }); payouts.insert('G', Payout { three: 4, four: 12, five: 40 }); payouts.insert('D', Payout { three: 6, four: 18, five: 70 }); payouts.insert('S', Payout { three: 9, four: 30, five: 150 }); 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 1-based winning lines. pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec) { let mut total = 0u64; let mut winning_lines = 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)); winning_lines.push(index + 1); } (total, winning_lines) } /// 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 per_line: f64 = self .payouts .values() .map(|payout| { p3 * payout.three as f64 + p4 * payout.four as f64 + p5 * payout.five as f64 }) .sum(); per_line * 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, winning_lines) = self.calculate_payout(&grid, state.bet_amount); let win_type = classify_win(winnings, state.bet_amount); let win_lines = if winning_lines.is_empty() { "-".to_string() } else { winning_lines .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); 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" } }