summaryrefslogtreecommitdiffstats
path: root/src/server/games/slots.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/games/slots.rs')
-rw-r--r--src/server/games/slots.rs212
1 files changed, 135 insertions, 77 deletions
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};
2use rand::Rng; 2use rand::Rng;
3use std::collections::HashMap; 3use std::collections::HashMap;
4 4
5pub const REELS: usize = 5;
6pub const ROWS: usize = 3;
7pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S'];
8
9/// Five paylines, each entry is the row used on reel 0..4.
10pub 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)]
19struct Payout {
20 three: u64,
21 four: u64,
22 five: u64,
23}
24
5pub struct SlotsGame { 25pub 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
12impl SlotsGame { 31impl 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
244fn 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}