summaryrefslogtreecommitdiffstats
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
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
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SLOTS.md74
-rw-r--r--docs/TESTING.md10
-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
-rw-r--r--tests/client_core.rs106
-rw-r--r--tests/server_core.rs33
-rw-r--r--tests/slots.rs159
11 files changed, 511 insertions, 213 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 0ab5ada..3e9d2dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.4.0" 3version = "1.0.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/SLOTS.md b/docs/SLOTS.md
new file mode 100644
index 0000000..ccc3b60
--- /dev/null
+++ b/docs/SLOTS.md
@@ -0,0 +1,74 @@
1# Slots: 5 reels x 3 rows
2
3## Grid
4
5- 5 reels (columns) x 3 rows = 15 visible symbols.
6- Six symbols, uniform: `C` cherry, `L` lemon, `O` orange, `G` grapes,
7 `D` diamond, `S` seven.
8- The bet is the **total** bet, not a per-line bet.
9- Five paylines: top, middle, bottom, V, inverted V.
10- A line pays for 3, 4 or 5 matching symbols starting on reel 1 and reading
11 left to right.
12
13```
14line 1: top row across all five reels
15line 2: middle row across all five reels
16line 3: bottom row across all five reels
17line 4: top, middle, bottom, middle, top
18line 5: bottom, middle, top, middle, bottom
19```
20
21## Paytable
22
23Multipliers are applied to the total bet.
24
25| Symbol | 3 of a kind | 4 of a kind | 5 of a kind |
26| --- | ---: | ---: | ---: |
27| C | 2x | 5x | 15x |
28| L | 3x | 7x | 20x |
29| O | 3x | 8x | 25x |
30| G | 4x | 12x | 40x |
31| D | 6x | 18x | 70x |
32| S | 9x | 30x | 150x |
33
34- Exact RTP: **425/432 = 0.983796...** (1.62% house edge).
35- Maximum win: 5 lines x 150x = **750x total bet**.
36- Chance of any win on a spin: about **13%**.
37- The RTP is checked by `SlotsGame::theoretical_rtp()` and `tests/slots.rs`.
38
39## Wire format
40
41`GAME_RESULT` keeps its existing fields and appends the winning lines:
42
43```
44GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines>
45```
46
47- `reels`: 15 characters, **column-major**: reel 0 top/middle/bottom, then
48 reel 1, and so on.
49- `win_type`: `none`, `win`, `big` (>= 10x total bet) or `mega` (>= 50x).
50- `win_lines`: 1-based comma-separated line numbers, or `-` for no win.
51- Older servers may still send a 3-character `reels` string; the client
52 degrades it to the first row.
53
54The extra `win_lines` field is additive, but the 5x3 `reels` payload is a
55breaking change for pre-5x3 clients. That is why the release is `1.0.0`.
56
57## Client board
58
59The board is nine lines:
60
61```
62=== SLOTS ===
63Bet: 10 PLN Lines: 5 Balance: 1000 PLN
64[ C ] [ L ] [ O ] [ G ] [ D ]
65[ S ] [ C ] [ L ] [ O ] [ G ]
66[ D ] [ S ] [ C ] [ L ] [ O ]
67
68Won 20 PLN
69Winning lines: 1, 3
70[+/-] Bet [SPACE] Spin [Q] Quit
71```
72
73`win_lines` is already carried through the client state so the next animation
74pass can highlight the winning rows without recomputing the payout.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 55f5422..8fac851 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -36,10 +36,12 @@ Use an 80-column terminal because several output lines are width-sensitive.
36 prompt, Ctrl+C exits and restores the terminal. 36 prompt, Ctrl+C exits and restores the terminal.
374. `help` fits 80 columns. Tab completion wraps within 76 columns and the 374. `help` fits 80 columns. Tab completion wraps within 76 columns and the
38 cursor stays visible. 38 cursor stays visible.
395. `play slots 50` shows `Bet: 50 PLN` and the fetched balance. 395. `play slots 50` shows a 5x3 grid, `Lines: 5`, and the fetched balance.
406. Space shows `Spinning...`, then the reels, win text, and new balance inside 406. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and
41 the same 7-line board. The controls line is never overwritten. 41 new balance inside the same 9-line board. The controls line is never
427. `+` / `-` after a spin changes only the bet; no stale result lines appear. 42 overwritten. Slots rules and RTP: `docs/SLOTS.md`.
437. `+` / `-` after a spin changes only the bet; no stale grid or result lines
44 appear.
438. `q` clears the board. Ctrl+C in a game clears the board and exits. 458. `q` clears the board. Ctrl+C in a game clears the board and exits.
449. `info`, `ping`, `send hello`, and `hosts` print readable output; the host 469. `info`, `ping`, `send hello`, and `hosts` print readable output; the host
45 fingerprint fits 80 columns. 47 fingerprint fits 80 columns.
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}
diff --git a/tests/client_core.rs b/tests/client_core.rs
index c298ab7..80ec587 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -5,8 +5,13 @@
5//! player would see, not about ANSI sequences. 5//! player would see, not about ANSI sequences.
6 6
7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
8use zagan_bet::client::games::response::parse_spin_response;
8use zagan_bet::client::ui::command_names; 9use zagan_bet::client::ui::command_names;
9 10
11const WIN3: &str = "CCCCLLCLODGSDGS";
12const LOSS: &str = "CLOOGDSLCDCOGLS";
13const ALL_C: &str = "CCCCCCCCCCCCCCC";
14
10#[derive(Default)] 15#[derive(Default)]
11struct Screen { 16struct Screen {
12 lines: Vec<String>, 17 lines: Vec<String>,
@@ -123,16 +128,17 @@ impl Harness {
123} 128}
124 129
125#[test] 130#[test]
126fn play_shows_a_seven_line_board_and_fetches_the_balance() { 131fn play_shows_a_nine_line_board_and_fetches_the_balance() {
127 let mut h = Harness::new(); 132 let mut h = Harness::new();
128 h.line("play slots 50"); 133 h.line("play slots 50");
129 134
130 assert_eq!(h.board().len(), 7); 135 assert_eq!(h.board().len(), 9);
131 assert!(h.board()[1].contains("Bet: 50 PLN")); 136 assert!(h.board()[1].contains("Bet: 50 PLN"));
137 assert!(h.board()[1].contains("Lines: 5"));
132 assert!(h.sent.contains(&"INFO".to_string())); 138 assert!(h.sent.contains(&"INFO".to_string()));
133 139
134 h.reply("INFO alice 1 1000"); 140 h.reply("INFO alice 1 1000");
135 assert_eq!(h.board().len(), 7); 141 assert_eq!(h.board().len(), 9);
136 assert!(h.board()[1].contains("Balance: 1000 PLN")); 142 assert!(h.board()[1].contains("Balance: 1000 PLN"));
137} 143}
138 144
@@ -143,18 +149,22 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() {
143 let lines_before_spin = h.screen.lines.len(); 149 let lines_before_spin = h.screen.lines.len();
144 150
145 h.key(Key::Char(' ')); 151 h.key(Key::Char(' '));
146 assert_eq!(h.board().len(), 7); 152 assert_eq!(h.board().len(), 9);
147 assert!(h.board()[4].contains("Spinning...")); 153 assert!(h.board()[6].contains("Spinning..."));
148 assert!(h.board()[6].contains("[Q] Quit")); 154 assert!(h.board()[8].contains("[Q] Quit"));
149 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); 155 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50"));
150 assert_eq!(h.screen.lines.len(), lines_before_spin); 156 assert_eq!(h.screen.lines.len(), lines_before_spin);
151 157
152 h.reply("GAME_RESULT session-1 60 50 1010 OSS two"); 158 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1");
153 assert_eq!(h.board().len(), 7); 159 assert_eq!(h.board().len(), 9);
154 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); 160 assert!(h
155 assert!(h.board()[4].contains("Two of a kind! Winnings: 60 PLN")); 161 .board()
156 assert!(h.board()[1].contains("Balance: 1010 PLN")); 162 .get(2)
157 assert!(h.board()[6].contains("[Q] Quit")); 163 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]")));
164 assert!(h.board()[6].contains("Won 100 PLN"));
165 assert!(h.board()[7].contains("Winning lines: 1"));
166 assert!(h.board()[1].contains("Balance: 1050 PLN"));
167 assert!(h.board()[8].contains("[Q] Quit"));
158 assert_eq!(h.screen.lines.len(), lines_before_spin); 168 assert_eq!(h.screen.lines.len(), lines_before_spin);
159} 169}
160 170
@@ -163,14 +173,17 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
163 let mut h = Harness::new(); 173 let mut h = Harness::new();
164 h.enter_slots("50"); 174 h.enter_slots("50");
165 h.key(Key::Char(' ')); 175 h.key(Key::Char(' '));
166 h.reply("GAME_RESULT session-1 60 50 1010 OSS two"); 176 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1");
167 let lines_before_bet = h.screen.lines.len(); 177 let lines_before_bet = h.screen.lines.len();
168 178
169 h.key(Key::Char('+')); 179 h.key(Key::Char('+'));
170 180
171 assert_eq!(h.board().len(), 7); 181 assert_eq!(h.board().len(), 9);
172 assert!(h.board()[1].contains("Bet: 60 PLN")); 182 assert!(h.board()[1].contains("Bet: 60 PLN"));
173 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); 183 assert!(h
184 .board()
185 .get(2)
186 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]")));
174 assert_eq!(h.screen.lines.len(), lines_before_bet); 187 assert_eq!(h.screen.lines.len(), lines_before_bet);
175 assert_eq!( 188 assert_eq!(
176 h.screen 189 h.screen
@@ -183,6 +196,32 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
183} 196}
184 197
185#[test] 198#[test]
199fn a_losing_spin_shows_no_win_and_no_winning_lines() {
200 let mut h = Harness::new();
201 h.enter_slots("10");
202 h.key(Key::Char(' '));
203
204 h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -"));
205
206 assert_eq!(h.board().len(), 9);
207 assert!(h.board()[6].contains("No win."));
208 assert_eq!(h.board()[7], "");
209}
210
211#[test]
212fn a_mega_win_shows_all_winning_lines() {
213 let mut h = Harness::new();
214 h.enter_slots("10");
215 h.key(Key::Char(' '));
216
217 h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5"));
218
219 assert_eq!(h.board().len(), 9);
220 assert!(h.board()[6].contains("MEGA WIN! 750 PLN"));
221 assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5"));
222}
223
224#[test]
186fn quitting_clears_the_board_and_returns_the_prompt() { 225fn quitting_clears_the_board_and_returns_the_prompt() {
187 let mut h = Harness::new(); 226 let mut h = Harness::new();
188 h.enter_slots("50"); 227 h.enter_slots("50");
@@ -202,8 +241,8 @@ fn a_server_error_replaces_the_status_line() {
202 241
203 h.reply("ERROR Insufficient balance"); 242 h.reply("ERROR Insufficient balance");
204 243
205 assert_eq!(h.board().len(), 7); 244 assert_eq!(h.board().len(), 9);
206 assert!(h.board()[4].contains("ERROR: Insufficient balance")); 245 assert!(h.board()[6].contains("ERROR: Insufficient balance"));
207 assert!(h.board()[2].contains("[ ? ]")); 246 assert!(h.board()[2].contains("[ ? ]"));
208 assert!(!h.board().iter().any(|line| line.contains("Spinning"))); 247 assert!(!h.board().iter().any(|line| line.contains("Spinning")));
209} 248}
@@ -216,8 +255,8 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() {
216 255
217 h.reply(""); 256 h.reply("");
218 257
219 assert_eq!(h.board().len(), 7); 258 assert_eq!(h.board().len(), 9);
220 assert!(h.board()[4].contains("No response from server")); 259 assert!(h.board()[6].contains("No response from server"));
221} 260}
222 261
223#[test] 262#[test]
@@ -301,7 +340,7 @@ fn play_without_a_bet_uses_the_default() {
301 let mut h = Harness::new(); 340 let mut h = Harness::new();
302 h.line("play slots"); 341 h.line("play slots");
303 342
304 assert_eq!(h.board().len(), 7); 343 assert_eq!(h.board().len(), 9);
305 assert!(h.board()[1].contains("Bet: 10 PLN")); 344 assert!(h.board()[1].contains("Bet: 10 PLN"));
306} 345}
307 346
@@ -475,3 +514,30 @@ fn disconnect_clears_connection_state() {
475 .iter() 514 .iter()
476 .any(|line| line.contains("Not connected"))); 515 .any(|line| line.contains("Not connected")));
477} 516}
517
518#[test]
519fn parses_a_five_by_three_result_with_winning_lines() {
520 let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1");
521 let result = parse_spin_response(&response).expect("valid 5x3 result");
522
523 assert_eq!(result.reels.chars().count(), 15);
524 assert_eq!(result.win_type, "win");
525 assert_eq!(result.win_lines, vec![1]);
526}
527
528#[test]
529fn accepts_a_legacy_three_symbol_result() {
530 let result = parse_spin_response("GAME_RESULT session-1 60 10 1050 OSS two")
531 .expect("legacy result should still parse");
532
533 assert_eq!(result.reels, "OSS");
534 assert_eq!(result.win_type, "two");
535 assert!(result.win_lines.is_empty());
536}
537
538#[test]
539fn rejects_a_grid_with_the_wrong_size() {
540 let response = "GAME_RESULT session-1 100 50 1050 CCCCCCCCCCCCCC win 1";
541
542 assert!(parse_spin_response(response).is_err());
543}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index fe2b3d3..f3736e9 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -6,7 +6,7 @@ use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry; 6use zagan_bet::server::games::registry::GameRegistry;
7use zagan_bet::server::games::slots::SlotsGame; 7use zagan_bet::server::games::slots::SlotsGame;
8 8
9fn setup(name: &str, reels: &str) -> (ServerCore, String) { 9fn setup(name: &str, grid: &str) -> (ServerCore, String) {
10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) 10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
11 .join(format!("server-core-{name}.db")) 11 .join(format!("server-core-{name}.db"))
12 .to_string_lossy() 12 .to_string_lossy()
@@ -17,12 +17,18 @@ fn setup(name: &str, reels: &str) -> (ServerCore, String) {
17 database::init_db(&conn); 17 database::init_db(&conn);
18 18
19 let registry = GameRegistry::new(); 19 let registry = GameRegistry::new();
20 registry.register_game(SlotsGame::with_reels(reels)); 20 registry.register_game(SlotsGame::with_grid(grid));
21 21
22 // Low bcrypt cost keeps the tests fast. 22 // Low bcrypt cost keeps the tests fast.
23 (ServerCore::with_registry(conn, registry, 4), path) 23 (ServerCore::with_registry(conn, registry, 4), path)
24} 24}
25 25
26/// Column-major 5x3 grid with no winning line.
27const LOSS: &str = "CLOOGDSLCDCOGLS";
28
29/// Top line is C C C D D; it pays 2x total bet.
30const WIN3: &str = "CCCCLLCLODGSDGS";
31
26fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 32fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
27 assert_eq!( 33 assert_eq!(
28 core.handle_message(state, &format!("REGISTER {user} credential")), 34 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -39,7 +45,7 @@ fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
39 45
40#[test] 46#[test]
41fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { 47fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
42 let (mut core, path) = setup("register", "CLO"); 48 let (mut core, path) = setup("register", LOSS);
43 let mut state = ConnectionState::default(); 49 let mut state = ConnectionState::default();
44 50
45 register(&mut core, &mut state, "alice"); 51 register(&mut core, &mut state, "alice");
@@ -59,7 +65,7 @@ fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
59 65
60#[test] 66#[test]
61fn the_same_credential_gets_a_different_salt() { 67fn the_same_credential_gets_a_different_salt() {
62 let (mut core, path) = setup("salt", "CLO"); 68 let (mut core, path) = setup("salt", LOSS);
63 let mut state = ConnectionState::default(); 69 let mut state = ConnectionState::default();
64 70
65 register(&mut core, &mut state, "alice"); 71 register(&mut core, &mut state, "alice");
@@ -73,7 +79,7 @@ fn the_same_credential_gets_a_different_salt() {
73 79
74#[test] 80#[test]
75fn legacy_sha256_rows_are_upgraded_on_login() { 81fn legacy_sha256_rows_are_upgraded_on_login() {
76 let (mut core, path) = setup("legacy", "CLO"); 82 let (mut core, path) = setup("legacy", LOSS);
77 let conn = Connection::open(&path).unwrap(); 83 let conn = Connection::open(&path).unwrap();
78 database::add_user(&conn, "legacy", "deadbeef").unwrap(); 84 database::add_user(&conn, "legacy", "deadbeef").unwrap();
79 85
@@ -96,7 +102,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() {
96 102
97#[test] 103#[test]
98fn duplicate_registration_returns_an_error() { 104fn duplicate_registration_returns_an_error() {
99 let (mut core, _path) = setup("duplicate", "CLO"); 105 let (mut core, _path) = setup("duplicate", LOSS);
100 let mut state = ConnectionState::default(); 106 let mut state = ConnectionState::default();
101 107
102 register(&mut core, &mut state, "alice"); 108 register(&mut core, &mut state, "alice");
@@ -108,7 +114,7 @@ fn duplicate_registration_returns_an_error() {
108 114
109#[test] 115#[test]
110fn a_malicious_username_cannot_change_another_balance() { 116fn a_malicious_username_cannot_change_another_balance() {
111 let (mut core, _path) = setup("injection", "CLO"); 117 let (mut core, _path) = setup("injection", LOSS);
112 let mut admin = ConnectionState::default(); 118 let mut admin = ConnectionState::default();
113 register(&mut core, &mut admin, "victim"); 119 register(&mut core, &mut admin, "victim");
114 120
@@ -132,7 +138,7 @@ fn a_malicious_username_cannot_change_another_balance() {
132 138
133#[test] 139#[test]
134fn a_failed_audit_insert_rolls_back_the_balance_change() { 140fn a_failed_audit_insert_rolls_back_the_balance_change() {
135 let (mut core, path) = setup("rollback", "CLO"); 141 let (mut core, path) = setup("rollback", LOSS);
136 let mut state = ConnectionState::default(); 142 let mut state = ConnectionState::default();
137 register(&mut core, &mut state, "alice"); 143 register(&mut core, &mut state, "alice");
138 login(&mut core, &mut state, "alice"); 144 login(&mut core, &mut state, "alice");
@@ -155,7 +161,7 @@ fn a_failed_audit_insert_rolls_back_the_balance_change() {
155 161
156#[test] 162#[test]
157fn spins_write_audit_rows_and_leave_no_sessions() { 163fn spins_write_audit_rows_and_leave_no_sessions() {
158 let (mut core, _path) = setup("audit", "CLO"); 164 let (mut core, _path) = setup("audit", LOSS);
159 let mut state = ConnectionState::default(); 165 let mut state = ConnectionState::default();
160 register(&mut core, &mut state, "alice"); 166 register(&mut core, &mut state, "alice");
161 login(&mut core, &mut state, "alice"); 167 login(&mut core, &mut state, "alice");
@@ -175,7 +181,7 @@ fn spins_write_audit_rows_and_leave_no_sessions() {
175 181
176#[test] 182#[test]
177fn a_winning_spin_updates_the_balance_and_the_audit_row() { 183fn a_winning_spin_updates_the_balance_and_the_audit_row() {
178 let (mut core, _path) = setup("win", "CCC"); 184 let (mut core, _path) = setup("win", WIN3);
179 let mut state = ConnectionState::default(); 185 let mut state = ConnectionState::default();
180 register(&mut core, &mut state, "alice"); 186 register(&mut core, &mut state, "alice");
181 login(&mut core, &mut state, "alice"); 187 login(&mut core, &mut state, "alice");
@@ -183,6 +189,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() {
183 let response = core.handle_message(&mut state, "PLAY_START slots 10"); 189 let response = core.handle_message(&mut state, "PLAY_START slots 10");
184 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); 190 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
185 assert!(response.contains(" 20 10 1010 "), "response: {response}"); 191 assert!(response.contains(" 20 10 1010 "), "response: {response}");
192 assert!(response.ends_with(" win 1"), "response: {response}");
186 193
187 assert_eq!(core.balance("alice"), Some(1010)); 194 assert_eq!(core.balance("alice"), Some(1010));
188 assert_eq!(core.play_count("alice"), 1); 195 assert_eq!(core.play_count("alice"), 1);
@@ -191,7 +198,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() {
191 198
192#[test] 199#[test]
193fn insufficient_balance_does_not_write_an_audit_row() { 200fn insufficient_balance_does_not_write_an_audit_row() {
194 let (mut core, path) = setup("insufficient", "CLO"); 201 let (mut core, path) = setup("insufficient", LOSS);
195 let mut state = ConnectionState::default(); 202 let mut state = ConnectionState::default();
196 register(&mut core, &mut state, "alice"); 203 register(&mut core, &mut state, "alice");
197 login(&mut core, &mut state, "alice"); 204 login(&mut core, &mut state, "alice");
@@ -211,7 +218,7 @@ fn insufficient_balance_does_not_write_an_audit_row() {
211 218
212#[test] 219#[test]
213fn a_negative_balance_is_rejected_without_wrapping() { 220fn a_negative_balance_is_rejected_without_wrapping() {
214 let (mut core, path) = setup("negative", "CLO"); 221 let (mut core, path) = setup("negative", LOSS);
215 let mut state = ConnectionState::default(); 222 let mut state = ConnectionState::default();
216 register(&mut core, &mut state, "alice"); 223 register(&mut core, &mut state, "alice");
217 login(&mut core, &mut state, "alice"); 224 login(&mut core, &mut state, "alice");
@@ -233,7 +240,7 @@ fn a_negative_balance_is_rejected_without_wrapping() {
233 240
234#[test] 241#[test]
235fn unknown_games_are_rejected_before_a_session_is_created() { 242fn unknown_games_are_rejected_before_a_session_is_created() {
236 let (mut core, _path) = setup("unknown", "CLO"); 243 let (mut core, _path) = setup("unknown", LOSS);
237 let mut state = ConnectionState::default(); 244 let mut state = ConnectionState::default();
238 register(&mut core, &mut state, "alice"); 245 register(&mut core, &mut state, "alice");
239 login(&mut core, &mut state, "alice"); 246 login(&mut core, &mut state, "alice");
diff --git a/tests/slots.rs b/tests/slots.rs
index 3035800..5fb751a 100644
--- a/tests/slots.rs
+++ b/tests/slots.rs
@@ -1,99 +1,122 @@
1//! Regression tests for the server-side slots game logic. 1//! Regression tests for the server-side 5x3 slots game.
2 2
3use zagan_bet::server::games::slots::SlotsGame; 3use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS};
4use zagan_bet::server::games::traits::{Game, GamePhase}; 4use zagan_bet::server::games::traits::{Game, GamePhase};
5 5
6fn make_game() -> SlotsGame { 6/// Convert row-major test rows into the column-major wire format.
7 SlotsGame::new() 7fn grid(rows: [&str; 3]) -> String {
8 let cells: Vec<Vec<char>> = rows.iter().map(|row| row.chars().collect()).collect();
9 let mut result = String::new();
10 for col in 0..REELS {
11 for row in 0..ROWS {
12 result.push(cells[row][col]);
13 }
14 }
15 result
16}
17
18fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec<usize>) {
19 let reels = grid(rows);
20 let game = SlotsGame::with_grid(&reels);
21 let cells: Vec<char> = reels.chars().collect();
22 game.calculate_payout(&cells, bet)
23}
24
25#[test]
26fn the_wire_grid_is_five_reels_by_three_rows() {
27 assert_eq!(REELS, 5);
28 assert_eq!(ROWS, 3);
29 assert_eq!(PAYLINES.len(), 5);
30
31 let game = SlotsGame::new();
32 let state = game.prepare_state("alice", 10);
33 let state = game.process_action(&state, "spin", &[]).unwrap();
34 let reels = state.data.get("reels").unwrap();
35 assert_eq!(reels.chars().count(), REELS * ROWS);
8} 36}
9 37
10/// Three of a kind pays the three-of-a-kind multiplier.
11#[test] 38#[test]
12fn three_of_a_kind_pays() { 39fn three_of_a_kind_on_the_top_line_pays() {
13 let game = make_game(); 40 let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10);
14 // CCC = 2x 41
15 assert_eq!(game.calculate_payout(&['C', 'C', 'C'], 100), 200); 42 assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet");
16 // SSS = 28x 43 assert_eq!(lines, vec![1]);
17 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], 10), 280);
18} 44}
19 45
20/// Two adjacent matching symbols pays the two-of-a-kind multiplier.
21#[test] 46#[test]
22fn two_adjacent_pays() { 47fn four_of_a_kind_pays_more_than_three() {
23 let game = make_game(); 48 let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10);
24 // CC? = 1x 49
25 assert_eq!(game.calculate_payout(&['C', 'C', 'D'], 100), 100); 50 assert_eq!(winnings, 50, "CCCC pays 5x total bet");
26 // ?LL = 1x 51 assert_eq!(lines, vec![1]);
27 assert_eq!(game.calculate_payout(&['D', 'L', 'L'], 50), 50);
28} 52}
29 53
30/// Non-adjacent matching symbols (e.g. [C,D,C]) must also pay.
31/// This was a bug: the old code only checked pairs (0,1) and (1,2).
32#[test] 54#[test]
33fn two_non_adjacent_pays() { 55fn five_of_a_kind_pays_all_lines() {
34 let game = make_game(); 56 let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10);
35 // C,D,C — two cherries at positions 0 and 2 57
36 assert_eq!(game.calculate_payout(&['C', 'D', 'C'], 100), 100); 58 assert_eq!(winnings, 7500, "five SSSSS lines at 150x each");
37 // S,X,S — two sevens at positions 0 and 2 59 assert_eq!(lines, vec![1, 2, 3, 4, 5]);
38 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], 10), 30);
39 // L,O,L
40 assert_eq!(game.calculate_payout(&['L', 'O', 'L'], 50), 50);
41} 60}
42 61
43/// No matching symbols returns 0.
44#[test] 62#[test]
45fn no_match_returns_zero() { 63fn the_v_payline_is_evaluated() {
46 let game = make_game(); 64 let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10);
47 assert_eq!(game.calculate_payout(&['C', 'L', 'O'], 100), 0); 65
48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); 66 assert_eq!(winnings, 150, "V line with five C symbols pays 15x");
67 assert_eq!(lines, vec![4]);
49} 68}
50 69
51#[test] 70#[test]
52fn an_enormous_bet_saturates_instead_of_overflowing() { 71fn no_matching_line_pays_nothing() {
53 let game = make_game(); 72 let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100);
54 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX); 73
55 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX); 74 assert_eq!(winnings, 0);
75 assert!(lines.is_empty());
56} 76}
57 77
58/// process_action produces a finished state with correct reels and win_type.
59#[test] 78#[test]
60fn process_action_spin_sets_finished() { 79fn winning_lines_are_added_together() {
61 let game = make_game(); 80 let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10);
62 let state = game.prepare_state("alice", 100); 81
63 assert_eq!(state.phase, GamePhase::WaitingToStart); 82 assert_eq!(winnings, (150 + 20 + 25) * 10);
64 83 assert_eq!(lines, vec![1, 2, 3]);
65 // Manually set reels by going through process_action. 84}
66 // Since spin is random, we test the state machine rather than the outcome. 85
67 let result = game.process_action(&state, "spin", &[]); 86#[test]
68 assert!(result.is_ok()); 87fn theoretical_rtp_is_just_under_one() {
69 let new_state = result.unwrap(); 88 let rtp = SlotsGame::new().theoretical_rtp();
70 assert_eq!(new_state.phase, GamePhase::Finished); 89
71 assert!(new_state.data.contains_key("reels")); 90 assert!((rtp - 425.0 / 432.0).abs() < 1e-12);
72 assert!(new_state.data.contains_key("win_type")); 91 assert!(rtp < 1.0, "house edge must be positive");
92 assert!(rtp > 0.98, "house edge should be tiny");
93}
94
95#[test]
96fn an_enormous_bet_saturates_instead_of_overflowing() {
97 let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX);
98
99 assert_eq!(winnings, u64::MAX);
73} 100}
74 101
75/// calculate_result on a finished state returns valid GameResult.
76#[test] 102#[test]
77fn calculate_result_on_finished_state() { 103fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() {
78 let game = make_game(); 104 let game = SlotsGame::with_grid(&grid(["CCCDD", "CLLGG", "CLOSS"]));
79 let mut state = game.prepare_state("alice", 100); 105 let state = game.prepare_state("alice", 10);
80 state.phase = GamePhase::Finished; 106
81 state 107 let state = game.process_action(&state, "spin", &[]).unwrap();
82 .data 108 assert_eq!(state.phase, GamePhase::Finished);
83 .insert("reels".to_string(), "CCD".to_string());
84 state
85 .data
86 .insert("win_type".to_string(), "two".to_string());
87 109
88 let result = game.calculate_result(&state).unwrap(); 110 let result = game.calculate_result(&state).unwrap();
89 assert_eq!(result.bet_amount, 100); 111 assert_eq!(result.winnings, 20);
90 assert_eq!(result.winnings, 100); // CC = 1x 112 assert_eq!(result.result_data.get("win_type").unwrap(), "win");
113 assert_eq!(result.result_data.get("win_lines").unwrap(), "1");
91} 114}
92 115
93/// calculate_result on a non-finished state returns an error.
94#[test] 116#[test]
95fn calculate_result_on_unfinished_state() { 117fn calculate_result_rejects_an_unfinished_state() {
96 let game = make_game(); 118 let game = SlotsGame::new();
97 let state = game.prepare_state("alice", 100); 119 let state = game.prepare_state("alice", 10);
120
98 assert!(game.calculate_result(&state).is_err()); 121 assert!(game.calculate_result(&state).is_err());
99} 122}