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 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 1 deletion(-) (limited to 'tests/client_core.rs') 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"))); +} -- cgit v1.2.3