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 --- 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 + 16 files changed, 1150 insertions(+), 40 deletions(-) create mode 100644 src/client/games/crash.rs create mode 100644 src/crash.rs create mode 100644 src/server/games/crash.rs (limited to 'src') 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 } -- cgit v1.2.3