diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:19:52 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:19:52 +0100 |
| commit | 2915d826f744612e0d975282f5bddf13270b9f3f (patch) | |
| tree | a2864bfaef91070ac3b4e13b8b731e539a757cc8 | |
| parent | v1.0.0: replace slots with 5x3 reels and five paylines (diff) | |
| download | zagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.tar.gz zagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.zip | |
v1.1.0: highlight winning symbols on the slots board
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SLOTS.md | 26 | ||||
| -rw-r--r-- | docs/TESTING.md | 5 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 31 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 11 | ||||
| -rw-r--r-- | src/client/games/response.rs | 18 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 29 | ||||
| -rw-r--r-- | src/server/core.rs | 7 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 49 | ||||
| -rw-r--r-- | tests/client_core.rs | 16 | ||||
| -rw-r--r-- | tests/server_core.rs | 2 | ||||
| -rw-r--r-- | tests/slots.rs | 43 |
12 files changed, 186 insertions, 53 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "1.0.0" | 3 | version = "1.1.0" |
| 4 | edition = "2024" | 4 | edition = "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 | ``` |
| 44 | GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> | 44 | GAME_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 | ||
| 54 | The extra `win_lines` field is additive, but the 5x3 `reels` payload is a | 58 | The extra `win_lines`/`win_cells` fields are additive. The 5x3 `reels` payload |
| 55 | breaking change for pre-5x3 clients. That is why the release is `1.0.0`. | 59 | is a breaking change for pre-5x3 clients, hence `1.0.0`; highlighting is the |
| 60 | backward-compatible `1.1.0` release. The client never re-derives paylines: the | ||
| 61 | server 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 === |
| 63 | Bet: 10 PLN Lines: 5 Balance: 1000 PLN | 69 | Bet: 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 | ||
| 68 | Won 20 PLN | 74 | Won 20 PLN |
| 69 | Winning lines: 1, 3 | 75 | Winning 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 | 79 | Winning cells use braces and are rendered yellow/bold in the terminal. The |
| 74 | pass can highlight the winning rows without recomputing the payout. | 80 | line list below the status names the paylines that won. Both come from the |
| 81 | server's `win_lines` and `win_cells`, which are already carried through the | ||
| 82 | client 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. |
| 39 | 5. `play slots 50` shows a 5x3 grid, `Lines: 5`, and the fetched balance. | 39 | 5. `play slots 50` shows a 5x3 grid, `Lines: 5`, and the fetched balance. |
| 40 | 6. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and | 40 | 6. 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`. | ||
| 43 | 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines | 44 | 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines |
| 44 | appear. | 45 | appear. |
| 45 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. | 46 | 8. `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 | ||
| 443 | fn 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 | |||
| 439 | fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> { | 468 | fn 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 | |||
| 75 | fn 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)] | ||
| 26 | pub struct LineWin { | ||
| 27 | pub line: usize, | ||
| 28 | /// Column-major cell indices of the matched symbols. | ||
| 29 | pub cells: Vec<usize>, | ||
| 30 | } | ||
| 31 | |||
| 25 | pub struct SlotsGame { | 32 | pub 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] |
| 519 | fn parses_a_five_by_three_result_with_winning_lines() { | 521 | fn 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 | ||
| 3 | use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; | 3 | use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; |
| 4 | use zagan_bet::server::games::traits::{Game, GamePhase}; | 4 | use 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 | ||
| 18 | fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec<usize>) { | 18 | fn 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 | ||
| 25 | fn lines(wins: &[LineWin]) -> Vec<usize> { | ||
| 26 | wins.iter().map(|win| win.line).collect() | ||
| 27 | } | ||
| 28 | |||
| 25 | #[test] | 29 | #[test] |
| 26 | fn the_wire_grid_is_five_reels_by_three_rows() { | 30 | fn 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] |
| 39 | fn three_of_a_kind_on_the_top_line_pays() { | 43 | fn 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] |
| 47 | fn four_of_a_kind_pays_more_than_three() { | 52 | fn 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] |
| 55 | fn five_of_a_kind_pays_all_lines() { | 61 | fn 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] |
| 63 | fn the_v_payline_is_evaluated() { | 72 | fn 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] |
| 71 | fn no_matching_line_pays_nothing() { | 81 | fn 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] |
| 79 | fn winning_lines_are_added_together() { | 89 | fn 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] |
