diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
| commit | fc59ef77cda14d97d854dfcaa848307cfa512b69 (patch) | |
| tree | b0fc97390b96078e0a0538e76d7c76f7c04135f1 | |
| parent | v0.4.0: add first-time TLS setup and server help (diff) | |
| download | zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.tar.gz zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.zip | |
v1.0.0: replace slots with 5x3 reels and five paylines
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SLOTS.md | 74 | ||||
| -rw-r--r-- | docs/TESTING.md | 10 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 11 | ||||
| -rw-r--r-- | src/client/games/response.rs | 13 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 87 | ||||
| -rw-r--r-- | src/server/core.rs | 17 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 212 | ||||
| -rw-r--r-- | tests/client_core.rs | 106 | ||||
| -rw-r--r-- | tests/server_core.rs | 33 | ||||
| -rw-r--r-- | tests/slots.rs | 159 |
11 files changed, 511 insertions, 213 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "0.4.0" | 3 | version = "1.0.0" |
| 4 | edition = "2024" | 4 | edition = "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 | ``` | ||
| 14 | line 1: top row across all five reels | ||
| 15 | line 2: middle row across all five reels | ||
| 16 | line 3: bottom row across all five reels | ||
| 17 | line 4: top, middle, bottom, middle, top | ||
| 18 | line 5: bottom, middle, top, middle, bottom | ||
| 19 | ``` | ||
| 20 | |||
| 21 | ## Paytable | ||
| 22 | |||
| 23 | Multipliers 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 | ``` | ||
| 44 | GAME_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 | |||
| 54 | The extra `win_lines` field is additive, but the 5x3 `reels` payload is a | ||
| 55 | breaking change for pre-5x3 clients. That is why the release is `1.0.0`. | ||
| 56 | |||
| 57 | ## Client board | ||
| 58 | |||
| 59 | The board is nine lines: | ||
| 60 | |||
| 61 | ``` | ||
| 62 | === SLOTS === | ||
| 63 | Bet: 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 | |||
| 68 | Won 20 PLN | ||
| 69 | Winning 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 | ||
| 74 | pass 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. |
| 37 | 4. `help` fits 80 columns. Tab completion wraps within 76 columns and the | 37 | 4. `help` fits 80 columns. Tab completion wraps within 76 columns and the |
| 38 | cursor stays visible. | 38 | cursor stays visible. |
| 39 | 5. `play slots 50` shows `Bet: 50 PLN` 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 reels, win text, and new balance inside | 40 | 6. 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 |
| 42 | 7. `+` / `-` after a spin changes only the bet; no stale result lines appear. | 42 | overwritten. Slots rules and RTP: `docs/SLOTS.md`. |
| 43 | 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines | ||
| 44 | appear. | ||
| 43 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. | 45 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. |
| 44 | 9. `info`, `ping`, `send hello`, and `hosts` print readable output; the host | 46 | 9. `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 @@ | |||
| 1 | use super::traits::{Game, GamePhase, GameState}; | 1 | use 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. | 4 | pub const SLOT_BOARD_LINES: usize = 9; |
| 5 | pub const SLOT_BOARD_LINES: usize = 7; | 5 | pub const LINES: usize = 5; |
| 6 | pub const REELS: usize = 5; | ||
| 7 | pub const ROWS: usize = 3; | ||
| 6 | 8 | ||
| 7 | pub struct SlotsGame; | 9 | pub 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 | ||
| 48 | impl Game for SlotsGame { | 75 | impl 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}; | |||
| 2 | use rand::Rng; | 2 | use rand::Rng; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::HashMap; |
| 4 | 4 | ||
| 5 | pub const REELS: usize = 5; | ||
| 6 | pub const ROWS: usize = 3; | ||
| 7 | pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; | ||
| 8 | |||
| 9 | /// Five paylines, each entry is the row used on reel 0..4. | ||
| 10 | pub 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)] | ||
| 19 | struct Payout { | ||
| 20 | three: u64, | ||
| 21 | four: u64, | ||
| 22 | five: u64, | ||
| 23 | } | ||
| 24 | |||
| 5 | pub struct SlotsGame { | 25 | pub 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 | ||
| 12 | impl SlotsGame { | 31 | impl 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 | |||
| 244 | fn 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 | ||
| 7 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 7 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 8 | use zagan_bet::client::games::response::parse_spin_response; | ||
| 8 | use zagan_bet::client::ui::command_names; | 9 | use zagan_bet::client::ui::command_names; |
| 9 | 10 | ||
| 11 | const WIN3: &str = "CCCCLLCLODGSDGS"; | ||
| 12 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | ||
| 13 | const ALL_C: &str = "CCCCCCCCCCCCCCC"; | ||
| 14 | |||
| 10 | #[derive(Default)] | 15 | #[derive(Default)] |
| 11 | struct Screen { | 16 | struct Screen { |
| 12 | lines: Vec<String>, | 17 | lines: Vec<String>, |
| @@ -123,16 +128,17 @@ impl Harness { | |||
| 123 | } | 128 | } |
| 124 | 129 | ||
| 125 | #[test] | 130 | #[test] |
| 126 | fn play_shows_a_seven_line_board_and_fetches_the_balance() { | 131 | fn 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] |
| 199 | fn 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] | ||
| 212 | fn 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] | ||
| 186 | fn quitting_clears_the_board_and_returns_the_prompt() { | 225 | fn 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] | ||
| 519 | fn 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] | ||
| 529 | fn 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] | ||
| 539 | fn 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; | |||
| 6 | use zagan_bet::server::games::registry::GameRegistry; | 6 | use zagan_bet::server::games::registry::GameRegistry; |
| 7 | use zagan_bet::server::games::slots::SlotsGame; | 7 | use zagan_bet::server::games::slots::SlotsGame; |
| 8 | 8 | ||
| 9 | fn setup(name: &str, reels: &str) -> (ServerCore, String) { | 9 | fn 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. | ||
| 27 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | ||
| 28 | |||
| 29 | /// Top line is C C C D D; it pays 2x total bet. | ||
| 30 | const WIN3: &str = "CCCCLLCLODGSDGS"; | ||
| 31 | |||
| 26 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 32 | fn 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] |
| 41 | fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { | 47 | fn 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] |
| 61 | fn the_same_credential_gets_a_different_salt() { | 67 | fn 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] |
| 75 | fn legacy_sha256_rows_are_upgraded_on_login() { | 81 | fn 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] |
| 98 | fn duplicate_registration_returns_an_error() { | 104 | fn 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] |
| 110 | fn a_malicious_username_cannot_change_another_balance() { | 116 | fn 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] |
| 134 | fn a_failed_audit_insert_rolls_back_the_balance_change() { | 140 | fn 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] |
| 157 | fn spins_write_audit_rows_and_leave_no_sessions() { | 163 | fn 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] |
| 177 | fn a_winning_spin_updates_the_balance_and_the_audit_row() { | 183 | fn 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] |
| 193 | fn insufficient_balance_does_not_write_an_audit_row() { | 200 | fn 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] |
| 213 | fn a_negative_balance_is_rejected_without_wrapping() { | 220 | fn 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] |
| 235 | fn unknown_games_are_rejected_before_a_session_is_created() { | 242 | fn 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 | ||
| 3 | use zagan_bet::server::games::slots::SlotsGame; | 3 | use zagan_bet::server::games::slots::{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 | fn make_game() -> SlotsGame { | 6 | /// Convert row-major test rows into the column-major wire format. |
| 7 | SlotsGame::new() | 7 | fn 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 | |||
| 18 | fn 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] | ||
| 26 | fn 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] |
| 12 | fn three_of_a_kind_pays() { | 39 | fn 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] |
| 22 | fn two_adjacent_pays() { | 47 | fn 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] |
| 33 | fn two_non_adjacent_pays() { | 55 | fn 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] |
| 45 | fn no_match_returns_zero() { | 63 | fn 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] |
| 52 | fn an_enormous_bet_saturates_instead_of_overflowing() { | 71 | fn 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] |
| 60 | fn process_action_spin_sets_finished() { | 79 | fn 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()); | 87 | fn 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] | ||
| 96 | fn 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] |
| 77 | fn calculate_result_on_finished_state() { | 103 | fn 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] |
| 95 | fn calculate_result_on_unfinished_state() { | 117 | fn 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 | } |
