From fc59ef77cda14d97d854dfcaa848307cfa512b69 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:13:24 +0100 Subject: v1.0.0: replace slots with 5x3 reels and five paylines --- src/server/games/slots.rs | 212 +++++++++++++++++++++++++++++----------------- 1 file changed, 135 insertions(+), 77 deletions(-) (limited to 'src/server/games/slots.rs') diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index 02595b1..2ef1b46 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs @@ -2,77 +2,127 @@ 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 { - symbols: Vec, - payouts: HashMap, - /// Deterministic reels for tests and demos. - fixed_reels: Option>, + payouts: HashMap, + /// Deterministic grid for tests and demos, in column-major order. + fixed_grid: Option>, } impl SlotsGame { pub fn new() -> Self { - let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S']; let mut payouts = HashMap::new(); - // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge) - payouts.insert("CCC".to_string(), 2); - payouts.insert("LLL".to_string(), 3); - payouts.insert("OOO".to_string(), 5); - payouts.insert("GGG".to_string(), 8); - payouts.insert("DDD".to_string(), 14); - payouts.insert("SSS".to_string(), 28); - - payouts.insert("CC".to_string(), 1); - payouts.insert("LL".to_string(), 1); - payouts.insert("OO".to_string(), 1); - payouts.insert("GG".to_string(), 2); - payouts.insert("DD".to_string(), 2); - payouts.insert("SS".to_string(), 3); + // 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 { - symbols, payouts, - fixed_reels: None, + fixed_grid: None, } } - /// Build a slots game that always spins the given three symbols. - pub fn with_reels(reels: &str) -> Self { + /// 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_reels = Some(reels.chars().take(3).collect()); + game.fixed_grid = Some(grid.chars().take(REELS * ROWS).collect()); game } - fn spin_reels(&self) -> Vec { - if let Some(reels) = &self.fixed_reels { - return reels.clone(); + fn spin_grid(&self) -> Vec { + if let Some(grid) = &self.fixed_grid { + return grid.clone(); } let mut rng = rand::thread_rng(); - (0..3) - .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) + (0..REELS * ROWS) + .map(|_| SYMBOLS[rng.gen_range(0..SYMBOLS.len())]) .collect() } - pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { - let reel_string: String = reels.iter().collect(); - - if let Some(&multiplier) = self.payouts.get(&reel_string) { - return bet.saturating_mul(multiplier); - } - - // Check all three pairs including non-adjacent (e.g. [C,D,C]) - let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; - let mut best: u64 = 0; - for &(a, b) in &pairs { - if reels[a] == reels[b] { - let two_of_kind = format!("{}{}", reels[a], reels[a]); - if let Some(&multiplier) = self.payouts.get(&two_of_kind) { - best = best.max(bet.saturating_mul(multiplier)); + /// 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); } - best + + (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 } } @@ -86,7 +136,7 @@ impl Game for SlotsGame { } fn get_description(&self) -> &'static str { - "Spin the reels and match symbols to win!" + "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 { @@ -115,32 +165,21 @@ impl Game for SlotsGame { match action { "spin" => { - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { + if state.phase != GamePhase::WaitingToStart + && state.phase != GamePhase::Playing + { return Err(GameError::InvalidAction( "Cannot spin in current phase".to_string(), )); } - let reels = self.spin_reels(); - let reel_string: String = reels.iter().collect(); + let grid = self.spin_grid(); + let reels: String = grid.iter().collect(); let mut new_data = state.data.clone(); - new_data.insert("reels".to_string(), reel_string.clone()); + new_data.insert("reels".to_string(), reels); new_data.insert("status".to_string(), "spun".to_string()); - let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; - let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; - new_data.insert( - "win_type".to_string(), - if is_jackpot { - "jackpot".to_string() - } else if is_two { - "two".to_string() - } else { - "none".to_string() - }, - ); - Ok(GameState { session_id: state.session_id.clone(), game_type: state.game_type.clone(), @@ -165,24 +204,31 @@ impl Game for SlotsGame { .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 reels: Vec = reel_string.chars().collect(); - let winnings = self.calculate_payout(&reels, state.bet_amount); + 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( - "payout_multiplier".to_string(), - if winnings > 0 && state.bet_amount > 0 { - (winnings / state.bet_amount).to_string() - } else { - "0".to_string() - }, - ); - result_data.insert( - "win_type".to_string(), - state.data.get("win_type").cloned().unwrap_or_default(), - ); + 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(), @@ -194,3 +240,15 @@ impl Game for SlotsGame { }) } } + +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" + } +} -- cgit v1.2.3