diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 20:43:09 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 20:43:09 +0100 |
| commit | 6e2787d792de64c003eff03b62a2df798ef851c0 (patch) | |
| tree | 0e9bf97b2f71df5fb9eb00d5195d32b8326886ff | |
| parent | v2.2.1: forfeit blackjack bets when leaving or disconnecting (diff) | |
| download | zagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.tar.gz zagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.zip | |
v2.3.0: add crash with local multiplier and pushed crash event
| -rw-r--r-- | AGENTS.md | 15 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GAMES.md | 65 | ||||
| -rw-r--r-- | docs/TESTING.md | 18 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 5 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 123 | ||||
| -rw-r--r-- | src/bin/client/tls.rs | 58 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 55 | ||||
| -rw-r--r-- | src/bin/server/tls.rs | 9 | ||||
| -rw-r--r-- | src/client/core.rs | 190 | ||||
| -rw-r--r-- | src/client/games/crash.rs | 91 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 90 | ||||
| -rw-r--r-- | src/client/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/games/response.rs | 21 | ||||
| -rw-r--r-- | src/crash.rs | 87 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/server/core.rs | 240 | ||||
| -rw-r--r-- | src/server/games/crash.rs | 216 | ||||
| -rw-r--r-- | src/server/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/games/registry.rs | 2 | ||||
| -rw-r--r-- | tests/client_core.rs | 210 | ||||
| -rw-r--r-- | tests/crash.rs | 100 | ||||
| -rw-r--r-- | tests/server_core.rs | 245 |
23 files changed, 1800 insertions, 45 deletions
| @@ -30,6 +30,21 @@ | |||
| 30 | - All balance mutations (`SET balance = balance ± ?`) must happen in a | 30 | - All balance mutations (`SET balance = balance ± ?`) must happen in a |
| 31 | transaction. | 31 | transaction. |
| 32 | 32 | ||
| 33 | ## Crash | ||
| 34 | - The client draws the multiplier from its own clock for display only. A | ||
| 35 | cashout carries a session id and nothing else; the multiplier and payout come | ||
| 36 | from the server's clock at receipt. | ||
| 37 | - Never send the crash point to the client before the round ends. A client that | ||
| 38 | knows it can cash out at `crash - epsilon` every round for a guaranteed win. | ||
| 39 | - The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of | ||
| 40 | rounds that crash at 1.00x. That is where the house edge is charged on an | ||
| 41 | instant cashout, and it is why no cashout lockout is needed. | ||
| 42 | - The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. | ||
| 43 | Client code must drain the socket on a timer and route pushed lines by | ||
| 44 | prefix instead of assuming the next line answers the last request. | ||
| 45 | - `ServerCore::poll` must be idempotent: it settles the round, removes the | ||
| 46 | session, and pushes once. A second poll must produce nothing. | ||
| 47 | |||
| 33 | ## Client TUI | 48 | ## Client TUI |
| 34 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, | 49 | - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, |
| 35 | count lines against the same string the cursor will erase. | 50 | count lines against the same string the cursor will erase. |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "2.2.1" | 3 | version = "2.3.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
diff --git a/docs/GAMES.md b/docs/GAMES.md index ae15d50..844d1b0 100644 --- a/docs/GAMES.md +++ b/docs/GAMES.md | |||
| @@ -7,15 +7,52 @@ | |||
| 7 | | Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | | 7 | | Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | |
| 8 | | Roulette | Done | European wheel, split bets, 97.3% RTP | | 8 | | Roulette | Done | European wheel, split bets, 97.3% RTP | |
| 9 | | Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double | | 9 | | Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double | |
| 10 | | Crash | Done | Local multiplier, server-owned clock and crash event, 99% RTP | | ||
| 10 | 11 | ||
| 11 | ## Planned | 12 | ## Planned |
| 12 | 13 | ||
| 13 | | Game | Shape | Main open decisions | | 14 | | Game | Shape | Main open decisions | |
| 14 | | --- | --- | --- | | 15 | | --- | --- | --- | |
| 15 | | Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | | 16 | | — | — | Provable fairness for the crash point (`hash` commit/reveal) | |
| 17 | |||
| 18 | Crash rounds are private to one player and use the same request/response socket | ||
| 19 | plus one server-initiated line. A shared multiplayer round would need a lobby | ||
| 20 | and broadcast, which is a different product. | ||
| 21 | |||
| 22 | ## Crash | ||
| 23 | |||
| 24 | - The multiplier is `exp(0.1386294 * t)`: 1.00x at the start, 2.00x at 5s, | ||
| 25 | 10x at ~16.6s, capped at 1000x. | ||
| 26 | - The crash point is drawn when the round starts: | ||
| 27 | `max(1.0, (1 - 0.01) / (1 - u))` for a uniform `u`. That gives | ||
| 28 | `P(crash >= m) = 0.99 / m`, so a fixed-target cashout returns 99% of the | ||
| 29 | stake. The cap trims the RTP by a hair. | ||
| 30 | - The crash point is **not** sent to the client until the round ends. Knowing it | ||
| 31 | early would let a patched client cash out at `crash - epsilon` every round. | ||
| 32 | - The client draws the multiplier from its own monotonic clock, anchored when | ||
| 33 | `CRASH_START` arrives. That number is display only. | ||
| 34 | - The server computes the cashout multiplier from its own clock when the | ||
| 35 | request arrives. The client never sends a multiplier. A cashout that arrives | ||
| 36 | after the crash time loses the bet. | ||
| 37 | - Because `P(crash = 1.00x) = 1%`, an instant cashout returns the stake 99% of | ||
| 38 | the time and nothing 1% of the time. There is no risk-free window, which is | ||
| 39 | why there is no cashout lockout. | ||
| 40 | - Payouts are `floor(bet * multiplier)` in whole PLN, so anything under 1.10x | ||
| 41 | on a 10 PLN bet returns 10. | ||
| 42 | - When the crash time passes, the server pushes `CRASH_BUSTED` on the same | ||
| 43 | connection without being asked. The client's event loop polls for keys and | ||
| 44 | drains the socket every 25ms. | ||
| 45 | - Leaving with Q or disconnecting during a round forfeits the reserved bet and | ||
| 46 | writes a `result = forfeit` audit row. | ||
| 16 | 47 | ||
| 17 | Crash needs a timed round loop and a cashout action, so it should be its own | 48 | Client controls: |
| 18 | protocol/loop change. | 49 | |
| 50 | ``` | ||
| 51 | C cash out at the server's current multiplier | ||
| 52 | SPACE start the next round | ||
| 53 | + or = / - change the next round's bet | ||
| 54 | Q forfeit an active round (bet is lost), or leave when idle | ||
| 55 | ``` | ||
| 19 | 56 | ||
| 20 | ## Blackjack | 57 | ## Blackjack |
| 21 | 58 | ||
| @@ -92,6 +129,9 @@ plays ( | |||
| 92 | 129 | ||
| 93 | // blackjack | 130 | // blackjack |
| 94 | {"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} | 131 | {"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} |
| 132 | |||
| 133 | // crash | ||
| 134 | {"bet":10,"crash_point":3.0,"started_at":12.44,"phase":"cashed","result":"cashed","multiplier":2.0,"payout":20} | ||
| 95 | ``` | 135 | ``` |
| 96 | 136 | ||
| 97 | The balance update and the `plays` insert happen in one transaction | 137 | The balance update and the `plays` insert happen in one transaction |
| @@ -124,3 +164,22 @@ BLACKJACK_STATE <json> | |||
| 124 | 164 | ||
| 125 | `BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, | 165 | `BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, |
| 126 | result and winnings. The dealer's hole card is `?` until the hand is done. | 166 | result and winnings. The dealer's hole card is `?` until the hand is done. |
| 167 | |||
| 168 | Crash sends one line when the round starts, one line when it crashes (without a | ||
| 169 | request), and answers the cashout: | ||
| 170 | |||
| 171 | ``` | ||
| 172 | PLAY_START crash <bet> | ||
| 173 | CRASH_START <json> | ||
| 174 | |||
| 175 | CRASH_CASHOUT <session> | ||
| 176 | CRASH_STATE <json> | ||
| 177 | |||
| 178 | CRASH_BUSTED <json> # pushed when the crash time passes | ||
| 179 | CRASH_FORFEIT <session> | ||
| 180 | CRASH_STATE <json> | ||
| 181 | ``` | ||
| 182 | |||
| 183 | `CRASH_STATE` carries `session_id`, `phase` (`running`, `cashed`, `crashed`, | ||
| 184 | `forfeit`), `bet`, `balance`, the current/final `multiplier`, `potential`, | ||
| 185 | `crash_point` (null while running), `payout` and `result`. | ||
diff --git a/docs/TESTING.md b/docs/TESTING.md index ec5c681..3c7fa61 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md | |||
| @@ -14,6 +14,7 @@ shared global database. Each test owns its state. | |||
| 14 | | `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | | 14 | | `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | |
| 15 | | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | | 15 | | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | |
| 16 | | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | | 16 | | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | |
| 17 | | `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | | ||
| 17 | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | | 18 | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | |
| 18 | | `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | | 19 | | `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | |
| 19 | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | | 20 | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | |
| @@ -93,6 +94,23 @@ Use an 80-column terminal because several output lines are width-sensitive. | |||
| 93 | double), winnings, and the final cards/result in `details`. A forfeit has | 94 | double), winnings, and the final cards/result in `details`. A forfeit has |
| 94 | `result = forfeit`, `winnings = 0`. | 95 | `result = forfeit`, `winnings = 0`. |
| 95 | 96 | ||
| 97 | ## Manual crash checklist | ||
| 98 | |||
| 99 | 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and | ||
| 100 | reserves the bet. | ||
| 101 | 2. The multiplier rises smoothly with no network traffic. Cash out with C and | ||
| 102 | confirm the board shows the cashout multiplier, the payout and the new | ||
| 103 | balance. | ||
| 104 | 3. On another round, press nothing. The board must switch to `CRASHED at Xx` | ||
| 105 | by itself when the crash point is reached, and the bet must be lost. | ||
| 106 | 4. Press C just as a round crashes. Either it pays or the crashed result wins; | ||
| 107 | a late cashout must never show an error over a finished round. | ||
| 108 | 5. Q during a live round forfeits the bet; Q when idle just leaves. | ||
| 109 | 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, | ||
| 110 | the payout, and `crash_point`/`multiplier`/`result` in `details`. | ||
| 111 | 7. Confirm the crash point is absent from the `CRASH_START` line (it must only | ||
| 112 | appear once the round is over). | ||
| 113 | |||
| 96 | ## Manual server checks | 114 | ## Manual server checks |
| 97 | 115 | ||
| 98 | - Register, log out, log in again (bcrypt path). | 116 | - Register, log out, log in again (bcrypt path). |
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 6459de5..363a2a9 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs | |||
| @@ -125,6 +125,11 @@ impl TlsStream { | |||
| 125 | self.inner.write_line(msg) | 125 | self.inner.write_line(msg) |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | /// One line if the socket already has one, without blocking. | ||
| 129 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 130 | self.inner.read_available_line() | ||
| 131 | } | ||
| 132 | |||
| 128 | pub fn set_read_timeout( | 133 | pub fn set_read_timeout( |
| 129 | &self, | 134 | &self, |
| 130 | duration: Option<std::time::Duration>, | 135 | duration: Option<std::time::Duration>, |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index f85af47..7fa462a 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -102,29 +102,76 @@ impl ClientState { | |||
| 102 | self.apply_prompt(); | 102 | self.apply_prompt(); |
| 103 | 103 | ||
| 104 | loop { | 104 | loop { |
| 105 | let event = read().unwrap(); | 105 | let mut quit = false; |
| 106 | match event { | 106 | |
| 107 | Event::Key(key) => { | 107 | if crossterm::event::poll(std::time::Duration::from_millis(25)).unwrap_or(false) { |
| 108 | if key.kind != KeyEventKind::Press { | 108 | match read().unwrap() { |
| 109 | continue; | 109 | Event::Key(key) => { |
| 110 | } | 110 | if key.kind == KeyEventKind::Press { |
| 111 | let Some(key) = map_key(key) else { | 111 | if let Some(key) = map_key(key) { |
| 112 | continue; | 112 | let effects = self.core.handle_key(key); |
| 113 | }; | 113 | quit = self.apply_effects(effects); |
| 114 | let effects = self.core.handle_key(key); | 114 | self.apply_prompt(); |
| 115 | if self.apply_effects(effects) { | 115 | } |
| 116 | break; | 116 | } |
| 117 | } | 117 | } |
| 118 | self.apply_prompt(); | 118 | Event::Resize(_, _) => self.apply_prompt(), |
| 119 | _ => {} | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | if !quit && self.core.expects_push() { | ||
| 124 | quit = self.pump_async(); | ||
| 125 | } | ||
| 126 | |||
| 127 | if !quit { | ||
| 128 | let effects = self.core.tick(); | ||
| 129 | if !effects.is_empty() { | ||
| 130 | quit = self.apply_effects(effects); | ||
| 119 | } | 131 | } |
| 120 | Event::Resize(_, _) => self.apply_prompt(), | 132 | } |
| 121 | _ => {} | 133 | |
| 134 | if quit { | ||
| 135 | break; | ||
| 122 | } | 136 | } |
| 123 | } | 137 | } |
| 124 | 138 | ||
| 125 | restore_terminal(&self.cleanup_done); | 139 | restore_terminal(&self.cleanup_done); |
| 126 | } | 140 | } |
| 127 | 141 | ||
| 142 | /// Read any server-initiated lines that are already waiting and apply them. | ||
| 143 | /// Returns true when an effect asked the program to quit. | ||
| 144 | fn pump_async(&mut self) -> bool { | ||
| 145 | loop { | ||
| 146 | let Some(mut stream) = self.stream.take() else { | ||
| 147 | return false; | ||
| 148 | }; | ||
| 149 | let line = match stream.read_available_line() { | ||
| 150 | Ok(line) => { | ||
| 151 | self.stream = Some(stream); | ||
| 152 | line | ||
| 153 | } | ||
| 154 | Err(_) => { | ||
| 155 | self.core.set_connected(false); | ||
| 156 | self.stream = None; | ||
| 157 | return false; | ||
| 158 | } | ||
| 159 | }; | ||
| 160 | |||
| 161 | let Some(line) = line else { | ||
| 162 | return false; | ||
| 163 | }; | ||
| 164 | |||
| 165 | let effects = match self.core.handle_async_line(&line) { | ||
| 166 | Some(effects) => effects, | ||
| 167 | None => self.core.handle_reply(&line), | ||
| 168 | }; | ||
| 169 | if self.apply_effects(effects) { | ||
| 170 | return true; | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 128 | fn apply_effects(&mut self, effects: Vec<Effect>) -> bool { | 175 | fn apply_effects(&mut self, effects: Vec<Effect>) -> bool { |
| 129 | for effect in effects { | 176 | for effect in effects { |
| 130 | match effect { | 177 | match effect { |
| @@ -250,6 +297,9 @@ impl ClientState { | |||
| 250 | let roulette = lines | 297 | let roulette = lines |
| 251 | .first() | 298 | .first() |
| 252 | .is_some_and(|first| first.contains("ROULETTE")); | 299 | .is_some_and(|first| first.contains("ROULETTE")); |
| 300 | let crash = lines | ||
| 301 | .first() | ||
| 302 | .is_some_and(|first| first.contains("CRASH")); | ||
| 253 | let rendered: Vec<String> = lines | 303 | let rendered: Vec<String> = lines |
| 254 | .iter() | 304 | .iter() |
| 255 | .map(|line| { | 305 | .map(|line| { |
| @@ -263,6 +313,18 @@ impl ClientState { | |||
| 263 | } else { | 313 | } else { |
| 264 | style_roulette_numbers(line) | 314 | style_roulette_numbers(line) |
| 265 | } | 315 | } |
| 316 | } else if crash { | ||
| 317 | if line.contains("CRASHED") { | ||
| 318 | line.as_str().bold().red().to_string() | ||
| 319 | } else if line.starts_with("Cashed out") { | ||
| 320 | line.as_str().bold().green().to_string() | ||
| 321 | } else if line.starts_with("Multiplier:") { | ||
| 322 | line.as_str().yellow().to_string() | ||
| 323 | } else if line.starts_with("ERROR:") { | ||
| 324 | line.as_str().red().to_string() | ||
| 325 | } else { | ||
| 326 | line.clone() | ||
| 327 | } | ||
| 266 | } else if line.contains('{') { | 328 | } else if line.contains('{') { |
| 267 | style_winning_cells(line) | 329 | style_winning_cells(line) |
| 268 | } else if line.contains("WIN!") || line.starts_with("Won ") { | 330 | } else if line.contains("WIN!") || line.starts_with("Won ") { |
| @@ -361,22 +423,37 @@ impl ClientState { | |||
| 361 | return "ERROR Not connected".to_string(); | 423 | return "ERROR Not connected".to_string(); |
| 362 | }; | 424 | }; |
| 363 | 425 | ||
| 426 | // A crash event can be pushed while a request is in flight. Route those | ||
| 427 | // lines as they arrive and keep waiting for the actual reply. | ||
| 428 | let mut pushed: Vec<String> = Vec::new(); | ||
| 364 | let result = (|| -> std::io::Result<String> { | 429 | let result = (|| -> std::io::Result<String> { |
| 365 | stream.write_line(line)?; | 430 | stream.write_line(line)?; |
| 366 | let mut response = String::new(); | 431 | loop { |
| 367 | let read = stream.read_line(&mut response)?; | 432 | let mut response = String::new(); |
| 368 | if read == 0 { | 433 | let read = stream.read_line(&mut response)?; |
| 369 | return Err(std::io::Error::new( | 434 | if read == 0 { |
| 370 | std::io::ErrorKind::UnexpectedEof, | 435 | return Err(std::io::Error::new( |
| 371 | "connection closed", | 436 | std::io::ErrorKind::UnexpectedEof, |
| 372 | )); | 437 | "connection closed", |
| 438 | )); | ||
| 439 | } | ||
| 440 | let response = response.trim_end().to_string(); | ||
| 441 | if self.core.is_async(&response) { | ||
| 442 | pushed.push(response); | ||
| 443 | continue; | ||
| 444 | } | ||
| 445 | return Ok(response); | ||
| 373 | } | 446 | } |
| 374 | Ok(response) | ||
| 375 | })(); | 447 | })(); |
| 376 | 448 | ||
| 377 | match result { | 449 | match result { |
| 378 | Ok(response) => { | 450 | Ok(response) => { |
| 379 | self.stream = Some(stream); | 451 | self.stream = Some(stream); |
| 452 | for line in pushed { | ||
| 453 | if let Some(effects) = self.core.handle_async_line(&line) { | ||
| 454 | self.apply_effects(effects); | ||
| 455 | } | ||
| 456 | } | ||
| 380 | response | 457 | response |
| 381 | } | 458 | } |
| 382 | Err(error) => { | 459 | Err(error) => { |
diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs index 8c052f7..b46f52b 100644 --- a/src/bin/client/tls.rs +++ b/src/bin/client/tls.rs | |||
| @@ -64,6 +64,7 @@ impl TlsConnector { | |||
| 64 | sock, | 64 | sock, |
| 65 | tls: conn, | 65 | tls: conn, |
| 66 | lines: LineBuffer::new(), | 66 | lines: LineBuffer::new(), |
| 67 | read_timeout: Some(std::time::Duration::from_secs(10)), | ||
| 67 | })), | 68 | })), |
| 68 | }) | 69 | }) |
| 69 | } | 70 | } |
| @@ -150,6 +151,7 @@ struct Session { | |||
| 150 | sock: TcpStream, | 151 | sock: TcpStream, |
| 151 | tls: rustls::ClientConnection, | 152 | tls: rustls::ClientConnection, |
| 152 | lines: LineBuffer, | 153 | lines: LineBuffer, |
| 154 | read_timeout: Option<std::time::Duration>, | ||
| 153 | } | 155 | } |
| 154 | 156 | ||
| 155 | impl Session { | 157 | impl Session { |
| @@ -202,7 +204,61 @@ impl TlsStream { | |||
| 202 | &self, | 204 | &self, |
| 203 | duration: Option<std::time::Duration>, | 205 | duration: Option<std::time::Duration>, |
| 204 | ) -> std::io::Result<()> { | 206 | ) -> std::io::Result<()> { |
| 205 | self.session.lock().sock.set_read_timeout(duration) | 207 | let mut s = self.session.lock(); |
| 208 | s.read_timeout = duration; | ||
| 209 | s.sock.set_read_timeout(duration) | ||
| 210 | } | ||
| 211 | |||
| 212 | /// Return one complete line if the socket already has one, without | ||
| 213 | /// blocking. Used to pick up server pushes between requests. | ||
| 214 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 215 | let mut s = self.session.lock(); | ||
| 216 | if let Some(line) = s.lines.next_line() { | ||
| 217 | return Ok(Some(line)); | ||
| 218 | } | ||
| 219 | |||
| 220 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the | ||
| 221 | // shortest timeout the socket accepts instead of a nonblocking mode. | ||
| 222 | s.sock | ||
| 223 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; | ||
| 224 | let drained = (|| -> std::io::Result<()> { | ||
| 225 | loop { | ||
| 226 | let mut buf = [0u8; 4096]; | ||
| 227 | match s.tls.reader().read(&mut buf) { | ||
| 228 | Ok(0) => return Ok(()), | ||
| 229 | Ok(n) => s.lines.push(&buf[..n]), | ||
| 230 | // The reader only drains plaintext rustls has already | ||
| 231 | // decrypted; `drive` moves bytes off the socket. | ||
| 232 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { | ||
| 233 | match s.drive() { | ||
| 234 | Ok(()) => continue, | ||
| 235 | Err(error) | ||
| 236 | if matches!( | ||
| 237 | error.kind(), | ||
| 238 | std::io::ErrorKind::WouldBlock | ||
| 239 | | std::io::ErrorKind::TimedOut | ||
| 240 | ) => | ||
| 241 | { | ||
| 242 | return Ok(()); | ||
| 243 | } | ||
| 244 | Err(error) => return Err(error), | ||
| 245 | } | ||
| 246 | } | ||
| 247 | Err(error) | ||
| 248 | if matches!( | ||
| 249 | error.kind(), | ||
| 250 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 251 | ) => | ||
| 252 | { | ||
| 253 | return Ok(()); | ||
| 254 | } | ||
| 255 | Err(error) => return Err(error), | ||
| 256 | } | ||
| 257 | } | ||
| 258 | })(); | ||
| 259 | s.sock.set_read_timeout(s.read_timeout)?; | ||
| 260 | drained?; | ||
| 261 | Ok(s.lines.next_line()) | ||
| 206 | } | 262 | } |
| 207 | } | 263 | } |
| 208 | 264 | ||
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 86b09cb..dffae69 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs | |||
| @@ -1,6 +1,11 @@ | |||
| 1 | use std::time::Duration; | ||
| 2 | |||
| 1 | use crate::tls::TlsStream; | 3 | use crate::tls::TlsStream; |
| 2 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 4 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 3 | 5 | ||
| 6 | /// How long the connection waits for a request before advancing timed games. | ||
| 7 | const POLL_INTERVAL: Duration = Duration::from_millis(50); | ||
| 8 | |||
| 4 | pub fn handle_client_tls(mut stream: TlsStream) { | 9 | pub fn handle_client_tls(mut stream: TlsStream) { |
| 5 | let client_addr = match stream.peer_addr() { | 10 | let client_addr = match stream.peer_addr() { |
| 6 | Ok(addr) => addr.to_string(), | 11 | Ok(addr) => addr.to_string(), |
| @@ -19,34 +24,54 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 19 | } | 24 | } |
| 20 | }; | 25 | }; |
| 21 | 26 | ||
| 27 | if let Err(error) = stream.set_read_timeout(Some(POLL_INTERVAL)) { | ||
| 28 | eprintln!("Could not set read timeout: {error}"); | ||
| 29 | return; | ||
| 30 | } | ||
| 31 | |||
| 22 | let mut state = ConnectionState::default(); | 32 | let mut state = ConnectionState::default(); |
| 23 | println!("[TLS Client connected] {client_addr}"); | 33 | println!("[TLS Client connected] {client_addr}"); |
| 24 | 34 | ||
| 25 | loop { | 35 | 'connection: loop { |
| 26 | let mut line = String::new(); | 36 | let mut line = String::new(); |
| 27 | match stream.read_line(&mut line) { | 37 | match stream.read_line(&mut line) { |
| 28 | Ok(0) => break, | 38 | Ok(0) => break, |
| 29 | Ok(_) => {} | 39 | Ok(_) => { |
| 40 | let line = line.trim(); | ||
| 41 | if !line.is_empty() { | ||
| 42 | let user = state.user().unwrap_or("guest").to_string(); | ||
| 43 | println!("[{user} @ {client_addr}] Received: {line}"); | ||
| 44 | let response = core.handle_message(&mut state, line); | ||
| 45 | if let Err(error) = stream.write_line(&response) { | ||
| 46 | eprintln!("Write error: {error}"); | ||
| 47 | break; | ||
| 48 | } | ||
| 49 | let user = state.user().unwrap_or("guest"); | ||
| 50 | println!("[{user} @ {client_addr}] Sent: {response}"); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | Err(error) | ||
| 54 | if matches!( | ||
| 55 | error.kind(), | ||
| 56 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | ||
| 57 | ) => | ||
| 58 | { | ||
| 59 | // No request yet: fall through and let timed games advance. | ||
| 60 | } | ||
| 30 | Err(error) => { | 61 | Err(error) => { |
| 31 | eprintln!("Read error: {error}"); | 62 | eprintln!("Read error: {error}"); |
| 32 | break; | 63 | break; |
| 33 | } | 64 | } |
| 34 | } | 65 | } |
| 35 | 66 | ||
| 36 | let line = line.trim(); | 67 | for push in core.poll(&state) { |
| 37 | if line.is_empty() { | 68 | if let Err(error) = stream.write_line(&push) { |
| 38 | continue; | 69 | eprintln!("Write error: {error}"); |
| 39 | } | 70 | break 'connection; |
| 40 | 71 | } | |
| 41 | let user = state.user().unwrap_or("guest").to_string(); | 72 | let user = state.user().unwrap_or("guest"); |
| 42 | println!("[{user} @ {client_addr}] Received: {line}"); | 73 | println!("[{user} @ {client_addr}] Pushed: {push}"); |
| 43 | let response = core.handle_message(&mut state, line); | ||
| 44 | if let Err(error) = stream.write_line(&response) { | ||
| 45 | eprintln!("Write error: {error}"); | ||
| 46 | break; | ||
| 47 | } | 74 | } |
| 48 | let user = state.user().unwrap_or("guest"); | ||
| 49 | println!("[{user} @ {client_addr}] Sent: {response}"); | ||
| 50 | } | 75 | } |
| 51 | 76 | ||
| 52 | core.disconnect(&state); | 77 | core.disconnect(&state); |
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs index 292ebf7..5d84589 100644 --- a/src/bin/server/tls.rs +++ b/src/bin/server/tls.rs | |||
| @@ -77,6 +77,15 @@ impl TlsStream { | |||
| 77 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { | 77 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { |
| 78 | self.stream.get_ref().peer_addr() | 78 | self.stream.get_ref().peer_addr() |
| 79 | } | 79 | } |
| 80 | |||
| 81 | /// Read timeout for the socket. The connection loop uses a short one so it | ||
| 82 | /// can advance crash rounds while no request is in flight. | ||
| 83 | pub fn set_read_timeout( | ||
| 84 | &self, | ||
| 85 | duration: Option<std::time::Duration>, | ||
| 86 | ) -> std::io::Result<()> { | ||
| 87 | self.stream.get_ref().set_read_timeout(duration) | ||
| 88 | } | ||
| 80 | } | 89 | } |
| 81 | 90 | ||
| 82 | impl Read for TlsStream { | 91 | impl Read for TlsStream { |
diff --git a/src/client/core.rs b/src/client/core.rs index 77761b4..5f8c513 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -1,12 +1,14 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::{ | 2 | use crate::client::games::response::{ |
| 3 | parse_blackjack_response, parse_roulette_response, parse_spin_response, | 3 | parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, |
| 4 | }; | 4 | }; |
| 5 | use crate::client::ui::{ | 5 | use crate::client::ui::{ |
| 6 | command_names, completion_lines, display_width, help_lines, input_cursor_column, | 6 | command_names, completion_lines, display_width, help_lines, input_cursor_column, |
| 7 | parse_bet_arg, record_history, | 7 | parse_bet_arg, record_history, |
| 8 | }; | 8 | }; |
| 9 | use crate::crash::{multiplier_at, CrashState}; | ||
| 9 | use crate::roulette::{Bets, NumberBet}; | 10 | use crate::roulette::{Bets, NumberBet}; |
| 11 | use std::time::{Duration, Instant}; | ||
| 10 | 12 | ||
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum Key { | 14 | pub enum Key { |
| @@ -66,6 +68,9 @@ enum Pending { | |||
| 66 | BlackjackStart, | 68 | BlackjackStart, |
| 67 | BlackjackAction, | 69 | BlackjackAction, |
| 68 | BlackjackForfeit, | 70 | BlackjackForfeit, |
| 71 | CrashStart, | ||
| 72 | CrashCashout, | ||
| 73 | CrashForfeit, | ||
| 69 | Ping, | 74 | Ping, |
| 70 | Echo, | 75 | Echo, |
| 71 | Info, | 76 | Info, |
| @@ -90,6 +95,8 @@ pub struct ClientCore { | |||
| 90 | balance: Option<i64>, | 95 | balance: Option<i64>, |
| 91 | host: Option<String>, | 96 | host: Option<String>, |
| 92 | fingerprint: Option<String>, | 97 | fingerprint: Option<String>, |
| 98 | /// Local anchor for the crash multiplier, set when a round starts. | ||
| 99 | crash_started: Option<Instant>, | ||
| 93 | } | 100 | } |
| 94 | 101 | ||
| 95 | impl ClientCore { | 102 | impl ClientCore { |
| @@ -105,6 +112,7 @@ impl ClientCore { | |||
| 105 | balance: None, | 112 | balance: None, |
| 106 | host: None, | 113 | host: None, |
| 107 | fingerprint: None, | 114 | fingerprint: None, |
| 115 | crash_started: None, | ||
| 108 | } | 116 | } |
| 109 | } | 117 | } |
| 110 | 118 | ||
| @@ -113,6 +121,7 @@ impl ClientCore { | |||
| 113 | if !connected { | 121 | if !connected { |
| 114 | self.logged_in = None; | 122 | self.logged_in = None; |
| 115 | self.pending = None; | 123 | self.pending = None; |
| 124 | self.crash_started = None; | ||
| 116 | } | 125 | } |
| 117 | } | 126 | } |
| 118 | 127 | ||
| @@ -226,6 +235,49 @@ impl ClientCore { | |||
| 226 | self.board_effects() | 235 | self.board_effects() |
| 227 | } | 236 | } |
| 228 | }, | 237 | }, |
| 238 | Some(Pending::CrashStart) => match parse_crash_response(reply) { | ||
| 239 | Ok(state) => self.apply_crash(state), | ||
| 240 | Err(message) => { | ||
| 241 | self.game.set_error(message); | ||
| 242 | self.board_effects() | ||
| 243 | } | ||
| 244 | }, | ||
| 245 | Some(Pending::CrashCashout) => match parse_crash_response(reply) { | ||
| 246 | Ok(state) => self.apply_crash(state), | ||
| 247 | Err(message) => { | ||
| 248 | // A pushed bust can settle the round before this reply | ||
| 249 | // arrives. The late request must not overwrite the result. | ||
| 250 | if self.game.crash_status() != "running" { | ||
| 251 | return self.board_effects(); | ||
| 252 | } | ||
| 253 | self.game.set_error(message); | ||
| 254 | self.board_effects() | ||
| 255 | } | ||
| 256 | }, | ||
| 257 | Some(Pending::CrashForfeit) => match parse_crash_response(reply) { | ||
| 258 | Ok(state) => { | ||
| 259 | self.crash_started = None; | ||
| 260 | self.game.end_game(); | ||
| 261 | let message = format!( | ||
| 262 | "Game exited. Round forfeited ({} PLN lost).", | ||
| 263 | state.bet | ||
| 264 | ); | ||
| 265 | vec![Effect::ClearRegion, Effect::Print(vec![message])] | ||
| 266 | } | ||
| 267 | Err(_) if self.game.crash_status() != "running" => { | ||
| 268 | // The round ended while the forfeit was in flight. | ||
| 269 | self.crash_started = None; | ||
| 270 | self.game.end_game(); | ||
| 271 | vec![ | ||
| 272 | Effect::ClearRegion, | ||
| 273 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 274 | ] | ||
| 275 | } | ||
| 276 | Err(message) => { | ||
| 277 | self.game.set_error(message); | ||
| 278 | self.board_effects() | ||
| 279 | } | ||
| 280 | }, | ||
| 229 | Some(Pending::Ping) => { | 281 | Some(Pending::Ping) => { |
| 230 | if reply.trim() == "PONG" { | 282 | if reply.trim() == "PONG" { |
| 231 | print_line("Pong") | 283 | print_line("Pong") |
| @@ -270,6 +322,9 @@ impl ClientCore { | |||
| 270 | if game_type == Some("blackjack") { | 322 | if game_type == Some("blackjack") { |
| 271 | return self.handle_blackjack_key(key); | 323 | return self.handle_blackjack_key(key); |
| 272 | } | 324 | } |
| 325 | if game_type == Some("crash") { | ||
| 326 | return self.handle_crash_key(key); | ||
| 327 | } | ||
| 273 | 328 | ||
| 274 | match key { | 329 | match key { |
| 275 | Key::CtrlC => vec![Effect::Quit], | 330 | Key::CtrlC => vec![Effect::Quit], |
| @@ -559,6 +614,138 @@ impl ClientCore { | |||
| 559 | ))] | 614 | ))] |
| 560 | } | 615 | } |
| 561 | 616 | ||
| 617 | /// Redraw the rising multiplier. Called by the terminal loop on a timer. | ||
| 618 | pub fn tick(&mut self) -> Vec<Effect> { | ||
| 619 | let Some(started) = self.crash_started else { | ||
| 620 | return Vec::new(); | ||
| 621 | }; | ||
| 622 | self.tick_at(started.elapsed()) | ||
| 623 | } | ||
| 624 | |||
| 625 | pub fn tick_at(&mut self, elapsed: Duration) -> Vec<Effect> { | ||
| 626 | if self.crash_started.is_none() { | ||
| 627 | return Vec::new(); | ||
| 628 | } | ||
| 629 | if self | ||
| 630 | .game | ||
| 631 | .get_current_state() | ||
| 632 | .map(|state| state.game_type.as_str()) | ||
| 633 | != Some("crash") | ||
| 634 | { | ||
| 635 | return Vec::new(); | ||
| 636 | } | ||
| 637 | self.game.set_crash_multiplier(multiplier_at(elapsed)); | ||
| 638 | self.board_effects() | ||
| 639 | } | ||
| 640 | |||
| 641 | /// True while the server may push a crash event between requests. | ||
| 642 | pub fn expects_push(&self) -> bool { | ||
| 643 | self.crash_started.is_some() | ||
| 644 | } | ||
| 645 | |||
| 646 | pub fn is_async(&self, line: &str) -> bool { | ||
| 647 | line.trim_start().starts_with("CRASH_BUSTED ") | ||
| 648 | } | ||
| 649 | |||
| 650 | /// Handle a server-initiated line. Returns `None` when the line is a normal | ||
| 651 | /// request/response reply and belongs to `handle_reply`. | ||
| 652 | pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { | ||
| 653 | if !self.is_async(line) { | ||
| 654 | return None; | ||
| 655 | } | ||
| 656 | match parse_crash_response(line) { | ||
| 657 | Ok(state) => Some(self.apply_crash(state)), | ||
| 658 | Err(message) => { | ||
| 659 | self.game.set_error(message); | ||
| 660 | Some(self.board_effects()) | ||
| 661 | } | ||
| 662 | } | ||
| 663 | } | ||
| 664 | |||
| 665 | fn apply_crash(&mut self, state: CrashState) -> Vec<Effect> { | ||
| 666 | self.balance = Some(state.balance); | ||
| 667 | if state.is_running() { | ||
| 668 | if self.crash_started.is_none() { | ||
| 669 | self.crash_started = Some(Instant::now()); | ||
| 670 | } | ||
| 671 | } else { | ||
| 672 | self.crash_started = None; | ||
| 673 | } | ||
| 674 | self.game.apply_crash_state(state); | ||
| 675 | self.board_effects() | ||
| 676 | } | ||
| 677 | |||
| 678 | fn handle_crash_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 679 | let status = self.game.crash_status(); | ||
| 680 | let running = status == "running"; | ||
| 681 | |||
| 682 | match key { | ||
| 683 | Key::CtrlC => vec![Effect::Quit], | ||
| 684 | Key::Char('q') => { | ||
| 685 | if running { | ||
| 686 | let Some(session) = self.game.crash_session_id() else { | ||
| 687 | return Vec::new(); | ||
| 688 | }; | ||
| 689 | self.pending = Some(Pending::CrashForfeit); | ||
| 690 | vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] | ||
| 691 | } else { | ||
| 692 | self.crash_started = None; | ||
| 693 | self.game.end_game(); | ||
| 694 | vec![ | ||
| 695 | Effect::ClearRegion, | ||
| 696 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 697 | ] | ||
| 698 | } | ||
| 699 | } | ||
| 700 | Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(), | ||
| 701 | Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(), | ||
| 702 | Key::Char('+') | Key::Char('=') if !running => { | ||
| 703 | let _ = self.game.adjust_bet(10); | ||
| 704 | self.board_effects() | ||
| 705 | } | ||
| 706 | Key::Char('-') if !running => { | ||
| 707 | let _ = self.game.adjust_bet(-10); | ||
| 708 | self.board_effects() | ||
| 709 | } | ||
| 710 | _ => Vec::new(), | ||
| 711 | } | ||
| 712 | } | ||
| 713 | |||
| 714 | fn start_crash(&mut self) -> Vec<Effect> { | ||
| 715 | let status = self.game.crash_status(); | ||
| 716 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { | ||
| 717 | return Vec::new(); | ||
| 718 | } | ||
| 719 | let Some(request) = self.game.spin_request() else { | ||
| 720 | return Vec::new(); | ||
| 721 | }; | ||
| 722 | if let Some(balance) = self.balance { | ||
| 723 | let bet = self | ||
| 724 | .game | ||
| 725 | .get_current_state() | ||
| 726 | .map(|state| state.bet_amount) | ||
| 727 | .unwrap_or(0); | ||
| 728 | if bet as i64 > balance { | ||
| 729 | self.game.set_error("Insufficient balance"); | ||
| 730 | return self.board_effects(); | ||
| 731 | } | ||
| 732 | } | ||
| 733 | |||
| 734 | self.game.set_crash_status("starting"); | ||
| 735 | self.pending = Some(Pending::CrashStart); | ||
| 736 | let mut effects = self.board_effects(); | ||
| 737 | effects.push(Effect::SendLine(request)); | ||
| 738 | effects | ||
| 739 | } | ||
| 740 | |||
| 741 | fn cash_out_crash(&mut self) -> Vec<Effect> { | ||
| 742 | let Some(session) = self.game.crash_session_id() else { | ||
| 743 | return Vec::new(); | ||
| 744 | }; | ||
| 745 | self.pending = Some(Pending::CrashCashout); | ||
| 746 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] | ||
| 747 | } | ||
| 748 | |||
| 562 | fn submit(&mut self) -> Vec<Effect> { | 749 | fn submit(&mut self) -> Vec<Effect> { |
| 563 | let line = self.input.trim().to_string(); | 750 | let line = self.input.trim().to_string(); |
| 564 | self.input.clear(); | 751 | self.input.clear(); |
| @@ -616,6 +803,7 @@ impl ClientCore { | |||
| 616 | self.connected = false; | 803 | self.connected = false; |
| 617 | self.logged_in = None; | 804 | self.logged_in = None; |
| 618 | self.pending = None; | 805 | self.pending = None; |
| 806 | self.crash_started = None; | ||
| 619 | self.game.end_game(); | 807 | self.game.end_game(); |
| 620 | vec![ | 808 | vec![ |
| 621 | Effect::ClearRegion, | 809 | Effect::ClearRegion, |
diff --git a/src/client/games/crash.rs b/src/client/games/crash.rs new file mode 100644 index 0000000..19733f2 --- /dev/null +++ b/src/client/games/crash.rs | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | use super::traits::{Game, GameState}; | ||
| 2 | |||
| 3 | pub const CRASH_BOARD_LINES: usize = 6; | ||
| 4 | |||
| 5 | pub struct CrashGame; | ||
| 6 | |||
| 7 | impl CrashGame { | ||
| 8 | pub fn new() -> Self { | ||
| 9 | Self | ||
| 10 | } | ||
| 11 | |||
| 12 | fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> { | ||
| 13 | state.data.get(key).map(String::as_str) | ||
| 14 | } | ||
| 15 | |||
| 16 | fn status_text(state: &GameState) -> String { | ||
| 17 | let status = Self::value(state, "status").unwrap_or("ready"); | ||
| 18 | match status { | ||
| 19 | "starting" => "Starting...".to_string(), | ||
| 20 | "running" => "Rising... cash out before it crashes.".to_string(), | ||
| 21 | "cashed" => format!( | ||
| 22 | "Cashed out at {}x Won {} PLN", | ||
| 23 | Self::value(state, "multiplier").unwrap_or("1.00"), | ||
| 24 | Self::value(state, "payout").unwrap_or("0") | ||
| 25 | ), | ||
| 26 | "crashed" => format!( | ||
| 27 | "CRASHED at {}x Bet lost", | ||
| 28 | Self::value(state, "crash_point").unwrap_or("?") | ||
| 29 | ), | ||
| 30 | "forfeit" => "Forfeited. Bet lost.".to_string(), | ||
| 31 | _ => "Press [SPACE] to start.".to_string(), | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | fn controls(state: &GameState) -> &'static str { | ||
| 36 | if Self::value(state, "status") == Some("running") { | ||
| 37 | "[C] Cash out [Q] Forfeit" | ||
| 38 | } else { | ||
| 39 | "[SPACE] Play [+/=/-] Bet [Q] Quit" | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | impl Game for CrashGame { | ||
| 45 | fn get_name(&self) -> &'static str { | ||
| 46 | "Crash" | ||
| 47 | } | ||
| 48 | |||
| 49 | fn render(&self, state: &GameState) -> String { | ||
| 50 | let balance = Self::value(state, "balance").unwrap_or("?"); | ||
| 51 | let multiplier = Self::value(state, "multiplier").unwrap_or("1.00"); | ||
| 52 | let potential = Self::value(state, "potential") | ||
| 53 | .map(str::to_string) | ||
| 54 | .unwrap_or_else(|| state.bet_amount.to_string()); | ||
| 55 | |||
| 56 | if let Some(error) = Self::value(state, "error") { | ||
| 57 | return format!( | ||
| 58 | "=== CRASH ===\r\nBalance: {balance} PLN Bet: {} PLN\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit", | ||
| 59 | state.bet_amount | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | format!( | ||
| 64 | "=== CRASH ===\r\nBalance: {} PLN Bet: {} PLN\r\nMultiplier: {}x\r\nPotential: {} PLN\r\n{}\r\n{}", | ||
| 65 | balance, | ||
| 66 | state.bet_amount, | ||
| 67 | multiplier, | ||
| 68 | potential, | ||
| 69 | Self::status_text(state), | ||
| 70 | Self::controls(state) | ||
| 71 | ) | ||
| 72 | } | ||
| 73 | |||
| 74 | fn render_lines(&self, _state: &GameState) -> usize { | ||
| 75 | CRASH_BOARD_LINES | ||
| 76 | } | ||
| 77 | |||
| 78 | fn get_help(&self) -> String { | ||
| 79 | "CRASH\n\ | ||
| 80 | =====\n\ | ||
| 81 | The multiplier rises from 1.00x and crashes at a hidden point.\n\ | ||
| 82 | Cash out before it crashes to win bet x multiplier.\n\ | ||
| 83 | A round can crash at 1.00x, so an instant cashout still risks the bet.\n\ | ||
| 84 | \n\ | ||
| 85 | C - cash out at the server's current multiplier\n\ | ||
| 86 | SPACE - start the next round\n\ | ||
| 87 | +/- - change the next round's bet\n\ | ||
| 88 | Q - forfeit an active round (bet is lost), or leave when idle" | ||
| 89 | .to_string() | ||
| 90 | } | ||
| 91 | } | ||
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 4e0218b..4d9e6a8 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs | |||
| @@ -1,9 +1,11 @@ | |||
| 1 | use super::blackjack::BlackjackGame; | 1 | use super::blackjack::BlackjackGame; |
| 2 | use super::crash::CrashGame; | ||
| 2 | use super::response::{RouletteResult, SpinResult}; | 3 | use super::response::{RouletteResult, SpinResult}; |
| 3 | use super::roulette::RouletteGame; | 4 | use super::roulette::RouletteGame; |
| 4 | use super::slots::SlotsGame; | 5 | use super::slots::SlotsGame; |
| 5 | use super::traits::{Game, GameError, GamePhase, GameState}; | 6 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 6 | use crate::blackjack::BlackjackState; | 7 | use crate::blackjack::BlackjackState; |
| 8 | use crate::crash::{payout, CrashState}; | ||
| 7 | use crate::roulette::Bets; | 9 | use crate::roulette::Bets; |
| 8 | use std::collections::HashMap; | 10 | use std::collections::HashMap; |
| 9 | 11 | ||
| @@ -18,6 +20,7 @@ impl GameManager { | |||
| 18 | games.insert("slots".to_string(), Box::new(SlotsGame::new())); | 20 | games.insert("slots".to_string(), Box::new(SlotsGame::new())); |
| 19 | games.insert("roulette".to_string(), Box::new(RouletteGame::new())); | 21 | games.insert("roulette".to_string(), Box::new(RouletteGame::new())); |
| 20 | games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); | 22 | games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); |
| 23 | games.insert("crash".to_string(), Box::new(CrashGame::new())); | ||
| 21 | 24 | ||
| 22 | Self { | 25 | Self { |
| 23 | current_session: None, | 26 | current_session: None, |
| @@ -57,6 +60,10 @@ impl GameManager { | |||
| 57 | } else if game_type == "blackjack" { | 60 | } else if game_type == "blackjack" { |
| 58 | data.insert("status".to_string(), "ready".to_string()); | 61 | data.insert("status".to_string(), "ready".to_string()); |
| 59 | 10 | 62 | 10 |
| 63 | } else if game_type == "crash" { | ||
| 64 | data.insert("status".to_string(), "ready".to_string()); | ||
| 65 | data.insert("multiplier".to_string(), "1.00".to_string()); | ||
| 66 | 10 | ||
| 60 | } else { | 67 | } else { |
| 61 | 10 | 68 | 10 |
| 62 | }; | 69 | }; |
| @@ -173,11 +180,19 @@ impl GameManager { | |||
| 173 | session.data.remove("winnings"); | 180 | session.data.remove("winnings"); |
| 174 | session.data.remove("number"); | 181 | session.data.remove("number"); |
| 175 | session.data.remove("color"); | 182 | session.data.remove("color"); |
| 183 | session.data.remove("payout"); | ||
| 184 | session.data.remove("crash_point"); | ||
| 176 | if session.game_type == "roulette" { | 185 | if session.game_type == "roulette" { |
| 177 | session | 186 | session |
| 178 | .data | 187 | .data |
| 179 | .insert("status".to_string(), "place".to_string()); | 188 | .insert("status".to_string(), "place".to_string()); |
| 180 | } | 189 | } |
| 190 | if session.game_type == "crash" { | ||
| 191 | session | ||
| 192 | .data | ||
| 193 | .insert("status".to_string(), "ready".to_string()); | ||
| 194 | session.data.remove("session_id"); | ||
| 195 | } | ||
| 181 | session.data.insert("error".to_string(), message.into()); | 196 | session.data.insert("error".to_string(), message.into()); |
| 182 | } | 197 | } |
| 183 | 198 | ||
| @@ -309,6 +324,81 @@ impl GameManager { | |||
| 309 | } | 324 | } |
| 310 | } | 325 | } |
| 311 | 326 | ||
| 327 | pub fn apply_crash_state(&mut self, state: CrashState) { | ||
| 328 | let Some(session) = self.current_session.as_mut() else { | ||
| 329 | return; | ||
| 330 | }; | ||
| 331 | let running = state.is_running(); | ||
| 332 | session.phase = if running { | ||
| 333 | GamePhase::Playing | ||
| 334 | } else { | ||
| 335 | GamePhase::Finished | ||
| 336 | }; | ||
| 337 | session.bet_amount = state.bet; | ||
| 338 | session.data.remove("error"); | ||
| 339 | for key in ["crash_point", "payout"] { | ||
| 340 | session.data.remove(key); | ||
| 341 | } | ||
| 342 | session.data.insert("status".to_string(), state.phase.clone()); | ||
| 343 | session | ||
| 344 | .data | ||
| 345 | .insert("session_id".to_string(), state.session_id); | ||
| 346 | session | ||
| 347 | .data | ||
| 348 | .insert("balance".to_string(), state.balance.to_string()); | ||
| 349 | session | ||
| 350 | .data | ||
| 351 | .insert("multiplier".to_string(), format!("{:.2}", state.multiplier)); | ||
| 352 | session | ||
| 353 | .data | ||
| 354 | .insert("potential".to_string(), state.potential.to_string()); | ||
| 355 | session | ||
| 356 | .data | ||
| 357 | .insert("payout".to_string(), state.payout.to_string()); | ||
| 358 | if let Some(crash_point) = state.crash_point { | ||
| 359 | session | ||
| 360 | .data | ||
| 361 | .insert("crash_point".to_string(), format!("{crash_point:.2}")); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | |||
| 365 | /// Redraw the live multiplier from the client's local clock. | ||
| 366 | pub fn set_crash_multiplier(&mut self, multiplier: f64) { | ||
| 367 | let Some(session) = self.current_session.as_mut() else { | ||
| 368 | return; | ||
| 369 | }; | ||
| 370 | let bet = session.bet_amount; | ||
| 371 | session | ||
| 372 | .data | ||
| 373 | .insert("multiplier".to_string(), format!("{multiplier:.2}")); | ||
| 374 | session | ||
| 375 | .data | ||
| 376 | .insert("potential".to_string(), payout(bet, multiplier).to_string()); | ||
| 377 | } | ||
| 378 | |||
| 379 | pub fn set_crash_status(&mut self, status: &str) { | ||
| 380 | if let Some(session) = self.current_session.as_mut() { | ||
| 381 | session | ||
| 382 | .data | ||
| 383 | .insert("status".to_string(), status.to_string()); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | pub fn crash_status(&self) -> String { | ||
| 388 | self.current_session | ||
| 389 | .as_ref() | ||
| 390 | .and_then(|session| session.data.get("status")) | ||
| 391 | .cloned() | ||
| 392 | .unwrap_or_else(|| "ready".to_string()) | ||
| 393 | } | ||
| 394 | |||
| 395 | pub fn crash_session_id(&self) -> Option<String> { | ||
| 396 | self.current_session | ||
| 397 | .as_ref() | ||
| 398 | .and_then(|session| session.data.get("session_id")) | ||
| 399 | .cloned() | ||
| 400 | } | ||
| 401 | |||
| 312 | pub fn get_current_game(&self) -> Option<&dyn Game> { | 402 | pub fn get_current_game(&self) -> Option<&dyn Game> { |
| 313 | self.current_session | 403 | self.current_session |
| 314 | .as_ref() | 404 | .as_ref() |
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index 40ba32a..e966a25 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod blackjack; | 1 | pub mod blackjack; |
| 2 | pub mod crash; | ||
| 2 | pub mod manager; | 3 | pub mod manager; |
| 3 | pub mod response; | 4 | pub mod response; |
| 4 | pub mod roulette; | 5 | pub mod roulette; |
diff --git a/src/client/games/response.rs b/src/client/games/response.rs index d9883f7..71e1fed 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs | |||
| @@ -163,3 +163,24 @@ pub fn parse_blackjack_response( | |||
| 163 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | 163 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; |
| 164 | serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) | 164 | serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) |
| 165 | } | 165 | } |
| 166 | |||
| 167 | pub fn parse_crash_response(response: &str) -> Result<crate::crash::CrashState, String> { | ||
| 168 | let response = response.trim(); | ||
| 169 | if response.is_empty() { | ||
| 170 | return Err("No response from server".to_string()); | ||
| 171 | } | ||
| 172 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 173 | let message = message.trim(); | ||
| 174 | return Err(if message.is_empty() { | ||
| 175 | "Server error".to_string() | ||
| 176 | } else { | ||
| 177 | message.to_string() | ||
| 178 | }); | ||
| 179 | } | ||
| 180 | |||
| 181 | let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] | ||
| 182 | .iter() | ||
| 183 | .find_map(|prefix| response.strip_prefix(prefix)) | ||
| 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | ||
| 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) | ||
| 186 | } | ||
diff --git a/src/crash.rs b/src/crash.rs new file mode 100644 index 0000000..55aab19 --- /dev/null +++ b/src/crash.rs | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | //! Shared crash game math and wire state. | ||
| 2 | //! | ||
| 3 | //! The multiplier is a pure function of elapsed time, so the client can draw it | ||
| 4 | //! locally. The crash point is drawn up front and never leaves the server until | ||
| 5 | //! the round ends; the payout at cashout is always computed from the server's | ||
| 6 | //! clock, never from anything the client sends. | ||
| 7 | |||
| 8 | use serde::{Deserialize, Serialize}; | ||
| 9 | use std::time::Duration; | ||
| 10 | |||
| 11 | /// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. | ||
| 12 | pub const GROWTH_RATE: f64 = 0.138_629_436_111_989_07; // ln(2) / 5 | ||
| 13 | /// House edge baked into the crash-point distribution. | ||
| 14 | pub const HOUSE_EDGE: f64 = 0.01; | ||
| 15 | /// Liability cap. At the cap `P(crash >= m)` stops following `edge / m`. | ||
| 16 | pub const MAX_MULTIPLIER: f64 = 1000.0; | ||
| 17 | |||
| 18 | /// Multiplier after `elapsed` seconds of a round. | ||
| 19 | pub fn multiplier_at(elapsed: Duration) -> f64 { | ||
| 20 | multiplier_at_secs(elapsed.as_secs_f64()) | ||
| 21 | } | ||
| 22 | |||
| 23 | pub fn multiplier_at_secs(seconds: f64) -> f64 { | ||
| 24 | if !seconds.is_finite() || seconds <= 0.0 { | ||
| 25 | return 1.0; | ||
| 26 | } | ||
| 27 | (GROWTH_RATE * seconds) | ||
| 28 | .exp() | ||
| 29 | .clamp(1.0, MAX_MULTIPLIER) | ||
| 30 | } | ||
| 31 | |||
| 32 | /// How long a round runs before reaching `multiplier`. | ||
| 33 | pub fn time_for_multiplier(multiplier: f64) -> Duration { | ||
| 34 | let multiplier = multiplier.clamp(1.0, MAX_MULTIPLIER); | ||
| 35 | Duration::from_secs_f64(multiplier.ln() / GROWTH_RATE) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Crash point for a uniform draw in `[0, 1)`. | ||
| 39 | /// | ||
| 40 | /// `crash = (1 - edge) / (1 - u)` gives `P(crash >= m) = edge_keep / m` for | ||
| 41 | /// every `m` in `[1, MAX_MULTIPLIER]`, so a fixed-target cashout returns | ||
| 42 | /// `(1 - edge)` of the stake on average. | ||
| 43 | pub fn crash_point_from_uniform(u: f64) -> f64 { | ||
| 44 | let u = if u.is_finite() { | ||
| 45 | u.clamp(0.0, 0.999_999_999) | ||
| 46 | } else { | ||
| 47 | 0.0 | ||
| 48 | }; | ||
| 49 | ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Whole-PLN cashout value. Balances are integers, so the payout is floored. | ||
| 53 | pub fn payout(bet: u64, multiplier: f64) -> u64 { | ||
| 54 | if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { | ||
| 55 | return 0; | ||
| 56 | } | ||
| 57 | let value = bet as f64 * multiplier.min(MAX_MULTIPLIER); | ||
| 58 | if value >= u64::MAX as f64 { | ||
| 59 | u64::MAX | ||
| 60 | } else { | ||
| 61 | value.floor() as u64 | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Round state as sent to the client. | ||
| 66 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 67 | pub struct CrashState { | ||
| 68 | pub session_id: String, | ||
| 69 | /// `running`, `cashed`, `crashed` or `forfeit`. | ||
| 70 | pub phase: String, | ||
| 71 | pub bet: u64, | ||
| 72 | pub balance: i64, | ||
| 73 | /// Live multiplier while running, otherwise the value the round ended at. | ||
| 74 | pub multiplier: f64, | ||
| 75 | /// `floor(bet * multiplier)` for the value above. | ||
| 76 | pub potential: u64, | ||
| 77 | /// Hidden until the round ends. | ||
| 78 | pub crash_point: Option<f64>, | ||
| 79 | pub payout: u64, | ||
| 80 | pub result: String, | ||
| 81 | } | ||
| 82 | |||
| 83 | impl CrashState { | ||
| 84 | pub fn is_running(&self) -> bool { | ||
| 85 | self.phase == "running" | ||
| 86 | } | ||
| 87 | } | ||
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | pub mod blackjack; | 8 | pub mod blackjack; |
| 9 | pub mod client; | 9 | pub mod client; |
| 10 | pub mod crash; | ||
| 10 | pub mod protocol; | 11 | pub mod protocol; |
| 11 | pub mod roulette; | 12 | pub mod roulette; |
| 12 | pub mod server; | 13 | pub mod server; |
diff --git a/src/server/core.rs b/src/server/core.rs index 259f936..d4c95f6 100644 --- a/src/server/core.rs +++ b/src/server/core.rs | |||
| @@ -1,10 +1,13 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | 1 | use bcrypt::{hash, verify, DEFAULT_COST}; |
| 2 | use rusqlite::Connection; | 2 | use rusqlite::Connection; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::HashMap; |
| 4 | use std::sync::Arc; | ||
| 4 | 5 | ||
| 5 | use crate::blackjack::BlackjackState; | 6 | use crate::blackjack::BlackjackState; |
| 7 | use crate::crash::CrashState; | ||
| 6 | use crate::server::database; | 8 | use crate::server::database; |
| 7 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; | 9 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; |
| 10 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; | ||
| 8 | use crate::server::games::registry::GameRegistry; | 11 | use crate::server::games::registry::GameRegistry; |
| 9 | use crate::server::games::session::SessionManager; | 12 | use crate::server::games::session::SessionManager; |
| 10 | use crate::server::games::traits::{GameError, GamePhase, GameState}; | 13 | use crate::server::games::traits::{GameError, GamePhase, GameState}; |
| @@ -26,6 +29,8 @@ pub struct ServerCore { | |||
| 26 | registry: GameRegistry, | 29 | registry: GameRegistry, |
| 27 | bcrypt_cost: u32, | 30 | bcrypt_cost: u32, |
| 28 | blackjack: BlackjackGame, | 31 | blackjack: BlackjackGame, |
| 32 | crash: CrashGame, | ||
| 33 | clock: Arc<dyn Clock>, | ||
| 29 | } | 34 | } |
| 30 | 35 | ||
| 31 | impl ServerCore { | 36 | impl ServerCore { |
| @@ -44,6 +49,8 @@ impl ServerCore { | |||
| 44 | registry, | 49 | registry, |
| 45 | bcrypt_cost, | 50 | bcrypt_cost, |
| 46 | blackjack: BlackjackGame::new(), | 51 | blackjack: BlackjackGame::new(), |
| 52 | crash: CrashGame::new(), | ||
| 53 | clock: Arc::new(RealClock::new()), | ||
| 47 | } | 54 | } |
| 48 | } | 55 | } |
| 49 | 56 | ||
| @@ -53,6 +60,22 @@ impl ServerCore { | |||
| 53 | self | 60 | self |
| 54 | } | 61 | } |
| 55 | 62 | ||
| 63 | /// Test/utility hook: queue the next crash-point draws. | ||
| 64 | pub fn with_crash_uniforms(mut self, uniforms: impl IntoIterator<Item = f64>) -> Self { | ||
| 65 | self.crash = CrashGame::with_uniforms(uniforms); | ||
| 66 | self | ||
| 67 | } | ||
| 68 | |||
| 69 | pub fn with_crash_uniform(self, uniform: f64) -> Self { | ||
| 70 | self.with_crash_uniforms([uniform]) | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Test/utility hook: replace the monotonic clock driving crash rounds. | ||
| 74 | pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self { | ||
| 75 | self.clock = clock; | ||
| 76 | self | ||
| 77 | } | ||
| 78 | |||
| 56 | pub fn open(path: &str) -> rusqlite::Result<Self> { | 79 | pub fn open(path: &str) -> rusqlite::Result<Self> { |
| 57 | let conn = Connection::open(path)?; | 80 | let conn = Connection::open(path)?; |
| 58 | database::init_db(&conn); | 81 | database::init_db(&conn); |
| @@ -98,6 +121,8 @@ impl ServerCore { | |||
| 98 | "PLAY_START" => { | 121 | "PLAY_START" => { |
| 99 | if parts.len() == 3 && parts[1] == "blackjack" { | 122 | if parts.len() == 3 && parts[1] == "blackjack" { |
| 100 | self.handle_blackjack_start(state, parts[2]) | 123 | self.handle_blackjack_start(state, parts[2]) |
| 124 | } else if parts.len() == 3 && parts[1] == "crash" { | ||
| 125 | self.handle_crash_start(state, parts[2]) | ||
| 101 | } else if parts.len() != 3 && parts.len() != 4 { | 126 | } else if parts.len() != 3 && parts.len() != 4 { |
| 102 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" | 127 | "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" |
| 103 | .to_string() | 128 | .to_string() |
| @@ -105,6 +130,22 @@ impl ServerCore { | |||
| 105 | self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) | 130 | self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) |
| 106 | } | 131 | } |
| 107 | } | 132 | } |
| 133 | "CRASH_CASHOUT" => { | ||
| 134 | if parts.len() != 2 { | ||
| 135 | "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>" | ||
| 136 | .to_string() | ||
| 137 | } else { | ||
| 138 | self.handle_crash_end(state, parts[1], false) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | "CRASH_FORFEIT" => { | ||
| 142 | if parts.len() != 2 { | ||
| 143 | "ERROR Invalid CRASH_FORFEIT format. Usage: CRASH_FORFEIT <session>" | ||
| 144 | .to_string() | ||
| 145 | } else { | ||
| 146 | self.handle_crash_end(state, parts[1], true) | ||
| 147 | } | ||
| 148 | } | ||
| 108 | "BLACKJACK_ACTION" => { | 149 | "BLACKJACK_ACTION" => { |
| 109 | if parts.len() != 3 { | 150 | if parts.len() != 3 { |
| 110 | "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>" | 151 | "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>" |
| @@ -371,6 +412,175 @@ impl ServerCore { | |||
| 371 | } | 412 | } |
| 372 | } | 413 | } |
| 373 | 414 | ||
| 415 | fn handle_crash_start(&mut self, state: &ConnectionState, bet_str: &str) -> String { | ||
| 416 | let Some(user) = state.user.as_deref() else { | ||
| 417 | return "ERROR Not logged in".to_string(); | ||
| 418 | }; | ||
| 419 | if self | ||
| 420 | .sessions | ||
| 421 | .get_user_sessions(user) | ||
| 422 | .iter() | ||
| 423 | .any(|session| session.game_type == "crash") | ||
| 424 | { | ||
| 425 | return "ERROR Crash round already in progress".to_string(); | ||
| 426 | } | ||
| 427 | let bet: i64 = match bet_str.parse() { | ||
| 428 | Ok(bet) => bet, | ||
| 429 | Err(_) => return "ERROR Invalid bet amount".to_string(), | ||
| 430 | }; | ||
| 431 | if bet <= 0 { | ||
| 432 | return "ERROR Invalid bet amount".to_string(); | ||
| 433 | } | ||
| 434 | let Some(balance) = database::get_balance(&self.conn, user) else { | ||
| 435 | return "ERROR Could not retrieve balance".to_string(); | ||
| 436 | }; | ||
| 437 | if balance < bet { | ||
| 438 | return "ERROR Insufficient balance".to_string(); | ||
| 439 | } | ||
| 440 | |||
| 441 | // The session is the audit guard for the deduction, so it exists before | ||
| 442 | // the balance is touched. | ||
| 443 | let crash_point = self.crash.next_crash_point(); | ||
| 444 | let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); | ||
| 445 | let mut data = HashMap::new(); | ||
| 446 | data.insert("round".to_string(), round.to_json()); | ||
| 447 | data.insert("balance".to_string(), balance.to_string()); | ||
| 448 | let game_state = GameState { | ||
| 449 | session_id: String::new(), | ||
| 450 | game_type: "crash".to_string(), | ||
| 451 | bet_amount: round.bet, | ||
| 452 | phase: GamePhase::Playing, | ||
| 453 | data, | ||
| 454 | }; | ||
| 455 | let session_id = self | ||
| 456 | .sessions | ||
| 457 | .create_session(user, "crash".to_string(), game_state); | ||
| 458 | |||
| 459 | let balance = match database::debit(&mut self.conn, user, bet) { | ||
| 460 | Ok(balance) => balance, | ||
| 461 | Err(_) => { | ||
| 462 | let _ = self.sessions.remove_session(&session_id); | ||
| 463 | return "ERROR Insufficient balance".to_string(); | ||
| 464 | } | ||
| 465 | }; | ||
| 466 | if let Ok(mut session) = self.sessions.get_session(&session_id) { | ||
| 467 | session | ||
| 468 | .state | ||
| 469 | .data | ||
| 470 | .insert("balance".to_string(), balance.to_string()); | ||
| 471 | let _ = self.sessions.update_session(&session_id, session.state); | ||
| 472 | } | ||
| 473 | |||
| 474 | let state = round.state(&session_id, balance); | ||
| 475 | crash_wire("CRASH_START", &state) | ||
| 476 | } | ||
| 477 | |||
| 478 | /// Cash out or forfeit. The multiplier comes from the server clock at the | ||
| 479 | /// moment the request is handled; the client never supplies one. | ||
| 480 | fn handle_crash_end(&mut self, state: &ConnectionState, session_id: &str, forfeit: bool) -> String { | ||
| 481 | let Some(user) = state.user.as_deref() else { | ||
| 482 | return "ERROR Not logged in".to_string(); | ||
| 483 | }; | ||
| 484 | let Ok(session) = self.sessions.get_session(session_id) else { | ||
| 485 | return "ERROR Session not found".to_string(); | ||
| 486 | }; | ||
| 487 | if session.username != user || session.game_type != "crash" { | ||
| 488 | return "ERROR Invalid session".to_string(); | ||
| 489 | } | ||
| 490 | let Some(raw) = session.state.data.get("round") else { | ||
| 491 | return "ERROR Missing round".to_string(); | ||
| 492 | }; | ||
| 493 | let Ok(mut round) = CrashRound::from_json(raw) else { | ||
| 494 | return "ERROR Invalid round".to_string(); | ||
| 495 | }; | ||
| 496 | if !round.is_running() { | ||
| 497 | return "ERROR Round already ended".to_string(); | ||
| 498 | } | ||
| 499 | |||
| 500 | let elapsed = (self.clock.now() - round.started_at).max(0.0); | ||
| 501 | let multiplier = crate::crash::multiplier_at_secs(elapsed); | ||
| 502 | let payout = if forfeit { | ||
| 503 | round.multiplier = multiplier; | ||
| 504 | round.forfeit(); | ||
| 505 | 0 | ||
| 506 | } else if multiplier < round.crash_point { | ||
| 507 | round.cash_out(multiplier) | ||
| 508 | } else { | ||
| 509 | round.crash(); | ||
| 510 | 0 | ||
| 511 | }; | ||
| 512 | |||
| 513 | match self.settle_crash(user, session_id, &round, payout) { | ||
| 514 | Ok(balance) => { | ||
| 515 | let state = round.state(session_id, balance); | ||
| 516 | crash_wire("CRASH_STATE", &state) | ||
| 517 | } | ||
| 518 | Err(message) => { | ||
| 519 | eprintln!("Crash settlement failed for {user}: {message}"); | ||
| 520 | "ERROR Failed to update balance".to_string() | ||
| 521 | } | ||
| 522 | } | ||
| 523 | } | ||
| 524 | |||
| 525 | /// Server-driven end of a round. `handler` calls this on every socket | ||
| 526 | /// timeout and forwards whatever lines come back. | ||
| 527 | pub fn poll(&mut self, state: &ConnectionState) -> Vec<String> { | ||
| 528 | let Some(user) = state.user.as_deref() else { | ||
| 529 | return Vec::new(); | ||
| 530 | }; | ||
| 531 | let mut out = Vec::new(); | ||
| 532 | for session in self.sessions.get_user_sessions(user) { | ||
| 533 | if session.game_type != "crash" { | ||
| 534 | continue; | ||
| 535 | } | ||
| 536 | let Some(raw) = session.state.data.get("round") else { | ||
| 537 | continue; | ||
| 538 | }; | ||
| 539 | let Ok(mut round) = CrashRound::from_json(raw) else { | ||
| 540 | continue; | ||
| 541 | }; | ||
| 542 | if !round.is_running() { | ||
| 543 | continue; | ||
| 544 | } | ||
| 545 | let elapsed = (self.clock.now() - round.started_at).max(0.0); | ||
| 546 | if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { | ||
| 547 | continue; | ||
| 548 | } | ||
| 549 | round.crash(); | ||
| 550 | match self.settle_crash(user, &session.session_id, &round, 0) { | ||
| 551 | Ok(balance) => { | ||
| 552 | let state = round.state(&session.session_id, balance); | ||
| 553 | out.push(crash_wire("CRASH_BUSTED", &state)); | ||
| 554 | } | ||
| 555 | Err(message) => { | ||
| 556 | eprintln!("Crash push settlement failed for {user}: {message}"); | ||
| 557 | } | ||
| 558 | } | ||
| 559 | } | ||
| 560 | out | ||
| 561 | } | ||
| 562 | |||
| 563 | fn settle_crash( | ||
| 564 | &mut self, | ||
| 565 | user: &str, | ||
| 566 | session_id: &str, | ||
| 567 | round: &CrashRound, | ||
| 568 | payout: u64, | ||
| 569 | ) -> Result<i64, String> { | ||
| 570 | let details = round.to_json(); | ||
| 571 | let result = database::settle_play( | ||
| 572 | &mut self.conn, | ||
| 573 | session_id, | ||
| 574 | user, | ||
| 575 | "crash", | ||
| 576 | round.bet as i64, | ||
| 577 | payout as i64, | ||
| 578 | &details, | ||
| 579 | ); | ||
| 580 | let _ = self.sessions.remove_session(session_id); | ||
| 581 | result.map_err(|error| error.to_string()) | ||
| 582 | } | ||
| 583 | |||
| 374 | fn handle_play_start( | 584 | fn handle_play_start( |
| 375 | &mut self, | 585 | &mut self, |
| 376 | state: &ConnectionState, | 586 | state: &ConnectionState, |
| @@ -527,6 +737,29 @@ impl ServerCore { | |||
| 527 | } | 737 | } |
| 528 | } | 738 | } |
| 529 | } | 739 | } |
| 740 | if session.game_type == "crash" { | ||
| 741 | if let Some(raw) = session.state.data.get("round") { | ||
| 742 | if let Ok(mut round) = CrashRound::from_json(raw) { | ||
| 743 | if round.is_running() { | ||
| 744 | round.forfeit(); | ||
| 745 | let details = round.to_json(); | ||
| 746 | if let Err(error) = database::settle_play( | ||
| 747 | &mut self.conn, | ||
| 748 | &session.session_id, | ||
| 749 | user, | ||
| 750 | "crash", | ||
| 751 | round.bet as i64, | ||
| 752 | 0, | ||
| 753 | &details, | ||
| 754 | ) { | ||
| 755 | eprintln!( | ||
| 756 | "Crash disconnect forfeit failed for {user}: {error}" | ||
| 757 | ); | ||
| 758 | } | ||
| 759 | } | ||
| 760 | } | ||
| 761 | } | ||
| 762 | } | ||
| 530 | let _ = self.sessions.remove_session(&session.session_id); | 763 | let _ = self.sessions.remove_session(&session.session_id); |
| 531 | } | 764 | } |
| 532 | } | 765 | } |
| @@ -562,3 +795,10 @@ fn blackjack_wire(state: &BlackjackState) -> String { | |||
| 562 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) | 795 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) |
| 563 | ) | 796 | ) |
| 564 | } | 797 | } |
| 798 | |||
| 799 | fn crash_wire(prefix: &str, state: &CrashState) -> String { | ||
| 800 | format!( | ||
| 801 | "{prefix} {}", | ||
| 802 | serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) | ||
| 803 | ) | ||
| 804 | } | ||
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs new file mode 100644 index 0000000..ba73c7f --- /dev/null +++ b/src/server/games/crash.rs | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | ||
| 2 | use crate::crash::{crash_point_from_uniform, payout, CrashState}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use std::collections::{HashMap, VecDeque}; | ||
| 5 | use std::sync::{Arc, Mutex}; | ||
| 6 | use std::time::Instant; | ||
| 7 | |||
| 8 | /// Monotonic seconds. Injected so tests can drive a round without sleeping. | ||
| 9 | pub trait Clock: Send + Sync { | ||
| 10 | fn now(&self) -> f64; | ||
| 11 | } | ||
| 12 | |||
| 13 | pub struct RealClock { | ||
| 14 | origin: Instant, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl RealClock { | ||
| 18 | pub fn new() -> Self { | ||
| 19 | Self { | ||
| 20 | origin: Instant::now(), | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | impl Clock for RealClock { | ||
| 26 | fn now(&self) -> f64 { | ||
| 27 | self.origin.elapsed().as_secs_f64() | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | /// A clock a test can move by hand. Clones share the same instant. | ||
| 32 | #[derive(Clone, Default)] | ||
| 33 | pub struct ManualClock { | ||
| 34 | seconds: Arc<Mutex<f64>>, | ||
| 35 | } | ||
| 36 | |||
| 37 | impl ManualClock { | ||
| 38 | pub fn new() -> Self { | ||
| 39 | Self::default() | ||
| 40 | } | ||
| 41 | |||
| 42 | pub fn set(&self, seconds: f64) { | ||
| 43 | *self.seconds.lock().unwrap() = seconds; | ||
| 44 | } | ||
| 45 | |||
| 46 | pub fn advance(&self, seconds: f64) { | ||
| 47 | *self.seconds.lock().unwrap() += seconds; | ||
| 48 | } | ||
| 49 | |||
| 50 | pub fn seconds(&self) -> f64 { | ||
| 51 | *self.seconds.lock().unwrap() | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | impl Clock for ManualClock { | ||
| 56 | fn now(&self) -> f64 { | ||
| 57 | self.seconds() | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// One crash round. Lives in the game session's `data` while it runs. | ||
| 62 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 63 | pub struct CrashRound { | ||
| 64 | pub bet: u64, | ||
| 65 | /// Drawn at start, revealed only when the round ends. | ||
| 66 | pub crash_point: f64, | ||
| 67 | pub started_at: f64, | ||
| 68 | /// `running`, `cashed`, `crashed` or `forfeit`. | ||
| 69 | pub phase: String, | ||
| 70 | pub result: String, | ||
| 71 | /// Live while running; frozen at the final value afterwards. | ||
| 72 | pub multiplier: f64, | ||
| 73 | pub payout: u64, | ||
| 74 | } | ||
| 75 | |||
| 76 | impl CrashRound { | ||
| 77 | pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { | ||
| 78 | Self { | ||
| 79 | bet, | ||
| 80 | crash_point, | ||
| 81 | started_at, | ||
| 82 | phase: "running".to_string(), | ||
| 83 | result: "playing".to_string(), | ||
| 84 | multiplier: 1.0, | ||
| 85 | payout: 0, | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn is_running(&self) -> bool { | ||
| 90 | self.phase == "running" | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn cash_out(&mut self, multiplier: f64) -> u64 { | ||
| 94 | self.multiplier = multiplier; | ||
| 95 | self.payout = payout(self.bet, multiplier); | ||
| 96 | self.phase = "cashed".to_string(); | ||
| 97 | self.result = "cashed".to_string(); | ||
| 98 | self.payout | ||
| 99 | } | ||
| 100 | |||
| 101 | /// The round ended before the player could cash out. | ||
| 102 | pub fn crash(&mut self) { | ||
| 103 | self.multiplier = self.crash_point; | ||
| 104 | self.payout = 0; | ||
| 105 | self.phase = "crashed".to_string(); | ||
| 106 | self.result = "crashed".to_string(); | ||
| 107 | } | ||
| 108 | |||
| 109 | pub fn forfeit(&mut self) { | ||
| 110 | self.payout = 0; | ||
| 111 | self.phase = "forfeit".to_string(); | ||
| 112 | self.result = "forfeit".to_string(); | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn state(&self, session_id: &str, balance: i64) -> CrashState { | ||
| 116 | CrashState { | ||
| 117 | session_id: session_id.to_string(), | ||
| 118 | phase: self.phase.clone(), | ||
| 119 | bet: self.bet, | ||
| 120 | balance, | ||
| 121 | multiplier: self.multiplier, | ||
| 122 | potential: payout(self.bet, self.multiplier), | ||
| 123 | crash_point: if self.is_running() { | ||
| 124 | None | ||
| 125 | } else { | ||
| 126 | Some(self.crash_point) | ||
| 127 | }, | ||
| 128 | payout: self.payout, | ||
| 129 | result: self.result.clone(), | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | pub fn to_json(&self) -> String { | ||
| 134 | serde_json::to_string(self).unwrap_or_default() | ||
| 135 | } | ||
| 136 | |||
| 137 | pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> { | ||
| 138 | serde_json::from_str(raw) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Crash-point generator. Tests queue fixed uniforms; production draws random | ||
| 143 | /// ones. The visible crash point stays hidden until `poll` or cashout ends the | ||
| 144 | /// round. | ||
| 145 | pub struct CrashGame { | ||
| 146 | uniforms: VecDeque<f64>, | ||
| 147 | } | ||
| 148 | |||
| 149 | impl CrashGame { | ||
| 150 | pub fn new() -> Self { | ||
| 151 | Self { | ||
| 152 | uniforms: VecDeque::new(), | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | pub fn with_uniform(uniform: f64) -> Self { | ||
| 157 | Self::with_uniforms([uniform]) | ||
| 158 | } | ||
| 159 | |||
| 160 | pub fn with_uniforms(uniforms: impl IntoIterator<Item = f64>) -> Self { | ||
| 161 | Self { | ||
| 162 | uniforms: uniforms.into_iter().collect(), | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | pub fn next_crash_point(&mut self) -> f64 { | ||
| 167 | let uniform = self | ||
| 168 | .uniforms | ||
| 169 | .pop_front() | ||
| 170 | .unwrap_or_else(rand::random::<f64>); | ||
| 171 | crash_point_from_uniform(uniform) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | impl Game for CrashGame { | ||
| 176 | fn game_type(&self) -> &'static str { | ||
| 177 | "crash" | ||
| 178 | } | ||
| 179 | |||
| 180 | fn get_name(&self) -> &'static str { | ||
| 181 | "Crash" | ||
| 182 | } | ||
| 183 | |||
| 184 | fn get_description(&self) -> &'static str { | ||
| 185 | "Crash: cash out before the multiplier crashes." | ||
| 186 | } | ||
| 187 | |||
| 188 | fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { | ||
| 189 | let mut data = HashMap::new(); | ||
| 190 | data.insert("status".to_string(), "ready".to_string()); | ||
| 191 | GameState { | ||
| 192 | session_id: String::new(), | ||
| 193 | game_type: "crash".to_string(), | ||
| 194 | bet_amount, | ||
| 195 | phase: GamePhase::WaitingToStart, | ||
| 196 | data, | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | fn process_action( | ||
| 201 | &self, | ||
| 202 | _state: &GameState, | ||
| 203 | _action: &str, | ||
| 204 | _params: &[&str], | ||
| 205 | ) -> Result<GameState, GameError> { | ||
| 206 | Err(GameError::InvalidAction( | ||
| 207 | "Crash uses CRASH_CASHOUT and CRASH_FORFEIT".to_string(), | ||
| 208 | )) | ||
| 209 | } | ||
| 210 | |||
| 211 | fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> { | ||
| 212 | Err(GameError::InternalError( | ||
| 213 | "Crash settles through CRASH_CASHOUT".to_string(), | ||
| 214 | )) | ||
| 215 | } | ||
| 216 | } | ||
diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs index 07c5772..5de9eef 100644 --- a/src/server/games/mod.rs +++ b/src/server/games/mod.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod blackjack; | 1 | pub mod blackjack; |
| 2 | pub mod crash; | ||
| 2 | pub mod registry; | 3 | pub mod registry; |
| 3 | pub mod roulette; | 4 | pub mod roulette; |
| 4 | pub mod session; | 5 | pub mod session; |
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs index 7ff8fbf..7a8b605 100644 --- a/src/server/games/registry.rs +++ b/src/server/games/registry.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | use super::blackjack::BlackjackGame; | 3 | use super::blackjack::BlackjackGame; |
| 4 | use super::crash::CrashGame; | ||
| 4 | use super::roulette::RouletteGame; | 5 | use super::roulette::RouletteGame; |
| 5 | use super::slots::SlotsGame; | 6 | use super::slots::SlotsGame; |
| 6 | use super::traits::Game; | 7 | use super::traits::Game; |
| @@ -25,6 +26,7 @@ impl GameRegistry { | |||
| 25 | registry.register_game(SlotsGame::new()); | 26 | registry.register_game(SlotsGame::new()); |
| 26 | registry.register_game(RouletteGame::new()); | 27 | registry.register_game(RouletteGame::new()); |
| 27 | registry.register_game(BlackjackGame::new()); | 28 | registry.register_game(BlackjackGame::new()); |
| 29 | registry.register_game(CrashGame::new()); | ||
| 28 | registry | 30 | registry |
| 29 | } | 31 | } |
| 30 | 32 | ||
diff --git a/tests/client_core.rs b/tests/client_core.rs index ec444bd..e8b5646 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -6,8 +6,12 @@ | |||
| 6 | 6 | ||
| 7 | use zagan_bet::blackjack::BlackjackState; | 7 | use zagan_bet::blackjack::BlackjackState; |
| 8 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 8 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 9 | use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; | 9 | use zagan_bet::client::games::response::{ |
| 10 | parse_blackjack_response, parse_crash_response, parse_spin_response, | ||
| 11 | }; | ||
| 10 | use zagan_bet::client::ui::command_names; | 12 | use zagan_bet::client::ui::command_names; |
| 13 | use zagan_bet::crash::CrashState; | ||
| 14 | use std::time::Duration; | ||
| 11 | 15 | ||
| 12 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 16 | const WIN3: &str = "CCCCLLCLODGSDGS"; |
| 13 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | 17 | const LOSS: &str = "CLOOGDSLCDCOGLS"; |
| @@ -145,6 +149,25 @@ impl Harness { | |||
| 145 | self.line("play blackjack"); | 149 | self.line("play blackjack"); |
| 146 | self.reply("INFO alice 1 1000"); | 150 | self.reply("INFO alice 1 1000"); |
| 147 | } | 151 | } |
| 152 | |||
| 153 | fn enter_crash(&mut self) { | ||
| 154 | self.line("play crash"); | ||
| 155 | self.reply("INFO alice 1 1000"); | ||
| 156 | } | ||
| 157 | |||
| 158 | fn tick(&mut self, elapsed: Duration) { | ||
| 159 | let effects = self.core.tick_at(elapsed); | ||
| 160 | self.apply(effects); | ||
| 161 | } | ||
| 162 | |||
| 163 | /// Deliver a server-initiated line the way the terminal loop does. | ||
| 164 | fn push(&mut self, line: &str) { | ||
| 165 | let effects = self | ||
| 166 | .core | ||
| 167 | .handle_async_line(line) | ||
| 168 | .expect("a pushed crash line"); | ||
| 169 | self.apply(effects); | ||
| 170 | } | ||
| 148 | } | 171 | } |
| 149 | 172 | ||
| 150 | #[test] | 173 | #[test] |
| @@ -851,3 +874,188 @@ fn parses_a_blackjack_state() { | |||
| 851 | assert_eq!(parse_blackjack_response(&response).unwrap(), state); | 874 | assert_eq!(parse_blackjack_response(&response).unwrap(), state); |
| 852 | assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); | 875 | assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); |
| 853 | } | 876 | } |
| 877 | |||
| 878 | fn crash_state(phase: &str, multiplier: f64, crash_point: Option<f64>, payout: u64, balance: i64) -> CrashState { | ||
| 879 | CrashState { | ||
| 880 | session_id: "cr1".to_string(), | ||
| 881 | phase: phase.to_string(), | ||
| 882 | bet: 10, | ||
| 883 | balance, | ||
| 884 | multiplier, | ||
| 885 | potential: (10.0 * multiplier) as u64, | ||
| 886 | crash_point, | ||
| 887 | payout, | ||
| 888 | result: phase.to_string(), | ||
| 889 | } | ||
| 890 | } | ||
| 891 | |||
| 892 | fn crash_line(prefix: &str, state: &CrashState) -> String { | ||
| 893 | format!("{prefix} {}", serde_json::to_string(state).unwrap()) | ||
| 894 | } | ||
| 895 | |||
| 896 | #[test] | ||
| 897 | fn crash_starts_a_round_and_draws_the_multiplier_locally() { | ||
| 898 | let mut h = Harness::new(); | ||
| 899 | h.enter_crash(); | ||
| 900 | |||
| 901 | assert_eq!(h.board().len(), 6); | ||
| 902 | assert!(h.board()[4].contains("Press [SPACE] to start.")); | ||
| 903 | assert!(h.board()[5].contains("[SPACE] Play")); | ||
| 904 | |||
| 905 | h.key(Key::Char(' ')); | ||
| 906 | assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10")); | ||
| 907 | |||
| 908 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 909 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 910 | |||
| 911 | assert_eq!(h.board().len(), 6); | ||
| 912 | assert!(h.board()[2].contains("Multiplier: 1.00x")); | ||
| 913 | assert!(h.board()[3].contains("Potential: 10 PLN")); | ||
| 914 | assert!(h.board()[4].contains("Rising")); | ||
| 915 | assert!(h.board()[5].contains("[C] Cash out")); | ||
| 916 | |||
| 917 | // The multiplier is drawn from the local clock, with no server round trip. | ||
| 918 | h.tick(Duration::from_secs(5)); | ||
| 919 | assert!(h.board()[2].contains("Multiplier: 2.00x")); | ||
| 920 | assert!(h.board()[3].contains("Potential: 20 PLN")); | ||
| 921 | |||
| 922 | h.key(Key::Char('c')); | ||
| 923 | assert_eq!( | ||
| 924 | h.sent.last().map(String::as_str), | ||
| 925 | Some("CRASH_CASHOUT cr1") | ||
| 926 | ); | ||
| 927 | |||
| 928 | let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); | ||
| 929 | h.reply(&crash_line("CRASH_STATE", &cashed)); | ||
| 930 | |||
| 931 | assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN")); | ||
| 932 | assert!(h.board()[5].contains("[SPACE] Play")); | ||
| 933 | assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN"); | ||
| 934 | } | ||
| 935 | |||
| 936 | #[test] | ||
| 937 | fn a_pushed_bust_ends_the_round_without_a_request() { | ||
| 938 | let mut h = Harness::new(); | ||
| 939 | h.enter_crash(); | ||
| 940 | h.key(Key::Char(' ')); | ||
| 941 | |||
| 942 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 943 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 944 | h.tick(Duration::from_secs(3)); | ||
| 945 | |||
| 946 | assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1")); | ||
| 947 | |||
| 948 | let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); | ||
| 949 | h.push(&crash_line("CRASH_BUSTED", &busted)); | ||
| 950 | |||
| 951 | assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost")); | ||
| 952 | assert!(h.board()[5].contains("[SPACE] Play")); | ||
| 953 | |||
| 954 | // The local clock stops once the round is over. | ||
| 955 | h.tick(Duration::from_secs(30)); | ||
| 956 | assert!(h.board()[2].contains("Multiplier: 1.50x")); | ||
| 957 | assert!(!h.core.expects_push()); | ||
| 958 | } | ||
| 959 | |||
| 960 | #[test] | ||
| 961 | fn crash_q_forfeits_an_active_round() { | ||
| 962 | let mut h = Harness::new(); | ||
| 963 | h.enter_crash(); | ||
| 964 | h.key(Key::Char(' ')); | ||
| 965 | |||
| 966 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 967 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 968 | |||
| 969 | h.key(Key::Char('q')); | ||
| 970 | assert_eq!( | ||
| 971 | h.sent.last().map(String::as_str), | ||
| 972 | Some("CRASH_FORFEIT cr1") | ||
| 973 | ); | ||
| 974 | |||
| 975 | let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990); | ||
| 976 | h.reply(&crash_line("CRASH_STATE", &forfeited)); | ||
| 977 | |||
| 978 | assert!(h.board().is_empty()); | ||
| 979 | assert!(h | ||
| 980 | .screen | ||
| 981 | .lines | ||
| 982 | .iter() | ||
| 983 | .any(|line| line.contains("Round forfeited (10 PLN lost)"))); | ||
| 984 | } | ||
| 985 | |||
| 986 | #[test] | ||
| 987 | fn crash_ready_board_changes_the_next_bet() { | ||
| 988 | let mut h = Harness::new(); | ||
| 989 | h.enter_crash(); | ||
| 990 | |||
| 991 | h.key(Key::Char('=')); | ||
| 992 | assert!(h.board()[1].contains("Bet: 20 PLN")); | ||
| 993 | assert!(h.board()[3].contains("Potential: 20 PLN")); | ||
| 994 | |||
| 995 | h.key(Key::Char(' ')); | ||
| 996 | assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20")); | ||
| 997 | } | ||
| 998 | |||
| 999 | #[test] | ||
| 1000 | fn parses_crash_messages_and_errors() { | ||
| 1001 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 1002 | let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); | ||
| 1003 | |||
| 1004 | assert_eq!( | ||
| 1005 | parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(), | ||
| 1006 | running | ||
| 1007 | ); | ||
| 1008 | assert_eq!( | ||
| 1009 | parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(), | ||
| 1010 | cashed | ||
| 1011 | ); | ||
| 1012 | assert_eq!( | ||
| 1013 | parse_crash_response("ERROR Round already ended").unwrap_err(), | ||
| 1014 | "Round already ended" | ||
| 1015 | ); | ||
| 1016 | assert!(parse_crash_response("GAME_RESULT nope").is_err()); | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | #[test] | ||
| 1020 | fn a_late_cashout_does_not_overwrite_a_pushed_bust() { | ||
| 1021 | let mut h = Harness::new(); | ||
| 1022 | h.enter_crash(); | ||
| 1023 | h.key(Key::Char(' ')); | ||
| 1024 | |||
| 1025 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 1026 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 1027 | |||
| 1028 | h.key(Key::Char('c')); | ||
| 1029 | assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1")); | ||
| 1030 | |||
| 1031 | // The round crashes while the cashout is in flight, so the push lands | ||
| 1032 | // before the server's "session not found" reply. | ||
| 1033 | let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); | ||
| 1034 | h.push(&crash_line("CRASH_BUSTED", &busted)); | ||
| 1035 | h.reply("ERROR Session not found"); | ||
| 1036 | |||
| 1037 | assert!(h.board()[4].contains("CRASHED at 1.20x")); | ||
| 1038 | assert!(!h.board().iter().any(|line| line.contains("ERROR"))); | ||
| 1039 | assert!(h.board()[5].contains("[SPACE] Play")); | ||
| 1040 | } | ||
| 1041 | |||
| 1042 | #[test] | ||
| 1043 | fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() { | ||
| 1044 | let mut h = Harness::new(); | ||
| 1045 | h.enter_crash(); | ||
| 1046 | h.key(Key::Char(' ')); | ||
| 1047 | |||
| 1048 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 1049 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 1050 | |||
| 1051 | h.key(Key::Char('q')); | ||
| 1052 | assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1")); | ||
| 1053 | |||
| 1054 | let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); | ||
| 1055 | h.push(&crash_line("CRASH_BUSTED", &busted)); | ||
| 1056 | h.reply("ERROR Session not found"); | ||
| 1057 | |||
| 1058 | assert!(h.board().is_empty()); | ||
| 1059 | assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); | ||
| 1060 | assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); | ||
| 1061 | } | ||
diff --git a/tests/crash.rs b/tests/crash.rs new file mode 100644 index 0000000..f59c52f --- /dev/null +++ b/tests/crash.rs | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | //! Crash math: the curve, the payout floor, and the house edge. | ||
| 2 | |||
| 3 | use std::time::Duration; | ||
| 4 | use zagan_bet::crash::{ | ||
| 5 | crash_point_from_uniform, multiplier_at, multiplier_at_secs, payout, time_for_multiplier, | ||
| 6 | HOUSE_EDGE, MAX_MULTIPLIER, | ||
| 7 | }; | ||
| 8 | |||
| 9 | fn close(a: f64, b: f64, tolerance: f64) { | ||
| 10 | assert!( | ||
| 11 | (a - b).abs() <= tolerance, | ||
| 12 | "expected {b} +/- {tolerance}, got {a}" | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | #[test] | ||
| 17 | fn the_multiplier_doubles_every_five_seconds() { | ||
| 18 | close(multiplier_at(Duration::ZERO), 1.0, 1e-9); | ||
| 19 | close(multiplier_at(Duration::from_secs(5)), 2.0, 1e-9); | ||
| 20 | close(multiplier_at(Duration::from_secs(10)), 4.0, 1e-6); | ||
| 21 | close(multiplier_at(Duration::from_millis(2500)), 1.414_213_562, 1e-6); | ||
| 22 | |||
| 23 | // Never below 1x, even for nonsense input. | ||
| 24 | assert_eq!(multiplier_at_secs(-3.0), 1.0); | ||
| 25 | assert_eq!(multiplier_at_secs(f64::NAN), 1.0); | ||
| 26 | assert_eq!(multiplier_at_secs(10_000.0), MAX_MULTIPLIER); | ||
| 27 | } | ||
| 28 | |||
| 29 | #[test] | ||
| 30 | fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() { | ||
| 31 | close(time_for_multiplier(2.0).as_secs_f64(), 5.0, 1e-9); | ||
| 32 | close(time_for_multiplier(10.0).as_secs_f64(), 16.609_640, 1e-4); | ||
| 33 | close(multiplier_at(time_for_multiplier(3.5)), 3.5, 1e-9); | ||
| 34 | } | ||
| 35 | |||
| 36 | #[test] | ||
| 37 | fn cashout_value_is_floored_to_whole_pln() { | ||
| 38 | assert_eq!(payout(10, 1.0), 10); | ||
| 39 | assert_eq!(payout(10, 1.05), 10, "half a PLN is not paid out"); | ||
| 40 | assert_eq!(payout(10, 1.10), 11); | ||
| 41 | assert_eq!(payout(10, 2.47), 24); | ||
| 42 | assert_eq!(payout(7, 3.0), 21); | ||
| 43 | |||
| 44 | assert_eq!(payout(0, 2.0), 0); | ||
| 45 | assert_eq!(payout(10, f64::NAN), 0); | ||
| 46 | assert_eq!(payout(10, f64::INFINITY), 0); | ||
| 47 | assert_eq!( | ||
| 48 | payout(u64::MAX, MAX_MULTIPLIER), | ||
| 49 | u64::MAX, | ||
| 50 | "a huge win saturates instead of wrapping" | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 54 | #[test] | ||
| 55 | fn crash_points_follow_the_house_edge() { | ||
| 56 | // P(crash >= m) should be (1 - edge) / m. | ||
| 57 | let samples = 100_000; | ||
| 58 | for target in [1.5_f64, 2.0, 5.0, 50.0] { | ||
| 59 | let survivors = (0..samples) | ||
| 60 | .filter(|index| { | ||
| 61 | let u = *index as f64 / samples as f64; | ||
| 62 | crash_point_from_uniform(u) >= target | ||
| 63 | }) | ||
| 64 | .count(); | ||
| 65 | let observed = survivors as f64 / samples as f64; | ||
| 66 | let expected = (1.0 - HOUSE_EDGE) / target; | ||
| 67 | close(observed, expected, 0.002); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | #[test] | ||
| 72 | fn an_instant_cashout_still_pays_the_house_edge() { | ||
| 73 | // One round in a hundred crashes at 1.00x, so cashing out instantly returns | ||
| 74 | // the stake 99% of the time and nothing 1% of the time: no free money. | ||
| 75 | let samples = 100_000; | ||
| 76 | let instant_crashes = (0..samples) | ||
| 77 | .filter(|index| { | ||
| 78 | let u = *index as f64 / samples as f64; | ||
| 79 | crash_point_from_uniform(u) <= 1.0 | ||
| 80 | }) | ||
| 81 | .count(); | ||
| 82 | close(instant_crashes as f64 / samples as f64, HOUSE_EDGE, 0.001); | ||
| 83 | |||
| 84 | let stake = 10; | ||
| 85 | let kept = payout(stake, 1.0); | ||
| 86 | assert_eq!(kept, stake); | ||
| 87 | let ev = kept as f64 * (1.0 - HOUSE_EDGE); | ||
| 88 | close(ev, stake as f64 * (1.0 - HOUSE_EDGE), 1e-9); | ||
| 89 | } | ||
| 90 | |||
| 91 | #[test] | ||
| 92 | fn crash_points_are_clamped_to_the_liability_cap() { | ||
| 93 | assert_eq!(crash_point_from_uniform(0.0), 1.0); | ||
| 94 | assert_eq!(crash_point_from_uniform(-1.0), 1.0); | ||
| 95 | assert_eq!(crash_point_from_uniform(f64::NAN), 1.0); | ||
| 96 | close(crash_point_from_uniform(0.5), 1.98, 1e-9); | ||
| 97 | close(crash_point_from_uniform(0.99), 99.0, 1e-9); | ||
| 98 | assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); | ||
| 99 | assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); | ||
| 100 | } | ||
diff --git a/tests/server_core.rs b/tests/server_core.rs index 08a2bf2..695cfe4 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs | |||
| @@ -1,9 +1,12 @@ | |||
| 1 | use std::path::PathBuf; | 1 | use std::path::PathBuf; |
| 2 | use std::sync::Arc; | ||
| 2 | 3 | ||
| 3 | use rusqlite::Connection; | 4 | use rusqlite::Connection; |
| 4 | use zagan_bet::blackjack::BlackjackState; | 5 | use zagan_bet::blackjack::BlackjackState; |
| 6 | use zagan_bet::crash::CrashState; | ||
| 5 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 7 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 6 | use zagan_bet::server::database; | 8 | use zagan_bet::server::database; |
| 9 | use zagan_bet::server::games::crash::ManualClock; | ||
| 7 | use zagan_bet::server::games::registry::GameRegistry; | 10 | use zagan_bet::server::games::registry::GameRegistry; |
| 8 | use zagan_bet::server::games::roulette::RouletteGame; | 11 | use zagan_bet::server::games::roulette::RouletteGame; |
| 9 | use zagan_bet::server::games::slots::SlotsGame; | 12 | use zagan_bet::server::games::slots::SlotsGame; |
| @@ -73,6 +76,31 @@ fn blackjack_state(response: &str) -> BlackjackState { | |||
| 73 | serde_json::from_str(raw).expect("valid blackjack state") | 76 | serde_json::from_str(raw).expect("valid blackjack state") |
| 74 | } | 77 | } |
| 75 | 78 | ||
| 79 | fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) { | ||
| 80 | let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) | ||
| 81 | .join(format!("server-core-{name}.db")) | ||
| 82 | .to_string_lossy() | ||
| 83 | .into_owned(); | ||
| 84 | let _ = std::fs::remove_file(&path); | ||
| 85 | |||
| 86 | let conn = Connection::open(&path).unwrap(); | ||
| 87 | database::init_db(&conn); | ||
| 88 | |||
| 89 | let clock = ManualClock::new(); | ||
| 90 | let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) | ||
| 91 | .with_crash_uniform(uniform) | ||
| 92 | .with_clock(Arc::new(clock.clone())); | ||
| 93 | (core, path, clock) | ||
| 94 | } | ||
| 95 | |||
| 96 | fn crash_state(response: &str) -> CrashState { | ||
| 97 | let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] | ||
| 98 | .iter() | ||
| 99 | .find_map(|prefix| response.strip_prefix(prefix)) | ||
| 100 | .unwrap_or_else(|| panic!("not a crash response: {response}")); | ||
| 101 | serde_json::from_str(raw).expect("valid crash state") | ||
| 102 | } | ||
| 103 | |||
| 76 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 104 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { |
| 77 | assert_eq!( | 105 | assert_eq!( |
| 78 | core.handle_message(state, &format!("REGISTER {user} credential")), | 106 | core.handle_message(state, &format!("REGISTER {user} credential")), |
| @@ -496,3 +524,220 @@ fn blackjack_disconnect_forfeits_the_reserved_bet() { | |||
| 496 | assert_eq!(core.play_count("alice"), 1); | 524 | assert_eq!(core.play_count("alice"), 1); |
| 497 | assert_eq!(core.session_count(), 0); | 525 | assert_eq!(core.session_count(), 0); |
| 498 | } | 526 | } |
| 527 | |||
| 528 | // 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0. | ||
| 529 | const CRASH_AT_3: f64 = 0.67; | ||
| 530 | |||
| 531 | #[test] | ||
| 532 | fn crash_start_reserves_the_bet_and_hides_the_crash_point() { | ||
| 533 | let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3); | ||
| 534 | let mut state = ConnectionState::default(); | ||
| 535 | register(&mut core, &mut state, "alice"); | ||
| 536 | login(&mut core, &mut state, "alice"); | ||
| 537 | |||
| 538 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 539 | |||
| 540 | assert_eq!(started.phase, "running"); | ||
| 541 | assert_eq!(started.bet, 10); | ||
| 542 | assert_eq!(started.balance, 990); | ||
| 543 | assert_eq!(started.multiplier, 1.0); | ||
| 544 | assert_eq!(started.potential, 10); | ||
| 545 | assert_eq!(started.payout, 0); | ||
| 546 | assert_eq!( | ||
| 547 | started.crash_point, None, | ||
| 548 | "the crash point must not leak before the round ends" | ||
| 549 | ); | ||
| 550 | assert_eq!(core.balance("alice"), Some(990)); | ||
| 551 | assert_eq!(core.session_count(), 1); | ||
| 552 | assert_eq!(core.play_count("alice"), 0); | ||
| 553 | } | ||
| 554 | |||
| 555 | #[test] | ||
| 556 | fn crash_cashout_pays_the_multiplier_from_the_server_clock() { | ||
| 557 | let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3); | ||
| 558 | let mut state = ConnectionState::default(); | ||
| 559 | register(&mut core, &mut state, "alice"); | ||
| 560 | login(&mut core, &mut state, "alice"); | ||
| 561 | |||
| 562 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 563 | |||
| 564 | // Five seconds of round time: 2.00x, still below the 3.00x crash point. | ||
| 565 | clock.advance(5.0); | ||
| 566 | let cashed = crash_state(&core.handle_message( | ||
| 567 | &mut state, | ||
| 568 | &format!("CRASH_CASHOUT {}", started.session_id), | ||
| 569 | )); | ||
| 570 | |||
| 571 | assert_eq!(cashed.phase, "cashed"); | ||
| 572 | assert_eq!(cashed.multiplier, 2.0); | ||
| 573 | assert_eq!(cashed.potential, 20); | ||
| 574 | assert_eq!(cashed.payout, 20); | ||
| 575 | assert_eq!(cashed.balance, 1010); | ||
| 576 | let crash_point = cashed.crash_point.unwrap(); | ||
| 577 | assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); | ||
| 578 | assert_eq!(core.balance("alice"), Some(1010)); | ||
| 579 | assert_eq!(core.play_count("alice"), 1); | ||
| 580 | assert_eq!(core.session_count(), 0); | ||
| 581 | |||
| 582 | let conn = Connection::open(&path).unwrap(); | ||
| 583 | let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn | ||
| 584 | .query_row( | ||
| 585 | "SELECT game_type, bet_amount, winnings, balance_after, details | ||
| 586 | FROM plays WHERE username = 'alice'", | ||
| 587 | [], | ||
| 588 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), | ||
| 589 | ) | ||
| 590 | .unwrap(); | ||
| 591 | assert_eq!(game_type, "crash"); | ||
| 592 | assert_eq!(bet, 10); | ||
| 593 | assert_eq!(winnings, 20); | ||
| 594 | assert_eq!(balance_after, 1010); | ||
| 595 | let details: serde_json::Value = serde_json::from_str(&details).unwrap(); | ||
| 596 | assert_eq!(details["phase"], "cashed"); | ||
| 597 | let audited_crash_point = details["crash_point"].as_f64().unwrap(); | ||
| 598 | assert!( | ||
| 599 | (audited_crash_point - 3.0).abs() < 1e-9, | ||
| 600 | "got {audited_crash_point}" | ||
| 601 | ); | ||
| 602 | assert_eq!(details["multiplier"], 2.0); | ||
| 603 | } | ||
| 604 | |||
| 605 | #[test] | ||
| 606 | fn a_cashout_after_the_crash_time_loses_the_reserved_bet() { | ||
| 607 | let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3); | ||
| 608 | let mut state = ConnectionState::default(); | ||
| 609 | register(&mut core, &mut state, "alice"); | ||
| 610 | login(&mut core, &mut state, "alice"); | ||
| 611 | |||
| 612 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 613 | |||
| 614 | clock.advance(20.0); | ||
| 615 | let late = crash_state(&core.handle_message( | ||
| 616 | &mut state, | ||
| 617 | &format!("CRASH_CASHOUT {}", started.session_id), | ||
| 618 | )); | ||
| 619 | |||
| 620 | assert_eq!(late.phase, "crashed"); | ||
| 621 | assert_eq!(late.payout, 0); | ||
| 622 | assert_eq!(late.balance, 990); | ||
| 623 | let crash_point = late.crash_point.unwrap(); | ||
| 624 | assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); | ||
| 625 | assert_eq!(core.balance("alice"), Some(990)); | ||
| 626 | assert_eq!(core.play_count("alice"), 1); | ||
| 627 | assert_eq!(core.session_count(), 0); | ||
| 628 | } | ||
| 629 | |||
| 630 | #[test] | ||
| 631 | fn crash_poll_pushes_the_bust_exactly_once() { | ||
| 632 | let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3); | ||
| 633 | let mut state = ConnectionState::default(); | ||
| 634 | register(&mut core, &mut state, "alice"); | ||
| 635 | login(&mut core, &mut state, "alice"); | ||
| 636 | |||
| 637 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 638 | |||
| 639 | clock.advance(1.0); | ||
| 640 | assert!( | ||
| 641 | core.poll(&state).is_empty(), | ||
| 642 | "a running round must not push anything" | ||
| 643 | ); | ||
| 644 | |||
| 645 | clock.advance(15.0); | ||
| 646 | let pushed = core.poll(&state); | ||
| 647 | assert_eq!(pushed.len(), 1); | ||
| 648 | let busted = crash_state(&pushed[0]); | ||
| 649 | assert_eq!(busted.session_id, started.session_id); | ||
| 650 | assert_eq!(busted.phase, "crashed"); | ||
| 651 | assert_eq!(busted.payout, 0); | ||
| 652 | assert_eq!(busted.balance, 990); | ||
| 653 | |||
| 654 | assert!(core.poll(&state).is_empty(), "the bust must not repeat"); | ||
| 655 | assert_eq!(core.play_count("alice"), 1); | ||
| 656 | assert_eq!(core.session_count(), 0); | ||
| 657 | } | ||
| 658 | |||
| 659 | #[test] | ||
| 660 | fn crash_forfeit_keeps_the_reserved_bet() { | ||
| 661 | let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3); | ||
| 662 | let mut state = ConnectionState::default(); | ||
| 663 | register(&mut core, &mut state, "alice"); | ||
| 664 | login(&mut core, &mut state, "alice"); | ||
| 665 | |||
| 666 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 667 | let forfeited = crash_state(&core.handle_message( | ||
| 668 | &mut state, | ||
| 669 | &format!("CRASH_FORFEIT {}", started.session_id), | ||
| 670 | )); | ||
| 671 | |||
| 672 | assert_eq!(forfeited.phase, "forfeit"); | ||
| 673 | assert_eq!(forfeited.payout, 0); | ||
| 674 | assert_eq!(core.balance("alice"), Some(990)); | ||
| 675 | assert_eq!(core.play_count("alice"), 1); | ||
| 676 | assert_eq!(core.session_count(), 0); | ||
| 677 | } | ||
| 678 | |||
| 679 | #[test] | ||
| 680 | fn crash_disconnect_forfeits_the_reserved_bet() { | ||
| 681 | let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3); | ||
| 682 | let mut state = ConnectionState::default(); | ||
| 683 | register(&mut core, &mut state, "alice"); | ||
| 684 | login(&mut core, &mut state, "alice"); | ||
| 685 | |||
| 686 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 687 | assert_eq!(started.balance, 990); | ||
| 688 | |||
| 689 | core.disconnect(&state); | ||
| 690 | |||
| 691 | assert_eq!(core.balance("alice"), Some(990)); | ||
| 692 | assert_eq!(core.play_count("alice"), 1); | ||
| 693 | assert_eq!(core.session_count(), 0); | ||
| 694 | } | ||
| 695 | |||
| 696 | #[test] | ||
| 697 | fn crash_rejects_a_second_round_and_bad_cashout_formats() { | ||
| 698 | let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3); | ||
| 699 | let mut state = ConnectionState::default(); | ||
| 700 | register(&mut core, &mut state, "alice"); | ||
| 701 | login(&mut core, &mut state, "alice"); | ||
| 702 | |||
| 703 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 704 | assert_eq!( | ||
| 705 | core.handle_message(&mut state, "PLAY_START crash 10"), | ||
| 706 | "ERROR Crash round already in progress" | ||
| 707 | ); | ||
| 708 | assert_eq!( | ||
| 709 | core.handle_message( | ||
| 710 | &mut state, | ||
| 711 | &format!("CRASH_CASHOUT {} 50.0", started.session_id) | ||
| 712 | ), | ||
| 713 | "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>" | ||
| 714 | ); | ||
| 715 | assert_eq!( | ||
| 716 | core.handle_message(&mut state, "CRASH_CASHOUT nope"), | ||
| 717 | "ERROR Session not found" | ||
| 718 | ); | ||
| 719 | } | ||
| 720 | |||
| 721 | #[test] | ||
| 722 | fn crash_rounds_are_owned_by_the_player_who_started_them() { | ||
| 723 | let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3); | ||
| 724 | let mut alice = ConnectionState::default(); | ||
| 725 | let mut bob = ConnectionState::default(); | ||
| 726 | register(&mut core, &mut alice, "alice"); | ||
| 727 | register(&mut core, &mut bob, "bob"); | ||
| 728 | login(&mut core, &mut alice, "alice"); | ||
| 729 | login(&mut core, &mut bob, "bob"); | ||
| 730 | |||
| 731 | let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10")); | ||
| 732 | |||
| 733 | clock.advance(5.0); | ||
| 734 | assert_eq!( | ||
| 735 | core.handle_message( | ||
| 736 | &mut bob, | ||
| 737 | &format!("CRASH_CASHOUT {}", started.session_id) | ||
| 738 | ), | ||
| 739 | "ERROR Invalid session" | ||
| 740 | ); | ||
| 741 | assert_eq!(core.balance("bob"), Some(1000)); | ||
| 742 | assert_eq!(core.session_count(), 1, "alice's round must survive"); | ||
| 743 | } | ||
