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 --- src/client/core.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) (limited to 'src/client/core.rs') diff --git a/src/client/core.rs b/src/client/core.rs index 77761b4..5f8c513 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -1,12 +1,14 @@ use crate::client::games::manager::GameManager; use crate::client::games::response::{ - parse_blackjack_response, parse_roulette_response, parse_spin_response, + parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, }; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, record_history, }; +use crate::crash::{multiplier_at, CrashState}; use crate::roulette::{Bets, NumberBet}; +use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { @@ -66,6 +68,9 @@ enum Pending { BlackjackStart, BlackjackAction, BlackjackForfeit, + CrashStart, + CrashCashout, + CrashForfeit, Ping, Echo, Info, @@ -90,6 +95,8 @@ pub struct ClientCore { balance: Option, host: Option, fingerprint: Option, + /// Local anchor for the crash multiplier, set when a round starts. + crash_started: Option, } impl ClientCore { @@ -105,6 +112,7 @@ impl ClientCore { balance: None, host: None, fingerprint: None, + crash_started: None, } } @@ -113,6 +121,7 @@ impl ClientCore { if !connected { self.logged_in = None; self.pending = None; + self.crash_started = None; } } @@ -226,6 +235,49 @@ impl ClientCore { self.board_effects() } }, + Some(Pending::CrashStart) => match parse_crash_response(reply) { + Ok(state) => self.apply_crash(state), + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, + Some(Pending::CrashCashout) => match parse_crash_response(reply) { + Ok(state) => self.apply_crash(state), + Err(message) => { + // A pushed bust can settle the round before this reply + // arrives. The late request must not overwrite the result. + if self.game.crash_status() != "running" { + return self.board_effects(); + } + self.game.set_error(message); + self.board_effects() + } + }, + Some(Pending::CrashForfeit) => match parse_crash_response(reply) { + Ok(state) => { + self.crash_started = None; + self.game.end_game(); + let message = format!( + "Game exited. Round forfeited ({} PLN lost).", + state.bet + ); + vec![Effect::ClearRegion, Effect::Print(vec![message])] + } + Err(_) if self.game.crash_status() != "running" => { + // The round ended while the forfeit was in flight. + self.crash_started = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -270,6 +322,9 @@ impl ClientCore { if game_type == Some("blackjack") { return self.handle_blackjack_key(key); } + if game_type == Some("crash") { + return self.handle_crash_key(key); + } match key { Key::CtrlC => vec![Effect::Quit], @@ -559,6 +614,138 @@ impl ClientCore { ))] } + /// Redraw the rising multiplier. Called by the terminal loop on a timer. + pub fn tick(&mut self) -> Vec { + let Some(started) = self.crash_started else { + return Vec::new(); + }; + self.tick_at(started.elapsed()) + } + + pub fn tick_at(&mut self, elapsed: Duration) -> Vec { + if self.crash_started.is_none() { + return Vec::new(); + } + if self + .game + .get_current_state() + .map(|state| state.game_type.as_str()) + != Some("crash") + { + return Vec::new(); + } + self.game.set_crash_multiplier(multiplier_at(elapsed)); + self.board_effects() + } + + /// True while the server may push a crash event between requests. + pub fn expects_push(&self) -> bool { + self.crash_started.is_some() + } + + pub fn is_async(&self, line: &str) -> bool { + line.trim_start().starts_with("CRASH_BUSTED ") + } + + /// Handle a server-initiated line. Returns `None` when the line is a normal + /// request/response reply and belongs to `handle_reply`. + pub fn handle_async_line(&mut self, line: &str) -> Option> { + if !self.is_async(line) { + return None; + } + match parse_crash_response(line) { + Ok(state) => Some(self.apply_crash(state)), + Err(message) => { + self.game.set_error(message); + Some(self.board_effects()) + } + } + } + + fn apply_crash(&mut self, state: CrashState) -> Vec { + self.balance = Some(state.balance); + if state.is_running() { + if self.crash_started.is_none() { + self.crash_started = Some(Instant::now()); + } + } else { + self.crash_started = None; + } + self.game.apply_crash_state(state); + self.board_effects() + } + + fn handle_crash_key(&mut self, key: Key) -> Vec { + let status = self.game.crash_status(); + let running = status == "running"; + + match key { + Key::CtrlC => vec![Effect::Quit], + Key::Char('q') => { + if running { + let Some(session) = self.game.crash_session_id() else { + return Vec::new(); + }; + self.pending = Some(Pending::CrashForfeit); + vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] + } else { + self.crash_started = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + } + Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(), + Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(), + Key::Char('+') | Key::Char('=') if !running => { + let _ = self.game.adjust_bet(10); + self.board_effects() + } + Key::Char('-') if !running => { + let _ = self.game.adjust_bet(-10); + self.board_effects() + } + _ => Vec::new(), + } + } + + fn start_crash(&mut self) -> Vec { + let status = self.game.crash_status(); + if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { + return Vec::new(); + } + let Some(request) = self.game.spin_request() else { + return Vec::new(); + }; + if let Some(balance) = self.balance { + let bet = self + .game + .get_current_state() + .map(|state| state.bet_amount) + .unwrap_or(0); + if bet as i64 > balance { + self.game.set_error("Insufficient balance"); + return self.board_effects(); + } + } + + self.game.set_crash_status("starting"); + self.pending = Some(Pending::CrashStart); + let mut effects = self.board_effects(); + effects.push(Effect::SendLine(request)); + effects + } + + fn cash_out_crash(&mut self) -> Vec { + let Some(session) = self.game.crash_session_id() else { + return Vec::new(); + }; + self.pending = Some(Pending::CrashCashout); + vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] + } + fn submit(&mut self) -> Vec { let line = self.input.trim().to_string(); self.input.clear(); @@ -616,6 +803,7 @@ impl ClientCore { self.connected = false; self.logged_in = None; self.pending = None; + self.crash_started = None; self.game.end_game(); vec![ Effect::ClearRegion, -- cgit v1.2.3