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(), )) } }