From 6e2787d792de64c003eff03b62a2df798ef851c0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 20:43:09 +0100 Subject: v2.3.0: add crash with local multiplier and pushed crash event --- AGENTS.md | 15 +++ Cargo.toml | 2 +- docs/GAMES.md | 65 +++++++++++- docs/TESTING.md | 18 ++++ src/bin/client/connection.rs | 5 + src/bin/client/repl.rs | 123 ++++++++++++++++++---- src/bin/client/tls.rs | 58 +++++++++- src/bin/server/handler.rs | 55 +++++++--- src/bin/server/tls.rs | 9 ++ src/client/core.rs | 190 ++++++++++++++++++++++++++++++++- src/client/games/crash.rs | 91 ++++++++++++++++ src/client/games/manager.rs | 90 ++++++++++++++++ src/client/games/mod.rs | 1 + src/client/games/response.rs | 21 ++++ src/crash.rs | 87 +++++++++++++++ src/lib.rs | 1 + src/server/core.rs | 240 ++++++++++++++++++++++++++++++++++++++++++ src/server/games/crash.rs | 216 ++++++++++++++++++++++++++++++++++++++ src/server/games/mod.rs | 1 + src/server/games/registry.rs | 2 + tests/client_core.rs | 210 ++++++++++++++++++++++++++++++++++++- tests/crash.rs | 100 ++++++++++++++++++ tests/server_core.rs | 245 +++++++++++++++++++++++++++++++++++++++++++ 23 files changed, 1800 insertions(+), 45 deletions(-) create mode 100644 src/client/games/crash.rs create mode 100644 src/crash.rs create mode 100644 src/server/games/crash.rs create mode 100644 tests/crash.rs diff --git a/AGENTS.md b/AGENTS.md index 9bee619..be00df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,21 @@ - All balance mutations (`SET balance = balance ± ?`) must happen in a transaction. +## Crash +- The client draws the multiplier from its own clock for display only. A + cashout carries a session id and nothing else; the multiplier and payout come + from the server's clock at receipt. +- Never send the crash point to the client before the round ends. A client that + knows it can cash out at `crash - epsilon` every round for a guaranteed win. +- The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of + rounds that crash at 1.00x. That is where the house edge is charged on an + instant cashout, and it is why no cashout lockout is needed. +- The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. + Client code must drain the socket on a timer and route pushed lines by + prefix instead of assuming the next line answers the last request. +- `ServerCore::poll` must be idempotent: it settles the round, removes the + session, and pushes once. A second poll must produce nothing. + ## Client TUI - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, count lines against the same string the cursor will erase. diff --git a/Cargo.toml b/Cargo.toml index d65b9ef..54c41df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "2.2.1" +version = "2.3.0" edition = "2024" [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 @@ | Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | | Roulette | Done | European wheel, split bets, 97.3% RTP | | Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double | +| Crash | Done | Local multiplier, server-owned clock and crash event, 99% RTP | ## Planned | Game | Shape | Main open decisions | | --- | --- | --- | -| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | +| — | — | Provable fairness for the crash point (`hash` commit/reveal) | + +Crash rounds are private to one player and use the same request/response socket +plus one server-initiated line. A shared multiplayer round would need a lobby +and broadcast, which is a different product. + +## Crash + +- The multiplier is `exp(0.1386294 * t)`: 1.00x at the start, 2.00x at 5s, + 10x at ~16.6s, capped at 1000x. +- The crash point is drawn when the round starts: + `max(1.0, (1 - 0.01) / (1 - u))` for a uniform `u`. That gives + `P(crash >= m) = 0.99 / m`, so a fixed-target cashout returns 99% of the + stake. The cap trims the RTP by a hair. +- The crash point is **not** sent to the client until the round ends. Knowing it + early would let a patched client cash out at `crash - epsilon` every round. +- The client draws the multiplier from its own monotonic clock, anchored when + `CRASH_START` arrives. That number is display only. +- The server computes the cashout multiplier from its own clock when the + request arrives. The client never sends a multiplier. A cashout that arrives + after the crash time loses the bet. +- Because `P(crash = 1.00x) = 1%`, an instant cashout returns the stake 99% of + the time and nothing 1% of the time. There is no risk-free window, which is + why there is no cashout lockout. +- Payouts are `floor(bet * multiplier)` in whole PLN, so anything under 1.10x + on a 10 PLN bet returns 10. +- When the crash time passes, the server pushes `CRASH_BUSTED` on the same + connection without being asked. The client's event loop polls for keys and + drains the socket every 25ms. +- Leaving with Q or disconnecting during a round forfeits the reserved bet and + writes a `result = forfeit` audit row. -Crash needs a timed round loop and a cashout action, so it should be its own -protocol/loop change. +Client controls: + +``` +C cash out at the server's current multiplier +SPACE start the next round ++ or = / - change the next round's bet +Q forfeit an active round (bet is lost), or leave when idle +``` ## Blackjack @@ -92,6 +129,9 @@ plays ( // blackjack {"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} + +// crash +{"bet":10,"crash_point":3.0,"started_at":12.44,"phase":"cashed","result":"cashed","multiplier":2.0,"payout":20} ``` The balance update and the `plays` insert happen in one transaction @@ -124,3 +164,22 @@ BLACKJACK_STATE `BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, result and winnings. The dealer's hole card is `?` until the hand is done. + +Crash sends one line when the round starts, one line when it crashes (without a +request), and answers the cashout: + +``` +PLAY_START crash +CRASH_START + +CRASH_CASHOUT +CRASH_STATE + +CRASH_BUSTED # pushed when the crash time passes +CRASH_FORFEIT +CRASH_STATE +``` + +`CRASH_STATE` carries `session_id`, `phase` (`running`, `cashed`, `crashed`, +`forfeit`), `bet`, `balance`, the current/final `multiplier`, `potential`, +`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. | `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | +| `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | | `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | | `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. double), winnings, and the final cards/result in `details`. A forfeit has `result = forfeit`, `winnings = 0`. +## Manual crash checklist + +1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and + reserves the bet. +2. The multiplier rises smoothly with no network traffic. Cash out with C and + confirm the board shows the cashout multiplier, the payout and the new + balance. +3. On another round, press nothing. The board must switch to `CRASHED at Xx` + by itself when the crash point is reached, and the bet must be lost. +4. Press C just as a round crashes. Either it pays or the crashed result wins; + a late cashout must never show an error over a finished round. +5. Q during a live round forfeits the bet; Q when idle just leaves. +6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, + the payout, and `crash_point`/`multiplier`/`result` in `details`. +7. Confirm the crash point is absent from the `CRASH_START` line (it must only + appear once the round is over). + ## Manual server checks - 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 { self.inner.write_line(msg) } + /// One line if the socket already has one, without blocking. + pub fn read_available_line(&mut self) -> std::io::Result> { + self.inner.read_available_line() + } + pub fn set_read_timeout( &self, duration: Option, 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 { self.apply_prompt(); loop { - let event = read().unwrap(); - match event { - Event::Key(key) => { - if key.kind != KeyEventKind::Press { - continue; - } - let Some(key) = map_key(key) else { - continue; - }; - let effects = self.core.handle_key(key); - if self.apply_effects(effects) { - break; + let mut quit = false; + + if crossterm::event::poll(std::time::Duration::from_millis(25)).unwrap_or(false) { + match read().unwrap() { + Event::Key(key) => { + if key.kind == KeyEventKind::Press { + if let Some(key) = map_key(key) { + let effects = self.core.handle_key(key); + quit = self.apply_effects(effects); + self.apply_prompt(); + } + } } - self.apply_prompt(); + Event::Resize(_, _) => self.apply_prompt(), + _ => {} + } + } + + if !quit && self.core.expects_push() { + quit = self.pump_async(); + } + + if !quit { + let effects = self.core.tick(); + if !effects.is_empty() { + quit = self.apply_effects(effects); } - Event::Resize(_, _) => self.apply_prompt(), - _ => {} + } + + if quit { + break; } } restore_terminal(&self.cleanup_done); } + /// Read any server-initiated lines that are already waiting and apply them. + /// Returns true when an effect asked the program to quit. + fn pump_async(&mut self) -> bool { + loop { + let Some(mut stream) = self.stream.take() else { + return false; + }; + let line = match stream.read_available_line() { + Ok(line) => { + self.stream = Some(stream); + line + } + Err(_) => { + self.core.set_connected(false); + self.stream = None; + return false; + } + }; + + let Some(line) = line else { + return false; + }; + + let effects = match self.core.handle_async_line(&line) { + Some(effects) => effects, + None => self.core.handle_reply(&line), + }; + if self.apply_effects(effects) { + return true; + } + } + } + fn apply_effects(&mut self, effects: Vec) -> bool { for effect in effects { match effect { @@ -250,6 +297,9 @@ impl ClientState { let roulette = lines .first() .is_some_and(|first| first.contains("ROULETTE")); + let crash = lines + .first() + .is_some_and(|first| first.contains("CRASH")); let rendered: Vec = lines .iter() .map(|line| { @@ -263,6 +313,18 @@ impl ClientState { } else { style_roulette_numbers(line) } + } else if crash { + if line.contains("CRASHED") { + line.as_str().bold().red().to_string() + } else if line.starts_with("Cashed out") { + line.as_str().bold().green().to_string() + } else if line.starts_with("Multiplier:") { + line.as_str().yellow().to_string() + } else if line.starts_with("ERROR:") { + line.as_str().red().to_string() + } else { + line.clone() + } } else if line.contains('{') { style_winning_cells(line) } else if line.contains("WIN!") || line.starts_with("Won ") { @@ -361,22 +423,37 @@ impl ClientState { return "ERROR Not connected".to_string(); }; + // A crash event can be pushed while a request is in flight. Route those + // lines as they arrive and keep waiting for the actual reply. + let mut pushed: Vec = Vec::new(); let result = (|| -> std::io::Result { stream.write_line(line)?; - let mut response = String::new(); - let read = stream.read_line(&mut response)?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "connection closed", - )); + loop { + let mut response = String::new(); + let read = stream.read_line(&mut response)?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "connection closed", + )); + } + let response = response.trim_end().to_string(); + if self.core.is_async(&response) { + pushed.push(response); + continue; + } + return Ok(response); } - Ok(response) })(); match result { Ok(response) => { self.stream = Some(stream); + for line in pushed { + if let Some(effects) = self.core.handle_async_line(&line) { + self.apply_effects(effects); + } + } response } 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 { sock, tls: conn, lines: LineBuffer::new(), + read_timeout: Some(std::time::Duration::from_secs(10)), })), }) } @@ -150,6 +151,7 @@ struct Session { sock: TcpStream, tls: rustls::ClientConnection, lines: LineBuffer, + read_timeout: Option, } impl Session { @@ -202,7 +204,61 @@ impl TlsStream { &self, duration: Option, ) -> std::io::Result<()> { - self.session.lock().sock.set_read_timeout(duration) + let mut s = self.session.lock(); + s.read_timeout = duration; + s.sock.set_read_timeout(duration) + } + + /// Return one complete line if the socket already has one, without + /// blocking. Used to pick up server pushes between requests. + pub fn read_available_line(&mut self) -> std::io::Result> { + let mut s = self.session.lock(); + if let Some(line) = s.lines.next_line() { + return Ok(Some(line)); + } + + // `set_read_timeout(Some(0))` is rejected by std, so poll with the + // shortest timeout the socket accepts instead of a nonblocking mode. + s.sock + .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; + let drained = (|| -> std::io::Result<()> { + loop { + let mut buf = [0u8; 4096]; + match s.tls.reader().read(&mut buf) { + Ok(0) => return Ok(()), + Ok(n) => s.lines.push(&buf[..n]), + // The reader only drains plaintext rustls has already + // decrypted; `drive` moves bytes off the socket. + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + match s.drive() { + Ok(()) => continue, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + })(); + s.sock.set_read_timeout(s.read_timeout)?; + drained?; + Ok(s.lines.next_line()) } } 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 @@ +use std::time::Duration; + use crate::tls::TlsStream; use zagan_bet::server::core::{ConnectionState, ServerCore}; +/// How long the connection waits for a request before advancing timed games. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + pub fn handle_client_tls(mut stream: TlsStream) { let client_addr = match stream.peer_addr() { Ok(addr) => addr.to_string(), @@ -19,34 +24,54 @@ pub fn handle_client_tls(mut stream: TlsStream) { } }; + if let Err(error) = stream.set_read_timeout(Some(POLL_INTERVAL)) { + eprintln!("Could not set read timeout: {error}"); + return; + } + let mut state = ConnectionState::default(); println!("[TLS Client connected] {client_addr}"); - loop { + 'connection: loop { let mut line = String::new(); match stream.read_line(&mut line) { Ok(0) => break, - Ok(_) => {} + Ok(_) => { + let line = line.trim(); + if !line.is_empty() { + let user = state.user().unwrap_or("guest").to_string(); + println!("[{user} @ {client_addr}] Received: {line}"); + let response = core.handle_message(&mut state, line); + if let Err(error) = stream.write_line(&response) { + eprintln!("Write error: {error}"); + break; + } + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Sent: {response}"); + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + // No request yet: fall through and let timed games advance. + } Err(error) => { eprintln!("Read error: {error}"); break; } } - let line = line.trim(); - if line.is_empty() { - continue; - } - - let user = state.user().unwrap_or("guest").to_string(); - println!("[{user} @ {client_addr}] Received: {line}"); - let response = core.handle_message(&mut state, line); - if let Err(error) = stream.write_line(&response) { - eprintln!("Write error: {error}"); - break; + for push in core.poll(&state) { + if let Err(error) = stream.write_line(&push) { + eprintln!("Write error: {error}"); + break 'connection; + } + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Pushed: {push}"); } - let user = state.user().unwrap_or("guest"); - println!("[{user} @ {client_addr}] Sent: {response}"); } 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 { pub fn peer_addr(&self) -> std::io::Result { self.stream.get_ref().peer_addr() } + + /// Read timeout for the socket. The connection loop uses a short one so it + /// can advance crash rounds while no request is in flight. + pub fn set_read_timeout( + &self, + duration: Option, + ) -> std::io::Result<()> { + self.stream.get_ref().set_read_timeout(duration) + } } 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 @@ use crate::client::games::manager::GameManager; use crate::client::games::response::{ - parse_blackjack_response, parse_roulette_response, parse_spin_response, + parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, }; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, record_history, }; +use crate::crash::{multiplier_at, CrashState}; use crate::roulette::{Bets, NumberBet}; +use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { @@ -66,6 +68,9 @@ enum Pending { BlackjackStart, BlackjackAction, BlackjackForfeit, + CrashStart, + CrashCashout, + CrashForfeit, Ping, Echo, Info, @@ -90,6 +95,8 @@ pub struct ClientCore { balance: Option, host: Option, fingerprint: Option, + /// Local anchor for the crash multiplier, set when a round starts. + crash_started: Option, } impl ClientCore { @@ -105,6 +112,7 @@ impl ClientCore { balance: None, host: None, fingerprint: None, + crash_started: None, } } @@ -113,6 +121,7 @@ impl ClientCore { if !connected { self.logged_in = None; self.pending = None; + self.crash_started = None; } } @@ -226,6 +235,49 @@ impl ClientCore { self.board_effects() } }, + Some(Pending::CrashStart) => match parse_crash_response(reply) { + Ok(state) => self.apply_crash(state), + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, + Some(Pending::CrashCashout) => match parse_crash_response(reply) { + Ok(state) => self.apply_crash(state), + Err(message) => { + // A pushed bust can settle the round before this reply + // arrives. The late request must not overwrite the result. + if self.game.crash_status() != "running" { + return self.board_effects(); + } + self.game.set_error(message); + self.board_effects() + } + }, + Some(Pending::CrashForfeit) => match parse_crash_response(reply) { + Ok(state) => { + self.crash_started = None; + self.game.end_game(); + let message = format!( + "Game exited. Round forfeited ({} PLN lost).", + state.bet + ); + vec![Effect::ClearRegion, Effect::Print(vec![message])] + } + Err(_) if self.game.crash_status() != "running" => { + // The round ended while the forfeit was in flight. + self.crash_started = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -270,6 +322,9 @@ impl ClientCore { if game_type == Some("blackjack") { return self.handle_blackjack_key(key); } + if game_type == Some("crash") { + return self.handle_crash_key(key); + } match key { Key::CtrlC => vec![Effect::Quit], @@ -559,6 +614,138 @@ impl ClientCore { ))] } + /// Redraw the rising multiplier. Called by the terminal loop on a timer. + pub fn tick(&mut self) -> Vec { + let Some(started) = self.crash_started else { + return Vec::new(); + }; + self.tick_at(started.elapsed()) + } + + pub fn tick_at(&mut self, elapsed: Duration) -> Vec { + if self.crash_started.is_none() { + return Vec::new(); + } + if self + .game + .get_current_state() + .map(|state| state.game_type.as_str()) + != Some("crash") + { + return Vec::new(); + } + self.game.set_crash_multiplier(multiplier_at(elapsed)); + self.board_effects() + } + + /// True while the server may push a crash event between requests. + pub fn expects_push(&self) -> bool { + self.crash_started.is_some() + } + + pub fn is_async(&self, line: &str) -> bool { + line.trim_start().starts_with("CRASH_BUSTED ") + } + + /// Handle a server-initiated line. Returns `None` when the line is a normal + /// request/response reply and belongs to `handle_reply`. + pub fn handle_async_line(&mut self, line: &str) -> Option> { + if !self.is_async(line) { + return None; + } + match parse_crash_response(line) { + Ok(state) => Some(self.apply_crash(state)), + Err(message) => { + self.game.set_error(message); + Some(self.board_effects()) + } + } + } + + fn apply_crash(&mut self, state: CrashState) -> Vec { + self.balance = Some(state.balance); + if state.is_running() { + if self.crash_started.is_none() { + self.crash_started = Some(Instant::now()); + } + } else { + self.crash_started = None; + } + self.game.apply_crash_state(state); + self.board_effects() + } + + fn handle_crash_key(&mut self, key: Key) -> Vec { + let status = self.game.crash_status(); + let running = status == "running"; + + match key { + Key::CtrlC => vec![Effect::Quit], + Key::Char('q') => { + if running { + let Some(session) = self.game.crash_session_id() else { + return Vec::new(); + }; + self.pending = Some(Pending::CrashForfeit); + vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] + } else { + self.crash_started = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + } + Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(), + Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(), + Key::Char('+') | Key::Char('=') if !running => { + let _ = self.game.adjust_bet(10); + self.board_effects() + } + Key::Char('-') if !running => { + let _ = self.game.adjust_bet(-10); + self.board_effects() + } + _ => Vec::new(), + } + } + + fn start_crash(&mut self) -> Vec { + let status = self.game.crash_status(); + if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { + return Vec::new(); + } + let Some(request) = self.game.spin_request() else { + return Vec::new(); + }; + if let Some(balance) = self.balance { + let bet = self + .game + .get_current_state() + .map(|state| state.bet_amount) + .unwrap_or(0); + if bet as i64 > balance { + self.game.set_error("Insufficient balance"); + return self.board_effects(); + } + } + + self.game.set_crash_status("starting"); + self.pending = Some(Pending::CrashStart); + let mut effects = self.board_effects(); + effects.push(Effect::SendLine(request)); + effects + } + + fn cash_out_crash(&mut self) -> Vec { + let Some(session) = self.game.crash_session_id() else { + return Vec::new(); + }; + self.pending = Some(Pending::CrashCashout); + vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] + } + fn submit(&mut self) -> Vec { let line = self.input.trim().to_string(); self.input.clear(); @@ -616,6 +803,7 @@ impl ClientCore { self.connected = false; self.logged_in = None; self.pending = None; + self.crash_started = None; self.game.end_game(); vec![ 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 @@ +use super::traits::{Game, GameState}; + +pub const CRASH_BOARD_LINES: usize = 6; + +pub struct CrashGame; + +impl CrashGame { + pub fn new() -> Self { + Self + } + + fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> { + state.data.get(key).map(String::as_str) + } + + fn status_text(state: &GameState) -> String { + let status = Self::value(state, "status").unwrap_or("ready"); + match status { + "starting" => "Starting...".to_string(), + "running" => "Rising... cash out before it crashes.".to_string(), + "cashed" => format!( + "Cashed out at {}x Won {} PLN", + Self::value(state, "multiplier").unwrap_or("1.00"), + Self::value(state, "payout").unwrap_or("0") + ), + "crashed" => format!( + "CRASHED at {}x Bet lost", + Self::value(state, "crash_point").unwrap_or("?") + ), + "forfeit" => "Forfeited. Bet lost.".to_string(), + _ => "Press [SPACE] to start.".to_string(), + } + } + + fn controls(state: &GameState) -> &'static str { + if Self::value(state, "status") == Some("running") { + "[C] Cash out [Q] Forfeit" + } else { + "[SPACE] Play [+/=/-] Bet [Q] Quit" + } + } +} + +impl Game for CrashGame { + fn get_name(&self) -> &'static str { + "Crash" + } + + fn render(&self, state: &GameState) -> String { + let balance = Self::value(state, "balance").unwrap_or("?"); + let multiplier = Self::value(state, "multiplier").unwrap_or("1.00"); + let potential = Self::value(state, "potential") + .map(str::to_string) + .unwrap_or_else(|| state.bet_amount.to_string()); + + if let Some(error) = Self::value(state, "error") { + return format!( + "=== CRASH ===\r\nBalance: {balance} PLN Bet: {} PLN\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit", + state.bet_amount + ); + } + + format!( + "=== CRASH ===\r\nBalance: {} PLN Bet: {} PLN\r\nMultiplier: {}x\r\nPotential: {} PLN\r\n{}\r\n{}", + balance, + state.bet_amount, + multiplier, + potential, + Self::status_text(state), + Self::controls(state) + ) + } + + fn render_lines(&self, _state: &GameState) -> usize { + CRASH_BOARD_LINES + } + + fn get_help(&self) -> String { + "CRASH\n\ + =====\n\ + The multiplier rises from 1.00x and crashes at a hidden point.\n\ + Cash out before it crashes to win bet x multiplier.\n\ + A round can crash at 1.00x, so an instant cashout still risks the bet.\n\ + \n\ + C - cash out at the server's current multiplier\n\ + SPACE - start the next round\n\ + +/- - change the next round's bet\n\ + Q - forfeit an active round (bet is lost), or leave when idle" + .to_string() + } +} 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 @@ use super::blackjack::BlackjackGame; +use super::crash::CrashGame; use super::response::{RouletteResult, SpinResult}; use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; use crate::blackjack::BlackjackState; +use crate::crash::{payout, CrashState}; use crate::roulette::Bets; use std::collections::HashMap; @@ -18,6 +20,7 @@ impl GameManager { games.insert("slots".to_string(), Box::new(SlotsGame::new())); games.insert("roulette".to_string(), Box::new(RouletteGame::new())); games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); + games.insert("crash".to_string(), Box::new(CrashGame::new())); Self { current_session: None, @@ -57,6 +60,10 @@ impl GameManager { } else if game_type == "blackjack" { data.insert("status".to_string(), "ready".to_string()); 10 + } else if game_type == "crash" { + data.insert("status".to_string(), "ready".to_string()); + data.insert("multiplier".to_string(), "1.00".to_string()); + 10 } else { 10 }; @@ -173,11 +180,19 @@ impl GameManager { session.data.remove("winnings"); session.data.remove("number"); session.data.remove("color"); + session.data.remove("payout"); + session.data.remove("crash_point"); if session.game_type == "roulette" { session .data .insert("status".to_string(), "place".to_string()); } + if session.game_type == "crash" { + session + .data + .insert("status".to_string(), "ready".to_string()); + session.data.remove("session_id"); + } session.data.insert("error".to_string(), message.into()); } @@ -309,6 +324,81 @@ impl GameManager { } } + pub fn apply_crash_state(&mut self, state: CrashState) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + let running = state.is_running(); + session.phase = if running { + GamePhase::Playing + } else { + GamePhase::Finished + }; + session.bet_amount = state.bet; + session.data.remove("error"); + for key in ["crash_point", "payout"] { + session.data.remove(key); + } + session.data.insert("status".to_string(), state.phase.clone()); + session + .data + .insert("session_id".to_string(), state.session_id); + session + .data + .insert("balance".to_string(), state.balance.to_string()); + session + .data + .insert("multiplier".to_string(), format!("{:.2}", state.multiplier)); + session + .data + .insert("potential".to_string(), state.potential.to_string()); + session + .data + .insert("payout".to_string(), state.payout.to_string()); + if let Some(crash_point) = state.crash_point { + session + .data + .insert("crash_point".to_string(), format!("{crash_point:.2}")); + } + } + + /// Redraw the live multiplier from the client's local clock. + pub fn set_crash_multiplier(&mut self, multiplier: f64) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + let bet = session.bet_amount; + session + .data + .insert("multiplier".to_string(), format!("{multiplier:.2}")); + session + .data + .insert("potential".to_string(), payout(bet, multiplier).to_string()); + } + + pub fn set_crash_status(&mut self, status: &str) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("status".to_string(), status.to_string()); + } + } + + pub fn crash_status(&self) -> String { + self.current_session + .as_ref() + .and_then(|session| session.data.get("status")) + .cloned() + .unwrap_or_else(|| "ready".to_string()) + } + + pub fn crash_session_id(&self) -> Option { + self.current_session + .as_ref() + .and_then(|session| session.data.get("session_id")) + .cloned() + } + pub fn get_current_game(&self) -> Option<&dyn Game> { self.current_session .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 @@ pub mod blackjack; +pub mod crash; pub mod manager; pub mod response; 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( .ok_or_else(|| format!("Unexpected server response: {response}"))?; serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) } + +pub fn parse_crash_response(response: &str) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] + .iter() + .find_map(|prefix| response.strip_prefix(prefix)) + .ok_or_else(|| format!("Unexpected server response: {response}"))?; + serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) +} 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 @@ +//! Shared crash game math and wire state. +//! +//! The multiplier is a pure function of elapsed time, so the client can draw it +//! locally. The crash point is drawn up front and never leaves the server until +//! the round ends; the payout at cashout is always computed from the server's +//! clock, never from anything the client sends. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. +pub const GROWTH_RATE: f64 = 0.138_629_436_111_989_07; // ln(2) / 5 +/// House edge baked into the crash-point distribution. +pub const HOUSE_EDGE: f64 = 0.01; +/// Liability cap. At the cap `P(crash >= m)` stops following `edge / m`. +pub const MAX_MULTIPLIER: f64 = 1000.0; + +/// Multiplier after `elapsed` seconds of a round. +pub fn multiplier_at(elapsed: Duration) -> f64 { + multiplier_at_secs(elapsed.as_secs_f64()) +} + +pub fn multiplier_at_secs(seconds: f64) -> f64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 1.0; + } + (GROWTH_RATE * seconds) + .exp() + .clamp(1.0, MAX_MULTIPLIER) +} + +/// How long a round runs before reaching `multiplier`. +pub fn time_for_multiplier(multiplier: f64) -> Duration { + let multiplier = multiplier.clamp(1.0, MAX_MULTIPLIER); + Duration::from_secs_f64(multiplier.ln() / GROWTH_RATE) +} + +/// Crash point for a uniform draw in `[0, 1)`. +/// +/// `crash = (1 - edge) / (1 - u)` gives `P(crash >= m) = edge_keep / m` for +/// every `m` in `[1, MAX_MULTIPLIER]`, so a fixed-target cashout returns +/// `(1 - edge)` of the stake on average. +pub fn crash_point_from_uniform(u: f64) -> f64 { + let u = if u.is_finite() { + u.clamp(0.0, 0.999_999_999) + } else { + 0.0 + }; + ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) +} + +/// Whole-PLN cashout value. Balances are integers, so the payout is floored. +pub fn payout(bet: u64, multiplier: f64) -> u64 { + if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { + return 0; + } + let value = bet as f64 * multiplier.min(MAX_MULTIPLIER); + if value >= u64::MAX as f64 { + u64::MAX + } else { + value.floor() as u64 + } +} + +/// Round state as sent to the client. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CrashState { + pub session_id: String, + /// `running`, `cashed`, `crashed` or `forfeit`. + pub phase: String, + pub bet: u64, + pub balance: i64, + /// Live multiplier while running, otherwise the value the round ended at. + pub multiplier: f64, + /// `floor(bet * multiplier)` for the value above. + pub potential: u64, + /// Hidden until the round ends. + pub crash_point: Option, + pub payout: u64, + pub result: String, +} + +impl CrashState { + pub fn is_running(&self) -> bool { + self.phase == "running" + } +} diff --git a/src/lib.rs b/src/lib.rs index 74af6e1..ffc3d60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod blackjack; pub mod client; +pub mod crash; pub mod protocol; pub mod roulette; 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 @@ use bcrypt::{hash, verify, DEFAULT_COST}; use rusqlite::Connection; use std::collections::HashMap; +use std::sync::Arc; use crate::blackjack::BlackjackState; +use crate::crash::CrashState; use crate::server::database; use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; +use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; use crate::server::games::registry::GameRegistry; use crate::server::games::session::SessionManager; use crate::server::games::traits::{GameError, GamePhase, GameState}; @@ -26,6 +29,8 @@ pub struct ServerCore { registry: GameRegistry, bcrypt_cost: u32, blackjack: BlackjackGame, + crash: CrashGame, + clock: Arc, } impl ServerCore { @@ -44,6 +49,8 @@ impl ServerCore { registry, bcrypt_cost, blackjack: BlackjackGame::new(), + crash: CrashGame::new(), + clock: Arc::new(RealClock::new()), } } @@ -53,6 +60,22 @@ impl ServerCore { self } + /// Test/utility hook: queue the next crash-point draws. + pub fn with_crash_uniforms(mut self, uniforms: impl IntoIterator) -> Self { + self.crash = CrashGame::with_uniforms(uniforms); + self + } + + pub fn with_crash_uniform(self, uniform: f64) -> Self { + self.with_crash_uniforms([uniform]) + } + + /// Test/utility hook: replace the monotonic clock driving crash rounds. + pub fn with_clock(mut self, clock: Arc) -> Self { + self.clock = clock; + self + } + pub fn open(path: &str) -> rusqlite::Result { let conn = Connection::open(path)?; database::init_db(&conn); @@ -98,6 +121,8 @@ impl ServerCore { "PLAY_START" => { if parts.len() == 3 && parts[1] == "blackjack" { self.handle_blackjack_start(state, parts[2]) + } else if parts.len() == 3 && parts[1] == "crash" { + self.handle_crash_start(state, parts[2]) } else if parts.len() != 3 && parts.len() != 4 { "ERROR Invalid PLAY_START format. Usage: PLAY_START [payload]" .to_string() @@ -105,6 +130,22 @@ impl ServerCore { self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) } } + "CRASH_CASHOUT" => { + if parts.len() != 2 { + "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT " + .to_string() + } else { + self.handle_crash_end(state, parts[1], false) + } + } + "CRASH_FORFEIT" => { + if parts.len() != 2 { + "ERROR Invalid CRASH_FORFEIT format. Usage: CRASH_FORFEIT " + .to_string() + } else { + self.handle_crash_end(state, parts[1], true) + } + } "BLACKJACK_ACTION" => { if parts.len() != 3 { "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION " @@ -371,6 +412,175 @@ impl ServerCore { } } + fn handle_crash_start(&mut self, state: &ConnectionState, bet_str: &str) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + if self + .sessions + .get_user_sessions(user) + .iter() + .any(|session| session.game_type == "crash") + { + return "ERROR Crash round already in progress".to_string(); + } + let bet: i64 = match bet_str.parse() { + Ok(bet) => bet, + Err(_) => return "ERROR Invalid bet amount".to_string(), + }; + if bet <= 0 { + return "ERROR Invalid bet amount".to_string(); + } + let Some(balance) = database::get_balance(&self.conn, user) else { + return "ERROR Could not retrieve balance".to_string(); + }; + if balance < bet { + return "ERROR Insufficient balance".to_string(); + } + + // The session is the audit guard for the deduction, so it exists before + // the balance is touched. + let crash_point = self.crash.next_crash_point(); + let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); + let mut data = HashMap::new(); + data.insert("round".to_string(), round.to_json()); + data.insert("balance".to_string(), balance.to_string()); + let game_state = GameState { + session_id: String::new(), + game_type: "crash".to_string(), + bet_amount: round.bet, + phase: GamePhase::Playing, + data, + }; + let session_id = self + .sessions + .create_session(user, "crash".to_string(), game_state); + + let balance = match database::debit(&mut self.conn, user, bet) { + Ok(balance) => balance, + Err(_) => { + let _ = self.sessions.remove_session(&session_id); + return "ERROR Insufficient balance".to_string(); + } + }; + if let Ok(mut session) = self.sessions.get_session(&session_id) { + session + .state + .data + .insert("balance".to_string(), balance.to_string()); + let _ = self.sessions.update_session(&session_id, session.state); + } + + let state = round.state(&session_id, balance); + crash_wire("CRASH_START", &state) + } + + /// Cash out or forfeit. The multiplier comes from the server clock at the + /// moment the request is handled; the client never supplies one. + fn handle_crash_end(&mut self, state: &ConnectionState, session_id: &str, forfeit: bool) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + let Ok(session) = self.sessions.get_session(session_id) else { + return "ERROR Session not found".to_string(); + }; + if session.username != user || session.game_type != "crash" { + return "ERROR Invalid session".to_string(); + } + let Some(raw) = session.state.data.get("round") else { + return "ERROR Missing round".to_string(); + }; + let Ok(mut round) = CrashRound::from_json(raw) else { + return "ERROR Invalid round".to_string(); + }; + if !round.is_running() { + return "ERROR Round already ended".to_string(); + } + + let elapsed = (self.clock.now() - round.started_at).max(0.0); + let multiplier = crate::crash::multiplier_at_secs(elapsed); + let payout = if forfeit { + round.multiplier = multiplier; + round.forfeit(); + 0 + } else if multiplier < round.crash_point { + round.cash_out(multiplier) + } else { + round.crash(); + 0 + }; + + match self.settle_crash(user, session_id, &round, payout) { + Ok(balance) => { + let state = round.state(session_id, balance); + crash_wire("CRASH_STATE", &state) + } + Err(message) => { + eprintln!("Crash settlement failed for {user}: {message}"); + "ERROR Failed to update balance".to_string() + } + } + } + + /// Server-driven end of a round. `handler` calls this on every socket + /// timeout and forwards whatever lines come back. + pub fn poll(&mut self, state: &ConnectionState) -> Vec { + let Some(user) = state.user.as_deref() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for session in self.sessions.get_user_sessions(user) { + if session.game_type != "crash" { + continue; + } + let Some(raw) = session.state.data.get("round") else { + continue; + }; + let Ok(mut round) = CrashRound::from_json(raw) else { + continue; + }; + if !round.is_running() { + continue; + } + let elapsed = (self.clock.now() - round.started_at).max(0.0); + if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { + continue; + } + round.crash(); + match self.settle_crash(user, &session.session_id, &round, 0) { + Ok(balance) => { + let state = round.state(&session.session_id, balance); + out.push(crash_wire("CRASH_BUSTED", &state)); + } + Err(message) => { + eprintln!("Crash push settlement failed for {user}: {message}"); + } + } + } + out + } + + fn settle_crash( + &mut self, + user: &str, + session_id: &str, + round: &CrashRound, + payout: u64, + ) -> Result { + let details = round.to_json(); + let result = database::settle_play( + &mut self.conn, + session_id, + user, + "crash", + round.bet as i64, + payout as i64, + &details, + ); + let _ = self.sessions.remove_session(session_id); + result.map_err(|error| error.to_string()) + } + fn handle_play_start( &mut self, state: &ConnectionState, @@ -527,6 +737,29 @@ impl ServerCore { } } } + if session.game_type == "crash" { + if let Some(raw) = session.state.data.get("round") { + if let Ok(mut round) = CrashRound::from_json(raw) { + if round.is_running() { + round.forfeit(); + let details = round.to_json(); + if let Err(error) = database::settle_play( + &mut self.conn, + &session.session_id, + user, + "crash", + round.bet as i64, + 0, + &details, + ) { + eprintln!( + "Crash disconnect forfeit failed for {user}: {error}" + ); + } + } + } + } + } let _ = self.sessions.remove_session(&session.session_id); } } @@ -562,3 +795,10 @@ fn blackjack_wire(state: &BlackjackState) -> String { serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) ) } + +fn crash_wire(prefix: &str, state: &CrashState) -> String { + format!( + "{prefix} {}", + serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) + ) +} 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 @@ +use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; +use crate::crash::{crash_point_from_uniform, payout, CrashState}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +/// Monotonic seconds. Injected so tests can drive a round without sleeping. +pub trait Clock: Send + Sync { + fn now(&self) -> f64; +} + +pub struct RealClock { + origin: Instant, +} + +impl RealClock { + pub fn new() -> Self { + Self { + origin: Instant::now(), + } + } +} + +impl Clock for RealClock { + fn now(&self) -> f64 { + self.origin.elapsed().as_secs_f64() + } +} + +/// A clock a test can move by hand. Clones share the same instant. +#[derive(Clone, Default)] +pub struct ManualClock { + seconds: Arc>, +} + +impl ManualClock { + pub fn new() -> Self { + Self::default() + } + + pub fn set(&self, seconds: f64) { + *self.seconds.lock().unwrap() = seconds; + } + + pub fn advance(&self, seconds: f64) { + *self.seconds.lock().unwrap() += seconds; + } + + pub fn seconds(&self) -> f64 { + *self.seconds.lock().unwrap() + } +} + +impl Clock for ManualClock { + fn now(&self) -> f64 { + self.seconds() + } +} + +/// One crash round. Lives in the game session's `data` while it runs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrashRound { + pub bet: u64, + /// Drawn at start, revealed only when the round ends. + pub crash_point: f64, + pub started_at: f64, + /// `running`, `cashed`, `crashed` or `forfeit`. + pub phase: String, + pub result: String, + /// Live while running; frozen at the final value afterwards. + pub multiplier: f64, + pub payout: u64, +} + +impl CrashRound { + pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { + Self { + bet, + crash_point, + started_at, + phase: "running".to_string(), + result: "playing".to_string(), + multiplier: 1.0, + payout: 0, + } + } + + pub fn is_running(&self) -> bool { + self.phase == "running" + } + + pub fn cash_out(&mut self, multiplier: f64) -> u64 { + self.multiplier = multiplier; + self.payout = payout(self.bet, multiplier); + self.phase = "cashed".to_string(); + self.result = "cashed".to_string(); + self.payout + } + + /// The round ended before the player could cash out. + pub fn crash(&mut self) { + self.multiplier = self.crash_point; + self.payout = 0; + self.phase = "crashed".to_string(); + self.result = "crashed".to_string(); + } + + pub fn forfeit(&mut self) { + self.payout = 0; + self.phase = "forfeit".to_string(); + self.result = "forfeit".to_string(); + } + + pub fn state(&self, session_id: &str, balance: i64) -> CrashState { + CrashState { + session_id: session_id.to_string(), + phase: self.phase.clone(), + bet: self.bet, + balance, + multiplier: self.multiplier, + potential: payout(self.bet, self.multiplier), + crash_point: if self.is_running() { + None + } else { + Some(self.crash_point) + }, + payout: self.payout, + result: self.result.clone(), + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + pub fn from_json(raw: &str) -> Result { + serde_json::from_str(raw) + } +} + +/// Crash-point generator. Tests queue fixed uniforms; production draws random +/// ones. The visible crash point stays hidden until `poll` or cashout ends the +/// round. +pub struct CrashGame { + uniforms: VecDeque, +} + +impl CrashGame { + pub fn new() -> Self { + Self { + uniforms: VecDeque::new(), + } + } + + pub fn with_uniform(uniform: f64) -> Self { + Self::with_uniforms([uniform]) + } + + pub fn with_uniforms(uniforms: impl IntoIterator) -> Self { + Self { + uniforms: uniforms.into_iter().collect(), + } + } + + pub fn next_crash_point(&mut self) -> f64 { + let uniform = self + .uniforms + .pop_front() + .unwrap_or_else(rand::random::); + crash_point_from_uniform(uniform) + } +} + +impl Game for CrashGame { + fn game_type(&self) -> &'static str { + "crash" + } + + fn get_name(&self) -> &'static str { + "Crash" + } + + fn get_description(&self) -> &'static str { + "Crash: cash out before the multiplier crashes." + } + + fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { + let mut data = HashMap::new(); + data.insert("status".to_string(), "ready".to_string()); + GameState { + session_id: String::new(), + game_type: "crash".to_string(), + bet_amount, + phase: GamePhase::WaitingToStart, + data, + } + } + + fn process_action( + &self, + _state: &GameState, + _action: &str, + _params: &[&str], + ) -> Result { + Err(GameError::InvalidAction( + "Crash uses CRASH_CASHOUT and CRASH_FORFEIT".to_string(), + )) + } + + fn calculate_result(&self, _state: &GameState) -> Result { + Err(GameError::InternalError( + "Crash settles through CRASH_CASHOUT".to_string(), + )) + } +} 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 @@ pub mod blackjack; +pub mod crash; pub mod registry; pub mod roulette; 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 @@ #![allow(dead_code)] use super::blackjack::BlackjackGame; +use super::crash::CrashGame; use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::Game; @@ -25,6 +26,7 @@ impl GameRegistry { registry.register_game(SlotsGame::new()); registry.register_game(RouletteGame::new()); registry.register_game(BlackjackGame::new()); + registry.register_game(CrashGame::new()); registry } 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 @@ use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; -use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; +use zagan_bet::client::games::response::{ + parse_blackjack_response, parse_crash_response, parse_spin_response, +}; use zagan_bet::client::ui::command_names; +use zagan_bet::crash::CrashState; +use std::time::Duration; const WIN3: &str = "CCCCLLCLODGSDGS"; const LOSS: &str = "CLOOGDSLCDCOGLS"; @@ -145,6 +149,25 @@ impl Harness { self.line("play blackjack"); self.reply("INFO alice 1 1000"); } + + fn enter_crash(&mut self) { + self.line("play crash"); + self.reply("INFO alice 1 1000"); + } + + fn tick(&mut self, elapsed: Duration) { + let effects = self.core.tick_at(elapsed); + self.apply(effects); + } + + /// Deliver a server-initiated line the way the terminal loop does. + fn push(&mut self, line: &str) { + let effects = self + .core + .handle_async_line(line) + .expect("a pushed crash line"); + self.apply(effects); + } } #[test] @@ -851,3 +874,188 @@ fn parses_a_blackjack_state() { assert_eq!(parse_blackjack_response(&response).unwrap(), state); assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); } + +fn crash_state(phase: &str, multiplier: f64, crash_point: Option, payout: u64, balance: i64) -> CrashState { + CrashState { + session_id: "cr1".to_string(), + phase: phase.to_string(), + bet: 10, + balance, + multiplier, + potential: (10.0 * multiplier) as u64, + crash_point, + payout, + result: phase.to_string(), + } +} + +fn crash_line(prefix: &str, state: &CrashState) -> String { + format!("{prefix} {}", serde_json::to_string(state).unwrap()) +} + +#[test] +fn crash_starts_a_round_and_draws_the_multiplier_locally() { + let mut h = Harness::new(); + h.enter_crash(); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[4].contains("Press [SPACE] to start.")); + assert!(h.board()[5].contains("[SPACE] Play")); + + h.key(Key::Char(' ')); + assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10")); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[2].contains("Multiplier: 1.00x")); + assert!(h.board()[3].contains("Potential: 10 PLN")); + assert!(h.board()[4].contains("Rising")); + assert!(h.board()[5].contains("[C] Cash out")); + + // The multiplier is drawn from the local clock, with no server round trip. + h.tick(Duration::from_secs(5)); + assert!(h.board()[2].contains("Multiplier: 2.00x")); + assert!(h.board()[3].contains("Potential: 20 PLN")); + + h.key(Key::Char('c')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("CRASH_CASHOUT cr1") + ); + + let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); + h.reply(&crash_line("CRASH_STATE", &cashed)); + + assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN")); + assert!(h.board()[5].contains("[SPACE] Play")); + assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN"); +} + +#[test] +fn a_pushed_bust_ends_the_round_without_a_request() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + h.tick(Duration::from_secs(3)); + + assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1")); + + let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + + assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost")); + assert!(h.board()[5].contains("[SPACE] Play")); + + // The local clock stops once the round is over. + h.tick(Duration::from_secs(30)); + assert!(h.board()[2].contains("Multiplier: 1.50x")); + assert!(!h.core.expects_push()); +} + +#[test] +fn crash_q_forfeits_an_active_round() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('q')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("CRASH_FORFEIT cr1") + ); + + let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990); + h.reply(&crash_line("CRASH_STATE", &forfeited)); + + assert!(h.board().is_empty()); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Round forfeited (10 PLN lost)"))); +} + +#[test] +fn crash_ready_board_changes_the_next_bet() { + let mut h = Harness::new(); + h.enter_crash(); + + h.key(Key::Char('=')); + assert!(h.board()[1].contains("Bet: 20 PLN")); + assert!(h.board()[3].contains("Potential: 20 PLN")); + + h.key(Key::Char(' ')); + assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20")); +} + +#[test] +fn parses_crash_messages_and_errors() { + let running = crash_state("running", 1.0, None, 0, 990); + let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); + + assert_eq!( + parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(), + running + ); + assert_eq!( + parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(), + cashed + ); + assert_eq!( + parse_crash_response("ERROR Round already ended").unwrap_err(), + "Round already ended" + ); + assert!(parse_crash_response("GAME_RESULT nope").is_err()); +} + +#[test] +fn a_late_cashout_does_not_overwrite_a_pushed_bust() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('c')); + assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1")); + + // The round crashes while the cashout is in flight, so the push lands + // before the server's "session not found" reply. + let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + h.reply("ERROR Session not found"); + + assert!(h.board()[4].contains("CRASHED at 1.20x")); + assert!(!h.board().iter().any(|line| line.contains("ERROR"))); + assert!(h.board()[5].contains("[SPACE] Play")); +} + +#[test] +fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('q')); + assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1")); + + let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + h.reply("ERROR Session not found"); + + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); + assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); +} 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 @@ +//! Crash math: the curve, the payout floor, and the house edge. + +use std::time::Duration; +use zagan_bet::crash::{ + crash_point_from_uniform, multiplier_at, multiplier_at_secs, payout, time_for_multiplier, + HOUSE_EDGE, MAX_MULTIPLIER, +}; + +fn close(a: f64, b: f64, tolerance: f64) { + assert!( + (a - b).abs() <= tolerance, + "expected {b} +/- {tolerance}, got {a}" + ); +} + +#[test] +fn the_multiplier_doubles_every_five_seconds() { + close(multiplier_at(Duration::ZERO), 1.0, 1e-9); + close(multiplier_at(Duration::from_secs(5)), 2.0, 1e-9); + close(multiplier_at(Duration::from_secs(10)), 4.0, 1e-6); + close(multiplier_at(Duration::from_millis(2500)), 1.414_213_562, 1e-6); + + // Never below 1x, even for nonsense input. + assert_eq!(multiplier_at_secs(-3.0), 1.0); + assert_eq!(multiplier_at_secs(f64::NAN), 1.0); + assert_eq!(multiplier_at_secs(10_000.0), MAX_MULTIPLIER); +} + +#[test] +fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() { + close(time_for_multiplier(2.0).as_secs_f64(), 5.0, 1e-9); + close(time_for_multiplier(10.0).as_secs_f64(), 16.609_640, 1e-4); + close(multiplier_at(time_for_multiplier(3.5)), 3.5, 1e-9); +} + +#[test] +fn cashout_value_is_floored_to_whole_pln() { + assert_eq!(payout(10, 1.0), 10); + assert_eq!(payout(10, 1.05), 10, "half a PLN is not paid out"); + assert_eq!(payout(10, 1.10), 11); + assert_eq!(payout(10, 2.47), 24); + assert_eq!(payout(7, 3.0), 21); + + assert_eq!(payout(0, 2.0), 0); + assert_eq!(payout(10, f64::NAN), 0); + assert_eq!(payout(10, f64::INFINITY), 0); + assert_eq!( + payout(u64::MAX, MAX_MULTIPLIER), + u64::MAX, + "a huge win saturates instead of wrapping" + ); +} + +#[test] +fn crash_points_follow_the_house_edge() { + // P(crash >= m) should be (1 - edge) / m. + let samples = 100_000; + for target in [1.5_f64, 2.0, 5.0, 50.0] { + let survivors = (0..samples) + .filter(|index| { + let u = *index as f64 / samples as f64; + crash_point_from_uniform(u) >= target + }) + .count(); + let observed = survivors as f64 / samples as f64; + let expected = (1.0 - HOUSE_EDGE) / target; + close(observed, expected, 0.002); + } +} + +#[test] +fn an_instant_cashout_still_pays_the_house_edge() { + // One round in a hundred crashes at 1.00x, so cashing out instantly returns + // the stake 99% of the time and nothing 1% of the time: no free money. + let samples = 100_000; + let instant_crashes = (0..samples) + .filter(|index| { + let u = *index as f64 / samples as f64; + crash_point_from_uniform(u) <= 1.0 + }) + .count(); + close(instant_crashes as f64 / samples as f64, HOUSE_EDGE, 0.001); + + let stake = 10; + let kept = payout(stake, 1.0); + assert_eq!(kept, stake); + let ev = kept as f64 * (1.0 - HOUSE_EDGE); + close(ev, stake as f64 * (1.0 - HOUSE_EDGE), 1e-9); +} + +#[test] +fn crash_points_are_clamped_to_the_liability_cap() { + assert_eq!(crash_point_from_uniform(0.0), 1.0); + assert_eq!(crash_point_from_uniform(-1.0), 1.0); + assert_eq!(crash_point_from_uniform(f64::NAN), 1.0); + close(crash_point_from_uniform(0.5), 1.98, 1e-9); + close(crash_point_from_uniform(0.99), 99.0, 1e-9); + assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); + assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); +} 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 @@ use std::path::PathBuf; +use std::sync::Arc; use rusqlite::Connection; use zagan_bet::blackjack::BlackjackState; +use zagan_bet::crash::CrashState; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; +use zagan_bet::server::games::crash::ManualClock; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::slots::SlotsGame; @@ -73,6 +76,31 @@ fn blackjack_state(response: &str) -> BlackjackState { serde_json::from_str(raw).expect("valid blackjack state") } +fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let clock = ManualClock::new(); + let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) + .with_crash_uniform(uniform) + .with_clock(Arc::new(clock.clone())); + (core, path, clock) +} + +fn crash_state(response: &str) -> CrashState { + let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] + .iter() + .find_map(|prefix| response.strip_prefix(prefix)) + .unwrap_or_else(|| panic!("not a crash response: {response}")); + serde_json::from_str(raw).expect("valid crash state") +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -496,3 +524,220 @@ fn blackjack_disconnect_forfeits_the_reserved_bet() { assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } + +// 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0. +const CRASH_AT_3: f64 = 0.67; + +#[test] +fn crash_start_reserves_the_bet_and_hides_the_crash_point() { + let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + assert_eq!(started.phase, "running"); + assert_eq!(started.bet, 10); + assert_eq!(started.balance, 990); + assert_eq!(started.multiplier, 1.0); + assert_eq!(started.potential, 10); + assert_eq!(started.payout, 0); + assert_eq!( + started.crash_point, None, + "the crash point must not leak before the round ends" + ); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.session_count(), 1); + assert_eq!(core.play_count("alice"), 0); +} + +#[test] +fn crash_cashout_pays_the_multiplier_from_the_server_clock() { + let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + // Five seconds of round time: 2.00x, still below the 3.00x crash point. + clock.advance(5.0); + let cashed = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {}", started.session_id), + )); + + assert_eq!(cashed.phase, "cashed"); + assert_eq!(cashed.multiplier, 2.0); + assert_eq!(cashed.potential, 20); + assert_eq!(cashed.payout, 20); + assert_eq!(cashed.balance, 1010); + let crash_point = cashed.crash_point.unwrap(); + assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); + assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); + + let conn = Connection::open(&path).unwrap(); + let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn + .query_row( + "SELECT game_type, bet_amount, winnings, balance_after, details + FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .unwrap(); + assert_eq!(game_type, "crash"); + assert_eq!(bet, 10); + assert_eq!(winnings, 20); + assert_eq!(balance_after, 1010); + let details: serde_json::Value = serde_json::from_str(&details).unwrap(); + assert_eq!(details["phase"], "cashed"); + let audited_crash_point = details["crash_point"].as_f64().unwrap(); + assert!( + (audited_crash_point - 3.0).abs() < 1e-9, + "got {audited_crash_point}" + ); + assert_eq!(details["multiplier"], 2.0); +} + +#[test] +fn a_cashout_after_the_crash_time_loses_the_reserved_bet() { + let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + clock.advance(20.0); + let late = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {}", started.session_id), + )); + + assert_eq!(late.phase, "crashed"); + assert_eq!(late.payout, 0); + assert_eq!(late.balance, 990); + let crash_point = late.crash_point.unwrap(); + assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_poll_pushes_the_bust_exactly_once() { + let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + clock.advance(1.0); + assert!( + core.poll(&state).is_empty(), + "a running round must not push anything" + ); + + clock.advance(15.0); + let pushed = core.poll(&state); + assert_eq!(pushed.len(), 1); + let busted = crash_state(&pushed[0]); + assert_eq!(busted.session_id, started.session_id); + assert_eq!(busted.phase, "crashed"); + assert_eq!(busted.payout, 0); + assert_eq!(busted.balance, 990); + + assert!(core.poll(&state).is_empty(), "the bust must not repeat"); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_forfeit_keeps_the_reserved_bet() { + let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + let forfeited = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_FORFEIT {}", started.session_id), + )); + + assert_eq!(forfeited.phase, "forfeit"); + assert_eq!(forfeited.payout, 0); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_disconnect_forfeits_the_reserved_bet() { + let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + assert_eq!(started.balance, 990); + + core.disconnect(&state); + + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_rejects_a_second_round_and_bad_cashout_formats() { + let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + assert_eq!( + core.handle_message(&mut state, "PLAY_START crash 10"), + "ERROR Crash round already in progress" + ); + assert_eq!( + core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {} 50.0", started.session_id) + ), + "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT " + ); + assert_eq!( + core.handle_message(&mut state, "CRASH_CASHOUT nope"), + "ERROR Session not found" + ); +} + +#[test] +fn crash_rounds_are_owned_by_the_player_who_started_them() { + let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3); + let mut alice = ConnectionState::default(); + let mut bob = ConnectionState::default(); + register(&mut core, &mut alice, "alice"); + register(&mut core, &mut bob, "bob"); + login(&mut core, &mut alice, "alice"); + login(&mut core, &mut bob, "bob"); + + let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10")); + + clock.advance(5.0); + assert_eq!( + core.handle_message( + &mut bob, + &format!("CRASH_CASHOUT {}", started.session_id) + ), + "ERROR Invalid session" + ); + assert_eq!(core.balance("bob"), Some(1000)); + assert_eq!(core.session_count(), 1, "alice's round must survive"); +} -- cgit v1.2.3