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 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) (limited to 'src/server/core.rs') 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()) + ) +} -- cgit v1.2.3