diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
| commit | fc59ef77cda14d97d854dfcaa848307cfa512b69 (patch) | |
| tree | b0fc97390b96078e0a0538e76d7c76f7c04135f1 /src/server | |
| parent | v0.4.0: add first-time TLS setup and server help (diff) | |
| download | zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.tar.gz zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.zip | |
v1.0.0: replace slots with 5x3 reels and five paylines
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/core.rs | 17 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 212 |
2 files changed, 148 insertions, 81 deletions
diff --git a/src/server/core.rs b/src/server/core.rs index 84ad4d2..33cafbf 100644 --- a/src/server/core.rs +++ b/src/server/core.rs | |||
| @@ -239,12 +239,21 @@ impl ServerCore { | |||
| 239 | } | 239 | } |
| 240 | }; | 240 | }; |
| 241 | 241 | ||
| 242 | let reels = new_state.data.get("reels").cloned().unwrap_or_default(); | 242 | let reels = result |
| 243 | let win_type = new_state | 243 | .result_data |
| 244 | .data | 244 | .get("reels") |
| 245 | .cloned() | ||
| 246 | .unwrap_or_default(); | ||
| 247 | let win_type = result | ||
| 248 | .result_data | ||
| 245 | .get("win_type") | 249 | .get("win_type") |
| 246 | .cloned() | 250 | .cloned() |
| 247 | .unwrap_or_default(); | 251 | .unwrap_or_default(); |
| 252 | let win_lines = result | ||
| 253 | .result_data | ||
| 254 | .get("win_lines") | ||
| 255 | .cloned() | ||
| 256 | .unwrap_or_else(|| "-".to_string()); | ||
| 248 | 257 | ||
| 249 | match database::apply_spin( | 258 | match database::apply_spin( |
| 250 | &mut self.conn, | 259 | &mut self.conn, |
| @@ -259,7 +268,7 @@ impl ServerCore { | |||
| 259 | Ok(new_balance) => { | 268 | Ok(new_balance) => { |
| 260 | let _ = self.sessions.remove_session(&session_id); | 269 | let _ = self.sessions.remove_session(&session_id); |
| 261 | format!( | 270 | format!( |
| 262 | "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type}" | 271 | "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines}" |
| 263 | ) | 272 | ) |
| 264 | } | 273 | } |
| 265 | Err(error) => { | 274 | Err(error) => { |
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}; | |||
| 2 | use rand::Rng; | 2 | use rand::Rng; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::HashMap; |
| 4 | 4 | ||
| 5 | pub const REELS: usize = 5; | ||
| 6 | pub const ROWS: usize = 3; | ||
| 7 | pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; | ||
| 8 | |||
| 9 | /// Five paylines, each entry is the row used on reel 0..4. | ||
| 10 | pub const PAYLINES: [[usize; REELS]; 5] = [ | ||
| 11 | [0, 0, 0, 0, 0], // line 1: top | ||
| 12 | [1, 1, 1, 1, 1], // line 2: middle | ||
| 13 | [2, 2, 2, 2, 2], // line 3: bottom | ||
| 14 | [0, 1, 2, 1, 0], // line 4: V | ||
| 15 | [2, 1, 0, 1, 2], // line 5: inverted V | ||
| 16 | ]; | ||
| 17 | |||
| 18 | #[derive(Clone, Copy)] | ||
| 19 | struct Payout { | ||
| 20 | three: u64, | ||
| 21 | four: u64, | ||
| 22 | five: u64, | ||
| 23 | } | ||
| 24 | |||
| 5 | pub struct SlotsGame { | 25 | pub struct SlotsGame { |
| 6 | symbols: Vec<char>, | 26 | payouts: HashMap<char, Payout>, |
| 7 | payouts: HashMap<String, u64>, | 27 | /// Deterministic grid for tests and demos, in column-major order. |
| 8 | /// Deterministic reels for tests and demos. | 28 | fixed_grid: Option<Vec<char>>, |
| 9 | fixed_reels: Option<Vec<char>>, | ||
| 10 | } | 29 | } |
| 11 | 30 | ||
| 12 | impl SlotsGame { | 31 | impl SlotsGame { |
| 13 | pub fn new() -> Self { | 32 | pub fn new() -> Self { |
| 14 | let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S']; | ||
| 15 | let mut payouts = HashMap::new(); | 33 | let mut payouts = HashMap::new(); |
| 16 | 34 | ||
| 17 | // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge) | 35 | // RTP = 425/432 = 0.9838 (1.62% house edge) with five paylines. |
| 18 | payouts.insert("CCC".to_string(), 2); | 36 | payouts.insert('C', Payout { three: 2, four: 5, five: 15 }); |
| 19 | payouts.insert("LLL".to_string(), 3); | 37 | payouts.insert('L', Payout { three: 3, four: 7, five: 20 }); |
| 20 | payouts.insert("OOO".to_string(), 5); | 38 | payouts.insert('O', Payout { three: 3, four: 8, five: 25 }); |
| 21 | payouts.insert("GGG".to_string(), 8); | 39 | payouts.insert('G', Payout { three: 4, four: 12, five: 40 }); |
| 22 | payouts.insert("DDD".to_string(), 14); | 40 | payouts.insert('D', Payout { three: 6, four: 18, five: 70 }); |
| 23 | payouts.insert("SSS".to_string(), 28); | 41 | payouts.insert('S', Payout { three: 9, four: 30, five: 150 }); |
| 24 | |||
| 25 | payouts.insert("CC".to_string(), 1); | ||
| 26 | payouts.insert("LL".to_string(), 1); | ||
| 27 | payouts.insert("OO".to_string(), 1); | ||
| 28 | payouts.insert("GG".to_string(), 2); | ||
| 29 | payouts.insert("DD".to_string(), 2); | ||
| 30 | payouts.insert("SS".to_string(), 3); | ||
| 31 | 42 | ||
| 32 | Self { | 43 | Self { |
| 33 | symbols, | ||
| 34 | payouts, | 44 | payouts, |
| 35 | fixed_reels: None, | 45 | fixed_grid: None, |
| 36 | } | 46 | } |
| 37 | } | 47 | } |
| 38 | 48 | ||
| 39 | /// Build a slots game that always spins the given three symbols. | 49 | /// Build a slots game that always spins the given 15-symbol grid. |
| 40 | pub fn with_reels(reels: &str) -> Self { | 50 | /// The string is column-major: reel 0 top/middle/bottom, then reel 1... |
| 51 | pub fn with_grid(grid: &str) -> Self { | ||
| 41 | let mut game = Self::new(); | 52 | let mut game = Self::new(); |
| 42 | game.fixed_reels = Some(reels.chars().take(3).collect()); | 53 | game.fixed_grid = Some(grid.chars().take(REELS * ROWS).collect()); |
| 43 | game | 54 | game |
| 44 | } | 55 | } |
| 45 | 56 | ||
| 46 | fn spin_reels(&self) -> Vec<char> { | 57 | fn spin_grid(&self) -> Vec<char> { |
| 47 | if let Some(reels) = &self.fixed_reels { | 58 | if let Some(grid) = &self.fixed_grid { |
| 48 | return reels.clone(); | 59 | return grid.clone(); |
| 49 | } | 60 | } |
| 50 | 61 | ||
| 51 | let mut rng = rand::thread_rng(); | 62 | let mut rng = rand::thread_rng(); |
| 52 | (0..3) | 63 | (0..REELS * ROWS) |
| 53 | .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) | 64 | .map(|_| SYMBOLS[rng.gen_range(0..SYMBOLS.len())]) |
| 54 | .collect() | 65 | .collect() |
| 55 | } | 66 | } |
| 56 | 67 | ||
| 57 | pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { | 68 | /// Evaluate one spin. Returns the total win and the 1-based winning lines. |
| 58 | let reel_string: String = reels.iter().collect(); | 69 | pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec<usize>) { |
| 59 | 70 | let mut total = 0u64; | |
| 60 | if let Some(&multiplier) = self.payouts.get(&reel_string) { | 71 | let mut winning_lines = Vec::new(); |
| 61 | return bet.saturating_mul(multiplier); | 72 | |
| 62 | } | 73 | for (index, pattern) in PAYLINES.iter().enumerate() { |
| 63 | 74 | let Some(&first) = grid.get(pattern[0]) else { | |
| 64 | // Check all three pairs including non-adjacent (e.g. [C,D,C]) | 75 | continue; |
| 65 | let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; | 76 | }; |
| 66 | let mut best: u64 = 0; | 77 | |
| 67 | for &(a, b) in &pairs { | 78 | let mut run = 1; |
| 68 | if reels[a] == reels[b] { | 79 | for (col, row) in pattern.iter().enumerate().skip(1) { |
| 69 | let two_of_kind = format!("{}{}", reels[a], reels[a]); | 80 | let position = col * ROWS + row; |
| 70 | if let Some(&multiplier) = self.payouts.get(&two_of_kind) { | 81 | if grid.get(position) == Some(&first) { |
| 71 | best = best.max(bet.saturating_mul(multiplier)); | 82 | run += 1; |
| 83 | } else { | ||
| 84 | break; | ||
| 72 | } | 85 | } |
| 73 | } | 86 | } |
| 87 | |||
| 88 | if run < 3 { | ||
| 89 | continue; | ||
| 90 | } | ||
| 91 | |||
| 92 | let Some(payout) = self.payouts.get(&first) else { | ||
| 93 | continue; | ||
| 94 | }; | ||
| 95 | let multiplier = match run { | ||
| 96 | 3 => payout.three, | ||
| 97 | 4 => payout.four, | ||
| 98 | _ => payout.five, | ||
| 99 | }; | ||
| 100 | |||
| 101 | total = total.saturating_add(bet.saturating_mul(multiplier)); | ||
| 102 | winning_lines.push(index + 1); | ||
| 74 | } | 103 | } |
| 75 | best | 104 | |
| 105 | (total, winning_lines) | ||
| 106 | } | ||
| 107 | |||
| 108 | /// Exact theoretical return to player for the current paytable. | ||
| 109 | pub fn theoretical_rtp(&self) -> f64 { | ||
| 110 | // For one reel line and one symbol: exactly 3, 4 or 5 of a kind. | ||
| 111 | let p3 = 5.0 / 1296.0; | ||
| 112 | let p4 = 5.0 / 7776.0; | ||
| 113 | let p5 = 1.0 / 7776.0; | ||
| 114 | |||
| 115 | let per_line: f64 = self | ||
| 116 | .payouts | ||
| 117 | .values() | ||
| 118 | .map(|payout| { | ||
| 119 | p3 * payout.three as f64 | ||
| 120 | + p4 * payout.four as f64 | ||
| 121 | + p5 * payout.five as f64 | ||
| 122 | }) | ||
| 123 | .sum(); | ||
| 124 | |||
| 125 | per_line * PAYLINES.len() as f64 | ||
| 76 | } | 126 | } |
| 77 | } | 127 | } |
| 78 | 128 | ||
| @@ -86,7 +136,7 @@ impl Game for SlotsGame { | |||
| 86 | } | 136 | } |
| 87 | 137 | ||
| 88 | fn get_description(&self) -> &'static str { | 138 | fn get_description(&self) -> &'static str { |
| 89 | "Spin the reels and match symbols to win!" | 139 | "5 reels x 3 rows, 5 paylines. Match 3, 4 or 5 symbols from the left." |
| 90 | } | 140 | } |
| 91 | 141 | ||
| 92 | fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { | 142 | fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { |
| @@ -115,32 +165,21 @@ impl Game for SlotsGame { | |||
| 115 | 165 | ||
| 116 | match action { | 166 | match action { |
| 117 | "spin" => { | 167 | "spin" => { |
| 118 | if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { | 168 | if state.phase != GamePhase::WaitingToStart |
| 169 | && state.phase != GamePhase::Playing | ||
| 170 | { | ||
| 119 | return Err(GameError::InvalidAction( | 171 | return Err(GameError::InvalidAction( |
| 120 | "Cannot spin in current phase".to_string(), | 172 | "Cannot spin in current phase".to_string(), |
| 121 | )); | 173 | )); |
| 122 | } | 174 | } |
| 123 | 175 | ||
| 124 | let reels = self.spin_reels(); | 176 | let grid = self.spin_grid(); |
| 125 | let reel_string: String = reels.iter().collect(); | 177 | let reels: String = grid.iter().collect(); |
| 126 | 178 | ||
| 127 | let mut new_data = state.data.clone(); | 179 | let mut new_data = state.data.clone(); |
| 128 | new_data.insert("reels".to_string(), reel_string.clone()); | 180 | new_data.insert("reels".to_string(), reels); |
| 129 | new_data.insert("status".to_string(), "spun".to_string()); | 181 | new_data.insert("status".to_string(), "spun".to_string()); |
| 130 | 182 | ||
| 131 | let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; | ||
| 132 | let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; | ||
| 133 | new_data.insert( | ||
| 134 | "win_type".to_string(), | ||
| 135 | if is_jackpot { | ||
| 136 | "jackpot".to_string() | ||
| 137 | } else if is_two { | ||
| 138 | "two".to_string() | ||
| 139 | } else { | ||
| 140 | "none".to_string() | ||
| 141 | }, | ||
| 142 | ); | ||
| 143 | |||
| 144 | Ok(GameState { | 183 | Ok(GameState { |
| 145 | session_id: state.session_id.clone(), | 184 | session_id: state.session_id.clone(), |
| 146 | game_type: state.game_type.clone(), | 185 | game_type: state.game_type.clone(), |
| @@ -165,24 +204,31 @@ impl Game for SlotsGame { | |||
| 165 | .data | 204 | .data |
| 166 | .get("reels") | 205 | .get("reels") |
| 167 | .ok_or(GameError::InternalError("No reels found".to_string()))?; | 206 | .ok_or(GameError::InternalError("No reels found".to_string()))?; |
| 207 | let grid: Vec<char> = reel_string.chars().collect(); | ||
| 208 | if grid.len() != REELS * ROWS { | ||
| 209 | return Err(GameError::InternalError(format!( | ||
| 210 | "Expected {} symbols, got {}", | ||
| 211 | REELS * ROWS, | ||
| 212 | grid.len() | ||
| 213 | ))); | ||
| 214 | } | ||
| 168 | 215 | ||
| 169 | let reels: Vec<char> = reel_string.chars().collect(); | 216 | let (winnings, winning_lines) = self.calculate_payout(&grid, state.bet_amount); |
| 170 | let winnings = self.calculate_payout(&reels, state.bet_amount); | 217 | let win_type = classify_win(winnings, state.bet_amount); |
| 218 | let win_lines = if winning_lines.is_empty() { | ||
| 219 | "-".to_string() | ||
| 220 | } else { | ||
| 221 | winning_lines | ||
| 222 | .iter() | ||
| 223 | .map(usize::to_string) | ||
| 224 | .collect::<Vec<_>>() | ||
| 225 | .join(",") | ||
| 226 | }; | ||
| 171 | 227 | ||
| 172 | let mut result_data = HashMap::new(); | 228 | let mut result_data = HashMap::new(); |
| 173 | result_data.insert("reels".to_string(), reel_string.clone()); | 229 | result_data.insert("reels".to_string(), reel_string.clone()); |
| 174 | result_data.insert( | 230 | result_data.insert("win_type".to_string(), win_type.to_string()); |
| 175 | "payout_multiplier".to_string(), | 231 | result_data.insert("win_lines".to_string(), win_lines); |
| 176 | if winnings > 0 && state.bet_amount > 0 { | ||
| 177 | (winnings / state.bet_amount).to_string() | ||
| 178 | } else { | ||
| 179 | "0".to_string() | ||
| 180 | }, | ||
| 181 | ); | ||
| 182 | result_data.insert( | ||
| 183 | "win_type".to_string(), | ||
| 184 | state.data.get("win_type").cloned().unwrap_or_default(), | ||
| 185 | ); | ||
| 186 | 232 | ||
| 187 | Ok(GameResult { | 233 | Ok(GameResult { |
| 188 | session_id: state.session_id.clone(), | 234 | session_id: state.session_id.clone(), |
| @@ -194,3 +240,15 @@ impl Game for SlotsGame { | |||
| 194 | }) | 240 | }) |
| 195 | } | 241 | } |
| 196 | } | 242 | } |
| 243 | |||
| 244 | fn classify_win(winnings: u64, bet: u64) -> &'static str { | ||
| 245 | if winnings == 0 { | ||
| 246 | "none" | ||
| 247 | } else if bet > 0 && winnings >= bet.saturating_mul(50) { | ||
| 248 | "mega" | ||
| 249 | } else if bet > 0 && winnings >= bet.saturating_mul(10) { | ||
| 250 | "big" | ||
| 251 | } else { | ||
| 252 | "win" | ||
| 253 | } | ||
| 254 | } | ||
