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 --- tests/server_core.rs | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) (limited to 'tests/server_core.rs') diff --git a/tests/server_core.rs b/tests/server_core.rs index 08a2bf2..695cfe4 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -1,9 +1,12 @@ use std::path::PathBuf; +use std::sync::Arc; use rusqlite::Connection; use zagan_bet::blackjack::BlackjackState; +use zagan_bet::crash::CrashState; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; +use zagan_bet::server::games::crash::ManualClock; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::slots::SlotsGame; @@ -73,6 +76,31 @@ fn blackjack_state(response: &str) -> BlackjackState { serde_json::from_str(raw).expect("valid blackjack state") } +fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let clock = ManualClock::new(); + let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) + .with_crash_uniform(uniform) + .with_clock(Arc::new(clock.clone())); + (core, path, clock) +} + +fn crash_state(response: &str) -> CrashState { + let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] + .iter() + .find_map(|prefix| response.strip_prefix(prefix)) + .unwrap_or_else(|| panic!("not a crash response: {response}")); + serde_json::from_str(raw).expect("valid crash state") +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -496,3 +524,220 @@ fn blackjack_disconnect_forfeits_the_reserved_bet() { assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } + +// 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0. +const CRASH_AT_3: f64 = 0.67; + +#[test] +fn crash_start_reserves_the_bet_and_hides_the_crash_point() { + let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + assert_eq!(started.phase, "running"); + assert_eq!(started.bet, 10); + assert_eq!(started.balance, 990); + assert_eq!(started.multiplier, 1.0); + assert_eq!(started.potential, 10); + assert_eq!(started.payout, 0); + assert_eq!( + started.crash_point, None, + "the crash point must not leak before the round ends" + ); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.session_count(), 1); + assert_eq!(core.play_count("alice"), 0); +} + +#[test] +fn crash_cashout_pays_the_multiplier_from_the_server_clock() { + let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + // Five seconds of round time: 2.00x, still below the 3.00x crash point. + clock.advance(5.0); + let cashed = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {}", started.session_id), + )); + + assert_eq!(cashed.phase, "cashed"); + assert_eq!(cashed.multiplier, 2.0); + assert_eq!(cashed.potential, 20); + assert_eq!(cashed.payout, 20); + assert_eq!(cashed.balance, 1010); + let crash_point = cashed.crash_point.unwrap(); + assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); + assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); + + let conn = Connection::open(&path).unwrap(); + let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn + .query_row( + "SELECT game_type, bet_amount, winnings, balance_after, details + FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .unwrap(); + assert_eq!(game_type, "crash"); + assert_eq!(bet, 10); + assert_eq!(winnings, 20); + assert_eq!(balance_after, 1010); + let details: serde_json::Value = serde_json::from_str(&details).unwrap(); + assert_eq!(details["phase"], "cashed"); + let audited_crash_point = details["crash_point"].as_f64().unwrap(); + assert!( + (audited_crash_point - 3.0).abs() < 1e-9, + "got {audited_crash_point}" + ); + assert_eq!(details["multiplier"], 2.0); +} + +#[test] +fn a_cashout_after_the_crash_time_loses_the_reserved_bet() { + let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + clock.advance(20.0); + let late = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {}", started.session_id), + )); + + assert_eq!(late.phase, "crashed"); + assert_eq!(late.payout, 0); + assert_eq!(late.balance, 990); + let crash_point = late.crash_point.unwrap(); + assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_poll_pushes_the_bust_exactly_once() { + let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + + clock.advance(1.0); + assert!( + core.poll(&state).is_empty(), + "a running round must not push anything" + ); + + clock.advance(15.0); + let pushed = core.poll(&state); + assert_eq!(pushed.len(), 1); + let busted = crash_state(&pushed[0]); + assert_eq!(busted.session_id, started.session_id); + assert_eq!(busted.phase, "crashed"); + assert_eq!(busted.payout, 0); + assert_eq!(busted.balance, 990); + + assert!(core.poll(&state).is_empty(), "the bust must not repeat"); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_forfeit_keeps_the_reserved_bet() { + let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + let forfeited = crash_state(&core.handle_message( + &mut state, + &format!("CRASH_FORFEIT {}", started.session_id), + )); + + assert_eq!(forfeited.phase, "forfeit"); + assert_eq!(forfeited.payout, 0); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_disconnect_forfeits_the_reserved_bet() { + let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + assert_eq!(started.balance, 990); + + core.disconnect(&state); + + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn crash_rejects_a_second_round_and_bad_cashout_formats() { + let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + assert_eq!( + core.handle_message(&mut state, "PLAY_START crash 10"), + "ERROR Crash round already in progress" + ); + assert_eq!( + core.handle_message( + &mut state, + &format!("CRASH_CASHOUT {} 50.0", started.session_id) + ), + "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT " + ); + assert_eq!( + core.handle_message(&mut state, "CRASH_CASHOUT nope"), + "ERROR Session not found" + ); +} + +#[test] +fn crash_rounds_are_owned_by_the_player_who_started_them() { + let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3); + let mut alice = ConnectionState::default(); + let mut bob = ConnectionState::default(); + register(&mut core, &mut alice, "alice"); + register(&mut core, &mut bob, "bob"); + login(&mut core, &mut alice, "alice"); + login(&mut core, &mut bob, "bob"); + + let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10")); + + clock.advance(5.0); + assert_eq!( + core.handle_message( + &mut bob, + &format!("CRASH_CASHOUT {}", started.session_id) + ), + "ERROR Invalid session" + ); + assert_eq!(core.balance("bob"), Some(1000)); + assert_eq!(core.session_count(), 1, "alice's round must survive"); +} -- cgit v1.2.3