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/client_core.rs | 210 ++++++++++++++++++++++++++++++++++++++++++- tests/crash.rs | 100 +++++++++++++++++++++ tests/server_core.rs | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 tests/crash.rs (limited to 'tests') diff --git a/tests/client_core.rs b/tests/client_core.rs index ec444bd..e8b5646 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -6,8 +6,12 @@ use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; -use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; +use zagan_bet::client::games::response::{ + parse_blackjack_response, parse_crash_response, parse_spin_response, +}; use zagan_bet::client::ui::command_names; +use zagan_bet::crash::CrashState; +use std::time::Duration; const WIN3: &str = "CCCCLLCLODGSDGS"; const LOSS: &str = "CLOOGDSLCDCOGLS"; @@ -145,6 +149,25 @@ impl Harness { self.line("play blackjack"); self.reply("INFO alice 1 1000"); } + + fn enter_crash(&mut self) { + self.line("play crash"); + self.reply("INFO alice 1 1000"); + } + + fn tick(&mut self, elapsed: Duration) { + let effects = self.core.tick_at(elapsed); + self.apply(effects); + } + + /// Deliver a server-initiated line the way the terminal loop does. + fn push(&mut self, line: &str) { + let effects = self + .core + .handle_async_line(line) + .expect("a pushed crash line"); + self.apply(effects); + } } #[test] @@ -851,3 +874,188 @@ fn parses_a_blackjack_state() { assert_eq!(parse_blackjack_response(&response).unwrap(), state); assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); } + +fn crash_state(phase: &str, multiplier: f64, crash_point: Option, payout: u64, balance: i64) -> CrashState { + CrashState { + session_id: "cr1".to_string(), + phase: phase.to_string(), + bet: 10, + balance, + multiplier, + potential: (10.0 * multiplier) as u64, + crash_point, + payout, + result: phase.to_string(), + } +} + +fn crash_line(prefix: &str, state: &CrashState) -> String { + format!("{prefix} {}", serde_json::to_string(state).unwrap()) +} + +#[test] +fn crash_starts_a_round_and_draws_the_multiplier_locally() { + let mut h = Harness::new(); + h.enter_crash(); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[4].contains("Press [SPACE] to start.")); + assert!(h.board()[5].contains("[SPACE] Play")); + + h.key(Key::Char(' ')); + assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10")); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[2].contains("Multiplier: 1.00x")); + assert!(h.board()[3].contains("Potential: 10 PLN")); + assert!(h.board()[4].contains("Rising")); + assert!(h.board()[5].contains("[C] Cash out")); + + // The multiplier is drawn from the local clock, with no server round trip. + h.tick(Duration::from_secs(5)); + assert!(h.board()[2].contains("Multiplier: 2.00x")); + assert!(h.board()[3].contains("Potential: 20 PLN")); + + h.key(Key::Char('c')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("CRASH_CASHOUT cr1") + ); + + let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); + h.reply(&crash_line("CRASH_STATE", &cashed)); + + assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN")); + assert!(h.board()[5].contains("[SPACE] Play")); + assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN"); +} + +#[test] +fn a_pushed_bust_ends_the_round_without_a_request() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + h.tick(Duration::from_secs(3)); + + assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1")); + + let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + + assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost")); + assert!(h.board()[5].contains("[SPACE] Play")); + + // The local clock stops once the round is over. + h.tick(Duration::from_secs(30)); + assert!(h.board()[2].contains("Multiplier: 1.50x")); + assert!(!h.core.expects_push()); +} + +#[test] +fn crash_q_forfeits_an_active_round() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('q')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("CRASH_FORFEIT cr1") + ); + + let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990); + h.reply(&crash_line("CRASH_STATE", &forfeited)); + + assert!(h.board().is_empty()); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Round forfeited (10 PLN lost)"))); +} + +#[test] +fn crash_ready_board_changes_the_next_bet() { + let mut h = Harness::new(); + h.enter_crash(); + + h.key(Key::Char('=')); + assert!(h.board()[1].contains("Bet: 20 PLN")); + assert!(h.board()[3].contains("Potential: 20 PLN")); + + h.key(Key::Char(' ')); + assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20")); +} + +#[test] +fn parses_crash_messages_and_errors() { + let running = crash_state("running", 1.0, None, 0, 990); + let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); + + assert_eq!( + parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(), + running + ); + assert_eq!( + parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(), + cashed + ); + assert_eq!( + parse_crash_response("ERROR Round already ended").unwrap_err(), + "Round already ended" + ); + assert!(parse_crash_response("GAME_RESULT nope").is_err()); +} + +#[test] +fn a_late_cashout_does_not_overwrite_a_pushed_bust() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('c')); + assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1")); + + // The round crashes while the cashout is in flight, so the push lands + // before the server's "session not found" reply. + let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + h.reply("ERROR Session not found"); + + assert!(h.board()[4].contains("CRASHED at 1.20x")); + assert!(!h.board().iter().any(|line| line.contains("ERROR"))); + assert!(h.board()[5].contains("[SPACE] Play")); +} + +#[test] +fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + + h.key(Key::Char('q')); + assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1")); + + let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + h.reply("ERROR Session not found"); + + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); + assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); +} diff --git a/tests/crash.rs b/tests/crash.rs new file mode 100644 index 0000000..f59c52f --- /dev/null +++ b/tests/crash.rs @@ -0,0 +1,100 @@ +//! Crash math: the curve, the payout floor, and the house edge. + +use std::time::Duration; +use zagan_bet::crash::{ + crash_point_from_uniform, multiplier_at, multiplier_at_secs, payout, time_for_multiplier, + HOUSE_EDGE, MAX_MULTIPLIER, +}; + +fn close(a: f64, b: f64, tolerance: f64) { + assert!( + (a - b).abs() <= tolerance, + "expected {b} +/- {tolerance}, got {a}" + ); +} + +#[test] +fn the_multiplier_doubles_every_five_seconds() { + close(multiplier_at(Duration::ZERO), 1.0, 1e-9); + close(multiplier_at(Duration::from_secs(5)), 2.0, 1e-9); + close(multiplier_at(Duration::from_secs(10)), 4.0, 1e-6); + close(multiplier_at(Duration::from_millis(2500)), 1.414_213_562, 1e-6); + + // Never below 1x, even for nonsense input. + assert_eq!(multiplier_at_secs(-3.0), 1.0); + assert_eq!(multiplier_at_secs(f64::NAN), 1.0); + assert_eq!(multiplier_at_secs(10_000.0), MAX_MULTIPLIER); +} + +#[test] +fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() { + close(time_for_multiplier(2.0).as_secs_f64(), 5.0, 1e-9); + close(time_for_multiplier(10.0).as_secs_f64(), 16.609_640, 1e-4); + close(multiplier_at(time_for_multiplier(3.5)), 3.5, 1e-9); +} + +#[test] +fn cashout_value_is_floored_to_whole_pln() { + assert_eq!(payout(10, 1.0), 10); + assert_eq!(payout(10, 1.05), 10, "half a PLN is not paid out"); + assert_eq!(payout(10, 1.10), 11); + assert_eq!(payout(10, 2.47), 24); + assert_eq!(payout(7, 3.0), 21); + + assert_eq!(payout(0, 2.0), 0); + assert_eq!(payout(10, f64::NAN), 0); + assert_eq!(payout(10, f64::INFINITY), 0); + assert_eq!( + payout(u64::MAX, MAX_MULTIPLIER), + u64::MAX, + "a huge win saturates instead of wrapping" + ); +} + +#[test] +fn crash_points_follow_the_house_edge() { + // P(crash >= m) should be (1 - edge) / m. + let samples = 100_000; + for target in [1.5_f64, 2.0, 5.0, 50.0] { + let survivors = (0..samples) + .filter(|index| { + let u = *index as f64 / samples as f64; + crash_point_from_uniform(u) >= target + }) + .count(); + let observed = survivors as f64 / samples as f64; + let expected = (1.0 - HOUSE_EDGE) / target; + close(observed, expected, 0.002); + } +} + +#[test] +fn an_instant_cashout_still_pays_the_house_edge() { + // One round in a hundred crashes at 1.00x, so cashing out instantly returns + // the stake 99% of the time and nothing 1% of the time: no free money. + let samples = 100_000; + let instant_crashes = (0..samples) + .filter(|index| { + let u = *index as f64 / samples as f64; + crash_point_from_uniform(u) <= 1.0 + }) + .count(); + close(instant_crashes as f64 / samples as f64, HOUSE_EDGE, 0.001); + + let stake = 10; + let kept = payout(stake, 1.0); + assert_eq!(kept, stake); + let ev = kept as f64 * (1.0 - HOUSE_EDGE); + close(ev, stake as f64 * (1.0 - HOUSE_EDGE), 1e-9); +} + +#[test] +fn crash_points_are_clamped_to_the_liability_cap() { + assert_eq!(crash_point_from_uniform(0.0), 1.0); + assert_eq!(crash_point_from_uniform(-1.0), 1.0); + assert_eq!(crash_point_from_uniform(f64::NAN), 1.0); + close(crash_point_from_uniform(0.5), 1.98, 1e-9); + close(crash_point_from_uniform(0.99), 99.0, 1e-9); + assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); + assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); +} 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