//! 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); }