summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 23:19:52 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 23:19:52 +0100
commit2915d826f744612e0d975282f5bddf13270b9f3f (patch)
treea2864bfaef91070ac3b4e13b8b731e539a757cc8
parentv1.0.0: replace slots with 5x3 reels and five paylines (diff)
downloadzagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.tar.gz
zagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.zip
v1.1.0: highlight winning symbols on the slots board
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SLOTS.md26
-rw-r--r--docs/TESTING.md5
-rw-r--r--src/bin/client/repl.rs31
-rw-r--r--src/client/games/manager.rs11
-rw-r--r--src/client/games/response.rs18
-rw-r--r--src/client/games/slots.rs29
-rw-r--r--src/server/core.rs7
-rw-r--r--src/server/games/slots.rs49
-rw-r--r--tests/client_core.rs16
-rw-r--r--tests/server_core.rs2
-rw-r--r--tests/slots.rs43
12 files changed, 186 insertions, 53 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 3e9d2dd..7de7c4f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "1.0.0" 3version = "1.1.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/SLOTS.md b/docs/SLOTS.md
index ccc3b60..a64bdbf 100644
--- a/docs/SLOTS.md
+++ b/docs/SLOTS.md
@@ -38,21 +38,27 @@ Multipliers are applied to the total bet.
38 38
39## Wire format 39## Wire format
40 40
41`GAME_RESULT` keeps its existing fields and appends the winning lines: 41`GAME_RESULT` keeps its existing fields and appends the winning lines and cells:
42 42
43``` 43```
44GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> 44GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells>
45``` 45```
46 46
47- `reels`: 15 characters, **column-major**: reel 0 top/middle/bottom, then 47- `reels`: 15 characters, **column-major**: reel 0 top/middle/bottom, then
48 reel 1, and so on. 48 reel 1, and so on.
49- `win_type`: `none`, `win`, `big` (>= 10x total bet) or `mega` (>= 50x). 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. 50- `win_lines`: 1-based comma-separated line numbers, or `-` for no win.
51- `win_cells`: 0-based column-major cell indices to highlight, or `-` for no
52 win. Example: `0,3,6` is the first three cells of the top row.
51- Older servers may still send a 3-character `reels` string; the client 53- Older servers may still send a 3-character `reels` string; the client
52 degrades it to the first row. 54 degrades it to the first row.
55- Older 1.0 servers send only `win_lines`; the 1.1 client simply shows no
56 highlights against them.
53 57
54The extra `win_lines` field is additive, but the 5x3 `reels` payload is a 58The extra `win_lines`/`win_cells` fields are additive. The 5x3 `reels` payload
55breaking change for pre-5x3 clients. That is why the release is `1.0.0`. 59is a breaking change for pre-5x3 clients, hence `1.0.0`; highlighting is the
60backward-compatible `1.1.0` release. The client never re-derives paylines: the
61server sends exactly which cells won.
56 62
57## Client board 63## Client board
58 64
@@ -62,13 +68,15 @@ The board is nine lines:
62=== SLOTS === 68=== SLOTS ===
63Bet: 10 PLN Lines: 5 Balance: 1000 PLN 69Bet: 10 PLN Lines: 5 Balance: 1000 PLN
64[ C ] [ L ] [ O ] [ G ] [ D ] 70[ C ] [ L ] [ O ] [ G ] [ D ]
65[ S ] [ C ] [ L ] [ O ] [ G ] 71[ S ] { C } { C } [ O ] [ G ]
66[ D ] [ S ] [ C ] [ L ] [ O ] 72[ D ] [ S ] { C } [ L ] [ O ]
67 73
68Won 20 PLN 74Won 20 PLN
69Winning lines: 1, 3 75Winning lines: 2
70[+/-] Bet [SPACE] Spin [Q] Quit 76[+/-] Bet [SPACE] Spin [Q] Quit
71``` 77```
72 78
73`win_lines` is already carried through the client state so the next animation 79Winning cells use braces and are rendered yellow/bold in the terminal. The
74pass can highlight the winning rows without recomputing the payout. 80line list below the status names the paylines that won. Both come from the
81server's `win_lines` and `win_cells`, which are already carried through the
82client state for the animation pass.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 8fac851..8e35c5a 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -38,8 +38,9 @@ Use an 80-column terminal because several output lines are width-sensitive.
38 cursor stays visible. 38 cursor stays visible.
395. `play slots 50` shows a 5x3 grid, `Lines: 5`, and the fetched balance. 395. `play slots 50` shows a 5x3 grid, `Lines: 5`, and the fetched balance.
406. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and 406. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and
41 new balance inside the same 9-line board. The controls line is never 41 new balance inside the same 9-line board. Winning symbols appear as
42 overwritten. Slots rules and RTP: `docs/SLOTS.md`. 42 yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls
43 line is never overwritten. Slots rules and RTP: `docs/SLOTS.md`.
437. `+` / `-` after a spin changes only the bet; no stale grid or result lines 447. `+` / `-` after a spin changes only the bet; no stale grid or result lines
44 appear. 45 appear.
458. `q` clears the board. Ctrl+C in a game clears the board and exits. 468. `q` clears the board. Ctrl+C in a game clears the board and exits.
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 033fb1a..58cb541 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -240,8 +240,12 @@ impl ClientState {
240 .map(|line| { 240 .map(|line| {
241 if line.starts_with("===") { 241 if line.starts_with("===") {
242 line.as_str().bold().cyan().to_string() 242 line.as_str().bold().cyan().to_string()
243 } else if line.contains("JACKPOT") || line.contains("Two of a kind") { 243 } else if line.contains('{') {
244 style_winning_cells(line)
245 } else if line.contains("WIN!") || line.starts_with("Won ") {
244 line.as_str().bold().green().to_string() 246 line.as_str().bold().green().to_string()
247 } else if line.starts_with("Winning lines:") {
248 line.as_str().yellow().to_string()
245 } else if line.starts_with("ERROR:") { 249 } else if line.starts_with("ERROR:") {
246 line.as_str().red().to_string() 250 line.as_str().red().to_string()
247 } else { 251 } else {
@@ -436,6 +440,31 @@ impl ClientState {
436 } 440 }
437} 441}
438 442
443fn style_winning_cells(line: &str) -> String {
444 let chars: Vec<char> = line.chars().collect();
445 let mut out = String::new();
446 let mut index = 0;
447
448 while index < chars.len() {
449 if chars[index] == '{' {
450 let start = index;
451 while index < chars.len() && chars[index] != '}' {
452 index += 1;
453 }
454 if index < chars.len() {
455 index += 1;
456 }
457 let cell: String = chars[start..index].iter().collect();
458 out.push_str(&cell.as_str().bold().yellow().to_string());
459 } else {
460 out.push(chars[index]);
461 index += 1;
462 }
463 }
464
465 out
466}
467
439fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> { 468fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> {
440 if key.modifiers.contains(KeyModifiers::CONTROL) { 469 if key.modifiers.contains(KeyModifiers::CONTROL) {
441 match key.code { 470 match key.code {
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 8436142..168c49d 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -76,6 +76,7 @@ impl GameManager {
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("win_lines");
79 session.data.remove("win_cells");
79 session.data.remove("winnings"); 80 session.data.remove("winnings");
80 Ok(()) 81 Ok(())
81 } 82 }
@@ -101,6 +102,15 @@ impl GameManager {
101 .collect::<Vec<_>>() 102 .collect::<Vec<_>>()
102 .join(","), 103 .join(","),
103 ); 104 );
105 session.data.insert(
106 "win_cells".to_string(),
107 result
108 .win_cells
109 .iter()
110 .map(usize::to_string)
111 .collect::<Vec<_>>()
112 .join(","),
113 );
104 session 114 session
105 .data 115 .data
106 .insert("winnings".to_string(), result.winnings.to_string()); 116 .insert("winnings".to_string(), result.winnings.to_string());
@@ -118,6 +128,7 @@ impl GameManager {
118 session.data.remove("reels"); 128 session.data.remove("reels");
119 session.data.remove("win_type"); 129 session.data.remove("win_type");
120 session.data.remove("win_lines"); 130 session.data.remove("win_lines");
131 session.data.remove("win_cells");
121 session.data.remove("winnings"); 132 session.data.remove("winnings");
122 session.data.insert("error".to_string(), message.into()); 133 session.data.insert("error".to_string(), message.into());
123 } 134 }
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
index de22a75..d7f9d45 100644
--- a/src/client/games/response.rs
+++ b/src/client/games/response.rs
@@ -7,6 +7,7 @@ pub struct SpinResult {
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 pub win_lines: Vec<usize>,
10 pub win_cells: Vec<usize>,
10} 11}
11 12
12/// Parse one server reply to `PLAY_START`. 13/// Parse one server reply to `PLAY_START`.
@@ -52,11 +53,11 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> {
52 53
53 let win_lines = parts 54 let win_lines = parts
54 .get(7) 55 .get(7)
55 .map(|raw| { 56 .map(|raw| parse_indices(raw))
56 raw.split(',') 57 .unwrap_or_default();
57 .filter_map(|value| value.trim().parse::<usize>().ok()) 58 let win_cells = parts
58 .collect() 59 .get(8)
59 }) 60 .map(|raw| parse_indices(raw))
60 .unwrap_or_default(); 61 .unwrap_or_default();
61 62
62 Ok(SpinResult { 63 Ok(SpinResult {
@@ -67,5 +68,12 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> {
67 reels: parts[5].to_string(), 68 reels: parts[5].to_string(),
68 win_type: parts[6].to_string(), 69 win_type: parts[6].to_string(),
69 win_lines, 70 win_lines,
71 win_cells,
70 }) 72 })
71} 73}
74
75fn parse_indices(raw: &str) -> Vec<usize> {
76 raw.split(',')
77 .filter_map(|value| value.trim().parse::<usize>().ok())
78 .collect()
79}
diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs
index 39c43b2..c367fcb 100644
--- a/src/client/games/slots.rs
+++ b/src/client/games/slots.rs
@@ -13,22 +13,40 @@ impl SlotsGame {
13 Self 13 Self
14 } 14 }
15 15
16 fn cell(chars: &[char], row: usize, col: usize) -> char { 16 fn cell(chars: &[char], row: usize, col: usize, winning: &[usize]) -> String {
17 if chars.len() == REELS * ROWS { 17 let symbol = if chars.len() == REELS * ROWS {
18 chars.get(col * ROWS + row).copied().unwrap_or('?') 18 chars.get(col * ROWS + row).copied().unwrap_or('?')
19 } else if chars.len() == 3 && row == 0 { 19 } else if chars.len() == 3 && row == 0 {
20 // Legacy 3-symbol server: degrade to the first row. 20 // Legacy 3-symbol server: degrade to the first row.
21 chars.get(col).copied().unwrap_or('?') 21 chars.get(col).copied().unwrap_or('?')
22 } else { 22 } else {
23 '?' 23 '?'
24 };
25
26 if winning.contains(&(col * ROWS + row)) {
27 format!("{{ {} }}", symbol)
28 } else {
29 format!("[ {} ]", symbol)
24 } 30 }
25 } 31 }
26 32
27 fn render_grid(&self, reels: &str) -> [String; ROWS] { 33 fn highlighted_cells(state: &GameState) -> Vec<usize> {
34 state
35 .data
36 .get("win_cells")
37 .map(|raw| {
38 raw.split(',')
39 .filter_map(|value| value.trim().parse::<usize>().ok())
40 .collect()
41 })
42 .unwrap_or_default()
43 }
44
45 fn render_grid(&self, reels: &str, winning: &[usize]) -> [String; ROWS] {
28 let chars: Vec<char> = reels.chars().collect(); 46 let chars: Vec<char> = reels.chars().collect();
29 std::array::from_fn(|row| { 47 std::array::from_fn(|row| {
30 (0..REELS) 48 (0..REELS)
31 .map(|col| format!("[ {} ]", Self::cell(&chars, row, col))) 49 .map(|col| Self::cell(&chars, row, col, winning))
32 .collect::<Vec<_>>() 50 .collect::<Vec<_>>()
33 .join(" ") 51 .join(" ")
34 }) 52 })
@@ -84,7 +102,8 @@ impl Game for SlotsGame {
84 .get("balance") 102 .get("balance")
85 .map(String::as_str) 103 .map(String::as_str)
86 .unwrap_or("?"); 104 .unwrap_or("?");
87 let grid = self.render_grid(&reels); 105 let winning = Self::highlighted_cells(state);
106 let grid = self.render_grid(&reels, &winning);
88 107
89 format!( 108 format!(
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", 109 "=== 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",
diff --git a/src/server/core.rs b/src/server/core.rs
index 33cafbf..350fe18 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -254,6 +254,11 @@ impl ServerCore {
254 .get("win_lines") 254 .get("win_lines")
255 .cloned() 255 .cloned()
256 .unwrap_or_else(|| "-".to_string()); 256 .unwrap_or_else(|| "-".to_string());
257 let win_cells = result
258 .result_data
259 .get("win_cells")
260 .cloned()
261 .unwrap_or_else(|| "-".to_string());
257 262
258 match database::apply_spin( 263 match database::apply_spin(
259 &mut self.conn, 264 &mut self.conn,
@@ -268,7 +273,7 @@ impl ServerCore {
268 Ok(new_balance) => { 273 Ok(new_balance) => {
269 let _ = self.sessions.remove_session(&session_id); 274 let _ = self.sessions.remove_session(&session_id);
270 format!( 275 format!(
271 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines}" 276 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}"
272 ) 277 )
273 } 278 }
274 Err(error) => { 279 Err(error) => {
diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs
index 2ef1b46..1d71c9d 100644
--- a/src/server/games/slots.rs
+++ b/src/server/games/slots.rs
@@ -22,6 +22,13 @@ struct Payout {
22 five: u64, 22 five: u64,
23} 23}
24 24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LineWin {
27 pub line: usize,
28 /// Column-major cell indices of the matched symbols.
29 pub cells: Vec<usize>,
30}
31
25pub struct SlotsGame { 32pub struct SlotsGame {
26 payouts: HashMap<char, Payout>, 33 payouts: HashMap<char, Payout>,
27 /// Deterministic grid for tests and demos, in column-major order. 34 /// Deterministic grid for tests and demos, in column-major order.
@@ -65,10 +72,11 @@ impl SlotsGame {
65 .collect() 72 .collect()
66 } 73 }
67 74
68 /// Evaluate one spin. Returns the total win and the 1-based winning lines. 75 /// Evaluate one spin. Returns the total win and the winning lines with
69 pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec<usize>) { 76 /// the column-major cells that should be highlighted.
77 pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec<LineWin>) {
70 let mut total = 0u64; 78 let mut total = 0u64;
71 let mut winning_lines = Vec::new(); 79 let mut wins = Vec::new();
72 80
73 for (index, pattern) in PAYLINES.iter().enumerate() { 81 for (index, pattern) in PAYLINES.iter().enumerate() {
74 let Some(&first) = grid.get(pattern[0]) else { 82 let Some(&first) = grid.get(pattern[0]) else {
@@ -99,10 +107,19 @@ impl SlotsGame {
99 }; 107 };
100 108
101 total = total.saturating_add(bet.saturating_mul(multiplier)); 109 total = total.saturating_add(bet.saturating_mul(multiplier));
102 winning_lines.push(index + 1); 110 let cells = pattern
111 .iter()
112 .enumerate()
113 .take(run)
114 .map(|(col, row)| col * ROWS + row)
115 .collect();
116 wins.push(LineWin {
117 line: index + 1,
118 cells,
119 });
103 } 120 }
104 121
105 (total, winning_lines) 122 (total, wins)
106 } 123 }
107 124
108 /// Exact theoretical return to player for the current paytable. 125 /// Exact theoretical return to player for the current paytable.
@@ -213,12 +230,27 @@ impl Game for SlotsGame {
213 ))); 230 )));
214 } 231 }
215 232
216 let (winnings, winning_lines) = self.calculate_payout(&grid, state.bet_amount); 233 let (winnings, wins) = self.calculate_payout(&grid, state.bet_amount);
217 let win_type = classify_win(winnings, state.bet_amount); 234 let win_type = classify_win(winnings, state.bet_amount);
218 let win_lines = if winning_lines.is_empty() { 235 let win_lines = if wins.is_empty() {
236 "-".to_string()
237 } else {
238 wins.iter()
239 .map(|win| win.line.to_string())
240 .collect::<Vec<_>>()
241 .join(",")
242 };
243
244 let mut cells: Vec<usize> = wins
245 .iter()
246 .flat_map(|win| win.cells.iter().copied())
247 .collect();
248 cells.sort_unstable();
249 cells.dedup();
250 let win_cells = if cells.is_empty() {
219 "-".to_string() 251 "-".to_string()
220 } else { 252 } else {
221 winning_lines 253 cells
222 .iter() 254 .iter()
223 .map(usize::to_string) 255 .map(usize::to_string)
224 .collect::<Vec<_>>() 256 .collect::<Vec<_>>()
@@ -229,6 +261,7 @@ impl Game for SlotsGame {
229 result_data.insert("reels".to_string(), reel_string.clone()); 261 result_data.insert("reels".to_string(), reel_string.clone());
230 result_data.insert("win_type".to_string(), win_type.to_string()); 262 result_data.insert("win_type".to_string(), win_type.to_string());
231 result_data.insert("win_lines".to_string(), win_lines); 263 result_data.insert("win_lines".to_string(), win_lines);
264 result_data.insert("win_cells".to_string(), win_cells);
232 265
233 Ok(GameResult { 266 Ok(GameResult {
234 session_id: state.session_id.clone(), 267 session_id: state.session_id.clone(),
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 80ec587..91ddcab 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -155,12 +155,12 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() {
155 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); 155 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50"));
156 assert_eq!(h.screen.lines.len(), lines_before_spin); 156 assert_eq!(h.screen.lines.len(), lines_before_spin);
157 157
158 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1"); 158 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6");
159 assert_eq!(h.board().len(), 9); 159 assert_eq!(h.board().len(), 9);
160 assert!(h 160 assert!(h
161 .board() 161 .board()
162 .get(2) 162 .get(2)
163 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]"))); 163 .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]")));
164 assert!(h.board()[6].contains("Won 100 PLN")); 164 assert!(h.board()[6].contains("Won 100 PLN"));
165 assert!(h.board()[7].contains("Winning lines: 1")); 165 assert!(h.board()[7].contains("Winning lines: 1"));
166 assert!(h.board()[1].contains("Balance: 1050 PLN")); 166 assert!(h.board()[1].contains("Balance: 1050 PLN"));
@@ -173,7 +173,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
173 let mut h = Harness::new(); 173 let mut h = Harness::new();
174 h.enter_slots("50"); 174 h.enter_slots("50");
175 h.key(Key::Char(' ')); 175 h.key(Key::Char(' '));
176 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1"); 176 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6");
177 let lines_before_bet = h.screen.lines.len(); 177 let lines_before_bet = h.screen.lines.len();
178 178
179 h.key(Key::Char('+')); 179 h.key(Key::Char('+'));
@@ -183,7 +183,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
183 assert!(h 183 assert!(h
184 .board() 184 .board()
185 .get(2) 185 .get(2)
186 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]"))); 186 .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]")));
187 assert_eq!(h.screen.lines.len(), lines_before_bet); 187 assert_eq!(h.screen.lines.len(), lines_before_bet);
188 assert_eq!( 188 assert_eq!(
189 h.screen 189 h.screen
@@ -206,6 +206,7 @@ fn a_losing_spin_shows_no_win_and_no_winning_lines() {
206 assert_eq!(h.board().len(), 9); 206 assert_eq!(h.board().len(), 9);
207 assert!(h.board()[6].contains("No win.")); 207 assert!(h.board()[6].contains("No win."));
208 assert_eq!(h.board()[7], ""); 208 assert_eq!(h.board()[7], "");
209 assert!(!h.board()[2].contains('{'));
209} 210}
210 211
211#[test] 212#[test]
@@ -214,11 +215,12 @@ fn a_mega_win_shows_all_winning_lines() {
214 h.enter_slots("10"); 215 h.enter_slots("10");
215 h.key(Key::Char(' ')); 216 h.key(Key::Char(' '));
216 217
217 h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5")); 218 h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14"));
218 219
219 assert_eq!(h.board().len(), 9); 220 assert_eq!(h.board().len(), 9);
220 assert!(h.board()[6].contains("MEGA WIN! 750 PLN")); 221 assert!(h.board()[6].contains("MEGA WIN! 750 PLN"));
221 assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5")); 222 assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5"));
223 assert_eq!(h.board()[2].matches("{ C }").count(), 5);
222} 224}
223 225
224#[test] 226#[test]
@@ -517,12 +519,13 @@ fn disconnect_clears_connection_state() {
517 519
518#[test] 520#[test]
519fn parses_a_five_by_three_result_with_winning_lines() { 521fn parses_a_five_by_three_result_with_winning_lines() {
520 let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1"); 522 let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1 0,3,6");
521 let result = parse_spin_response(&response).expect("valid 5x3 result"); 523 let result = parse_spin_response(&response).expect("valid 5x3 result");
522 524
523 assert_eq!(result.reels.chars().count(), 15); 525 assert_eq!(result.reels.chars().count(), 15);
524 assert_eq!(result.win_type, "win"); 526 assert_eq!(result.win_type, "win");
525 assert_eq!(result.win_lines, vec![1]); 527 assert_eq!(result.win_lines, vec![1]);
528 assert_eq!(result.win_cells, vec![0, 3, 6]);
526} 529}
527 530
528#[test] 531#[test]
@@ -533,6 +536,7 @@ fn accepts_a_legacy_three_symbol_result() {
533 assert_eq!(result.reels, "OSS"); 536 assert_eq!(result.reels, "OSS");
534 assert_eq!(result.win_type, "two"); 537 assert_eq!(result.win_type, "two");
535 assert!(result.win_lines.is_empty()); 538 assert!(result.win_lines.is_empty());
539 assert!(result.win_cells.is_empty());
536} 540}
537 541
538#[test] 542#[test]
diff --git a/tests/server_core.rs b/tests/server_core.rs
index f3736e9..5c3c316 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -189,7 +189,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() {
189 let response = core.handle_message(&mut state, "PLAY_START slots 10"); 189 let response = core.handle_message(&mut state, "PLAY_START slots 10");
190 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); 190 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
191 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}"); 192 assert!(response.ends_with(" win 1 0,3,6"), "response: {response}");
193 193
194 assert_eq!(core.balance("alice"), Some(1010)); 194 assert_eq!(core.balance("alice"), Some(1010));
195 assert_eq!(core.play_count("alice"), 1); 195 assert_eq!(core.play_count("alice"), 1);
diff --git a/tests/slots.rs b/tests/slots.rs
index 5fb751a..857e607 100644
--- a/tests/slots.rs
+++ b/tests/slots.rs
@@ -1,6 +1,6 @@
1//! Regression tests for the server-side 5x3 slots game. 1//! Regression tests for the server-side 5x3 slots game.
2 2
3use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; 3use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS};
4use zagan_bet::server::games::traits::{Game, GamePhase}; 4use zagan_bet::server::games::traits::{Game, GamePhase};
5 5
6/// Convert row-major test rows into the column-major wire format. 6/// Convert row-major test rows into the column-major wire format.
@@ -15,13 +15,17 @@ fn grid(rows: [&str; 3]) -> String {
15 result 15 result
16} 16}
17 17
18fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec<usize>) { 18fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec<LineWin>) {
19 let reels = grid(rows); 19 let reels = grid(rows);
20 let game = SlotsGame::with_grid(&reels); 20 let game = SlotsGame::with_grid(&reels);
21 let cells: Vec<char> = reels.chars().collect(); 21 let cells: Vec<char> = reels.chars().collect();
22 game.calculate_payout(&cells, bet) 22 game.calculate_payout(&cells, bet)
23} 23}
24 24
25fn lines(wins: &[LineWin]) -> Vec<usize> {
26 wins.iter().map(|win| win.line).collect()
27}
28
25#[test] 29#[test]
26fn the_wire_grid_is_five_reels_by_three_rows() { 30fn the_wire_grid_is_five_reels_by_three_rows() {
27 assert_eq!(REELS, 5); 31 assert_eq!(REELS, 5);
@@ -37,50 +41,60 @@ fn the_wire_grid_is_five_reels_by_three_rows() {
37 41
38#[test] 42#[test]
39fn three_of_a_kind_on_the_top_line_pays() { 43fn three_of_a_kind_on_the_top_line_pays() {
40 let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); 44 let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10);
41 45
42 assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); 46 assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet");
43 assert_eq!(lines, vec![1]); 47 assert_eq!(lines(&wins), vec![1]);
48 assert_eq!(wins[0].cells, vec![0, 3, 6]);
44} 49}
45 50
46#[test] 51#[test]
47fn four_of_a_kind_pays_more_than_three() { 52fn four_of_a_kind_pays_more_than_three() {
48 let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); 53 let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10);
49 54
50 assert_eq!(winnings, 50, "CCCC pays 5x total bet"); 55 assert_eq!(winnings, 50, "CCCC pays 5x total bet");
51 assert_eq!(lines, vec![1]); 56 assert_eq!(lines(&wins), vec![1]);
57 assert_eq!(wins[0].cells, vec![0, 3, 6, 9]);
52} 58}
53 59
54#[test] 60#[test]
55fn five_of_a_kind_pays_all_lines() { 61fn five_of_a_kind_pays_all_lines() {
56 let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); 62 let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10);
57 63
58 assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); 64 assert_eq!(winnings, 7500, "five SSSSS lines at 150x each");
59 assert_eq!(lines, vec![1, 2, 3, 4, 5]); 65 assert_eq!(lines(&wins), vec![1, 2, 3, 4, 5]);
66 assert_eq!(wins[0].cells, vec![0, 3, 6, 9, 12]);
67 assert_eq!(wins[3].cells, vec![0, 4, 8, 10, 12]);
68 assert_eq!(wins[4].cells, vec![2, 4, 6, 10, 14]);
60} 69}
61 70
62#[test] 71#[test]
63fn the_v_payline_is_evaluated() { 72fn the_v_payline_is_evaluated() {
64 let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10); 73 let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10);
65 74
66 assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); 75 assert_eq!(winnings, 150, "V line with five C symbols pays 15x");
67 assert_eq!(lines, vec![4]); 76 assert_eq!(lines(&wins), vec![4]);
77 assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]);
68} 78}
69 79
70#[test] 80#[test]
71fn no_matching_line_pays_nothing() { 81fn no_matching_line_pays_nothing() {
72 let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); 82 let (winnings, wins) = payout(["CLOGD", "LOSGC", "DGDSC"], 100);
73 83
74 assert_eq!(winnings, 0); 84 assert_eq!(winnings, 0);
75 assert!(lines.is_empty()); 85 assert!(wins.is_empty());
76} 86}
77 87
78#[test] 88#[test]
79fn winning_lines_are_added_together() { 89fn winning_lines_are_added_together() {
80 let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); 90 let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10);
81 91
82 assert_eq!(winnings, (150 + 20 + 25) * 10); 92 assert_eq!(winnings, (150 + 20 + 25) * 10);
83 assert_eq!(lines, vec![1, 2, 3]); 93 assert_eq!(lines(&wins), vec![1, 2, 3]);
94
95 let mut cells: Vec<usize> = wins.iter().flat_map(|win| win.cells.clone()).collect();
96 cells.sort_unstable();
97 assert_eq!(cells, (0..15).collect::<Vec<_>>());
84} 98}
85 99
86#[test] 100#[test]
@@ -111,6 +125,7 @@ fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() {
111 assert_eq!(result.winnings, 20); 125 assert_eq!(result.winnings, 20);
112 assert_eq!(result.result_data.get("win_type").unwrap(), "win"); 126 assert_eq!(result.result_data.get("win_type").unwrap(), "win");
113 assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); 127 assert_eq!(result.result_data.get("win_lines").unwrap(), "1");
128 assert_eq!(result.result_data.get("win_cells").unwrap(), "0,3,6");
114} 129}
115 130
116#[test] 131#[test]