summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 23:13:24 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 23:13:24 +0100
commitfc59ef77cda14d97d854dfcaa848307cfa512b69 (patch)
treeb0fc97390b96078e0a0538e76d7c76f7c04135f1 /src
parentv0.4.0: add first-time TLS setup and server help (diff)
downloadzagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.tar.gz
zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.zip
v1.0.0: replace slots with 5x3 reels and five paylines
Diffstat (limited to 'src')
-rw-r--r--src/client/games/manager.rs11
-rw-r--r--src/client/games/response.rs13
-rw-r--r--src/client/games/slots.rs87
-rw-r--r--src/server/core.rs17
-rw-r--r--src/server/games/slots.rs212
5 files changed, 233 insertions, 107 deletions
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 71cd28c..8436142 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -75,6 +75,7 @@ impl GameManager {
75 session.data.remove("error"); 75 session.data.remove("error");
76 session.data.remove("reels"); 76 session.data.remove("reels");
77 session.data.remove("win_type"); 77 session.data.remove("win_type");
78 session.data.remove("win_lines");
78 session.data.remove("winnings"); 79 session.data.remove("winnings");
79 Ok(()) 80 Ok(())
80 } 81 }
@@ -91,6 +92,15 @@ impl GameManager {
91 session 92 session
92 .data 93 .data
93 .insert("win_type".to_string(), result.win_type); 94 .insert("win_type".to_string(), result.win_type);
95 session.data.insert(
96 "win_lines".to_string(),
97 result
98 .win_lines
99 .iter()
100 .map(usize::to_string)
101 .collect::<Vec<_>>()
102 .join(","),
103 );
94 session 104 session
95 .data 105 .data
96 .insert("winnings".to_string(), result.winnings.to_string()); 106 .insert("winnings".to_string(), result.winnings.to_string());
@@ -107,6 +117,7 @@ impl GameManager {
107 session.phase = GamePhase::WaitingToStart; 117 session.phase = GamePhase::WaitingToStart;
108 session.data.remove("reels"); 118 session.data.remove("reels");
109 session.data.remove("win_type"); 119 session.data.remove("win_type");
120 session.data.remove("win_lines");
110 session.data.remove("winnings"); 121 session.data.remove("winnings");
111 session.data.insert("error".to_string(), message.into()); 122 session.data.insert("error".to_string(), message.into());
112 } 123 }
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
index 6948805..de22a75 100644
--- a/src/client/games/response.rs
+++ b/src/client/games/response.rs
@@ -6,6 +6,7 @@ pub struct SpinResult {
6 pub balance: i64, 6 pub balance: i64,
7 pub reels: String, 7 pub reels: String,
8 pub win_type: String, 8 pub win_type: String,
9 pub win_lines: Vec<usize>,
9} 10}
10 11
11/// Parse one server reply to `PLAY_START`. 12/// Parse one server reply to `PLAY_START`.
@@ -45,10 +46,19 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> {
45 .parse::<i64>() 46 .parse::<i64>()
46 .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; 47 .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?;
47 48
48 if parts[5].chars().count() != 3 { 49 if parts[5].chars().count() != 3 && parts[5].chars().count() != 15 {
49 return Err(format!("Invalid reels from server: {}", parts[5])); 50 return Err(format!("Invalid reels from server: {}", parts[5]));
50 } 51 }
51 52
53 let win_lines = parts
54 .get(7)
55 .map(|raw| {
56 raw.split(',')
57 .filter_map(|value| value.trim().parse::<usize>().ok())
58 .collect()
59 })
60 .unwrap_or_default();
61
52 Ok(SpinResult { 62 Ok(SpinResult {
53 session_id: parts[1].to_string(), 63 session_id: parts[1].to_string(),
54 winnings, 64 winnings,
@@ -56,5 +66,6 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> {
56 balance, 66 balance,
57 reels: parts[5].to_string(), 67 reels: parts[5].to_string(),
58 win_type: parts[6].to_string(), 68 win_type: parts[6].to_string(),
69 win_lines,
59 }) 70 })
60} 71}
diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs
index 5eed34d..39c43b2 100644
--- a/src/client/games/slots.rs
+++ b/src/client/games/slots.rs
@@ -1,8 +1,10 @@
1use super::traits::{Game, GamePhase, GameState}; 1use super::traits::{Game, GamePhase, GameState};
2 2
3/// Height of the game board. It must never depend on the spin result, or an 3/// Height of the game board. It must never depend on the spin result.
4/// in-place redraw would move to the wrong line. 4pub const SLOT_BOARD_LINES: usize = 9;
5pub const SLOT_BOARD_LINES: usize = 7; 5pub const LINES: usize = 5;
6pub const REELS: usize = 5;
7pub const ROWS: usize = 3;
6 8
7pub struct SlotsGame; 9pub struct SlotsGame;
8 10
@@ -11,17 +13,27 @@ impl SlotsGame {
11 Self 13 Self
12 } 14 }
13 15
14 fn render_reels(&self, reels: &str) -> String { 16 fn cell(chars: &[char], row: usize, col: usize) -> char {
15 let mut chars = reels.chars(); 17 if chars.len() == REELS * ROWS {
16 format!( 18 chars.get(col * ROWS + row).copied().unwrap_or('?')
17 "[ {} ] [ {} ] [ {} ]", 19 } else if chars.len() == 3 && row == 0 {
18 chars.next().unwrap_or('?'), 20 // Legacy 3-symbol server: degrade to the first row.
19 chars.next().unwrap_or('?'), 21 chars.get(col).copied().unwrap_or('?')
20 chars.next().unwrap_or('?') 22 } else {
21 ) 23 '?'
24 }
25 }
26
27 fn render_grid(&self, reels: &str) -> [String; ROWS] {
28 let chars: Vec<char> = reels.chars().collect();
29 std::array::from_fn(|row| {
30 (0..REELS)
31 .map(|col| format!("[ {} ]", Self::cell(&chars, row, col)))
32 .collect::<Vec<_>>()
33 .join(" ")
34 })
22 } 35 }
23 36
24 /// The fifth board line: current status, result, or error.
25 fn render_status(&self, state: &GameState) -> String { 37 fn render_status(&self, state: &GameState) -> String {
26 if let Some(error) = state.data.get("error") { 38 if let Some(error) = state.data.get("error") {
27 return format!("ERROR: {error}"); 39 return format!("ERROR: {error}");
@@ -35,14 +47,29 @@ impl SlotsGame {
35 .get("winnings") 47 .get("winnings")
36 .map(String::as_str) 48 .map(String::as_str)
37 .unwrap_or("0"); 49 .unwrap_or("0");
38 return match state.data.get("win_type").map(String::as_str) { 50 if winnings != "0" {
39 Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), 51 return match state.data.get("win_type").map(String::as_str) {
40 Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), 52 Some("mega") => format!("MEGA WIN! {winnings} PLN"),
41 _ => "No win.".to_string(), 53 Some("big") => format!("BIG WIN! {winnings} PLN"),
42 }; 54 _ => format!("Won {winnings} PLN"),
55 };
56 }
57 return "No win.".to_string();
43 } 58 }
44 "Press [SPACE] to spin!".to_string() 59 "Press [SPACE] to spin!".to_string()
45 } 60 }
61
62 fn render_win_lines(&self, state: &GameState) -> String {
63 if state.phase != GamePhase::Finished {
64 return String::new();
65 }
66 match state.data.get("win_lines").map(String::as_str) {
67 Some(lines) if !lines.is_empty() && lines != "-" => {
68 format!("Winning lines: {lines}")
69 }
70 _ => String::new(),
71 }
72 }
46} 73}
47 74
48impl Game for SlotsGame { 75impl Game for SlotsGame {
@@ -57,13 +84,18 @@ impl Game for SlotsGame {
57 .get("balance") 84 .get("balance")
58 .map(String::as_str) 85 .map(String::as_str)
59 .unwrap_or("?"); 86 .unwrap_or("?");
87 let grid = self.render_grid(&reels);
60 88
61 format!( 89 format!(
62 "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", 90 "=== SLOTS ===\r\nBet: {} PLN Lines: {} Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit",
63 state.bet_amount, 91 state.bet_amount,
92 LINES,
64 balance, 93 balance,
65 self.render_reels(&reels), 94 grid[0],
66 self.render_status(state) 95 grid[1],
96 grid[2],
97 self.render_status(state),
98 self.render_win_lines(state)
67 ) 99 )
68 } 100 }
69 101
@@ -74,17 +106,22 @@ impl Game for SlotsGame {
74 fn get_help(&self) -> String { 106 fn get_help(&self) -> String {
75 "SLOTS GAME\n\ 107 "SLOTS GAME\n\
76 =========\n\ 108 =========\n\
77 Match symbols to win!\n\ 109 5 reels x 3 rows, 5 paylines.\n\
110 Match 3, 4 or 5 symbols from the left on a payline.\n\
78 \n\ 111 \n\
79 Payouts:\n\ 112 Payouts (x total bet, 3/4/5 of a kind):\n\
80 Three of a kind: 2x-28x bet\n\ 113 C: 2 / 5 / 15\n\
81 Two of a kind: 1x-3x bet\n\ 114 L: 3 / 7 / 20\n\
115 O: 3 / 8 / 25\n\
116 G: 4 / 12 / 40\n\
117 D: 6 / 18 / 70\n\
118 S: 9 / 30 / 150\n\
82 \n\ 119 \n\
83 Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ 120 Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\
84 \n\ 121 \n\
85 Controls:\n\ 122 Controls:\n\
86 SPACE - Spin the reels\n\ 123 SPACE - Spin the reels\n\
87 +/- - Adjust bet amount\n\ 124 +/- - Adjust total bet\n\
88 Q - Exit the game" 125 Q - Exit the game"
89 .to_string() 126 .to_string()
90 } 127 }
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};
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}