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/server/core.rs | 240 +++++++++++++++++++++++++++++++++++++++++++ src/server/games/crash.rs | 216 ++++++++++++++++++++++++++++++++++++++ src/server/games/mod.rs | 1 + src/server/games/registry.rs | 2 + 4 files changed, 459 insertions(+) create mode 100644 src/server/games/crash.rs (limited to 'src/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