use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; use crate::crash::{ CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform, }; use rand::RngCore; 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, /// The 32 random bytes the crash point commits to, hex encoded. Kept on /// the server until the round ends. pub seed: String, /// `sha256(seed)` in hex, sent with the start reply. pub commit: String, pub started_at: f64, /// Server Unix time at start, milliseconds, for clock sync. pub started_at_ms: u64, /// `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, seed: [u8; SEED_LEN], started_at: f64, started_at_ms: u64) -> Self { let crash_point = crash_point_from_seed(&seed); Self { bet, crash_point, seed: seed_hex(&seed), commit: commit_seed(&seed), started_at, started_at_ms, 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), started_at: self.started_at_ms, crash_point: if self.is_running() { None } else { Some(self.crash_point) }, payout: self.payout, result: self.result.clone(), commit: Some(self.commit.clone()), seed: if self.is_running() { None } else { Some(self.seed.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, } /// Hex encode a round seed. Lowercase, no separators. fn seed_hex(seed: &[u8]) -> String { let mut out = String::with_capacity(seed.len() * 2); for byte in seed { use std::fmt::Write as _; let _ = write!(out, "{byte:02x}"); } out } /// Draw a seed whose committed point matches a queued test uniform. /// /// The first eight bytes are the big-endian integer closest to /// `uniform * 2^64`; the remaining bytes are zero. This keeps the existing /// deterministic crash-point tests meaningful while every round still has a /// real seed. fn seed_from_uniform(uniform: f64) -> [u8; SEED_LEN] { let uniform = if uniform.is_finite() { uniform.clamp(0.0, 0.999_999_999_999_999_9) } else { 0.0 }; let value = (uniform * 18_446_744_073_709_551_616.0) as u64; let mut seed = [0u8; SEED_LEN]; seed[..8].copy_from_slice(&value.to_be_bytes()); seed } 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(), } } /// Draw the next round seed. Tests supply uniforms; production uses the /// operating system's random source. pub fn next_seed(&mut self) -> [u8; SEED_LEN] { if let Some(uniform) = self.uniforms.pop_front() { return seed_from_uniform(uniform); } let mut seed = [0u8; SEED_LEN]; rand::thread_rng().fill_bytes(&mut seed); seed } /// Kept for callers that only need the point a queued uniform encodes. pub fn next_crash_point(&mut self) -> f64 { crash_point_from_seed(&self.next_seed()) } /// The uniform encoded by a seed, exposed for verification tests. pub fn uniform_of(seed: &[u8; SEED_LEN]) -> f64 { seed_to_uniform(seed) } } 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(), )) } }