use crate::client::games::manager::GameManager; use crate::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_history_response, parse_roulette_response, parse_spin_response, }; use crate::client::ui::{game_lines, history_lines}; use crate::client::view::{ BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, }; use crate::crash::{CrashState, multiplier_at}; use crate::history::{MAX_HISTORY, PlayRecord}; use crate::roulette::{BetTarget, Bets}; use crate::version::{parse_pong, unix_time_ms}; use std::time::{Duration, Instant}; /// High-level actions produced by the client core. The terminal adapter is /// responsible for turning these into crossterm calls and network writes. #[derive(Debug, Clone, PartialEq)] pub enum Effect { /// Append lines to the scrolling transcript. Print(Vec), /// Draw the current view in the board region. Render(View), /// Remove the game board region. ClearRegion, /// Send one enveloped request and route its reply back by `id`. SendRequest { id: u64, payload: String }, /// Drop the connection. Disconnect, /// Leave the program. Quit, } #[derive(Debug, Clone, PartialEq, Eq)] enum Pending { Spin, RouletteSpin, BlackjackStart, BlackjackAction, BlackjackForfeit, CrashStart, CrashCashout, CrashForfeit, History, Login { user: String }, Register, Ping, Echo, Info, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Screen { Lobby, History, } pub struct ClientCore { game: GameManager, connected: bool, logged_in: Option, pending: Option, /// Id of the request whose reply fills `pending`. pending_id: Option, /// Next correlation id allocated on this connection. next_request_id: u64, balance: Option, /// Account number from the last `INFO`, shown by both frontends. account: Option, host: Option, fingerprint: Option, /// Local anchor for the crash multiplier, set when a round starts. crash_started: Option, /// Server-time anchor for the crash multiplier, when the server supplied /// `started_at`. crash_started_ms: Option, /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`. server_offset_ms: i64, /// True once a frontend has supplied a server timestamp. clock_synced: bool, /// App screen shown when no game is open. screen: Screen, /// Last plays fetched with `history`. play_history: Vec, /// Why the last login or registration attempt failed. login_error: Option, } impl ClientCore { pub fn new() -> Self { Self { game: GameManager::new(), connected: false, logged_in: None, pending: None, pending_id: None, next_request_id: 1, balance: None, account: None, host: None, fingerprint: None, crash_started: None, crash_started_ms: None, server_offset_ms: 0, clock_synced: false, screen: Screen::Lobby, play_history: Vec::new(), login_error: None, } } pub fn set_connected(&mut self, connected: bool) { self.connected = connected; if !connected { self.logged_in = None; self.pending = None; self.pending_id = None; self.crash_started = None; self.crash_started_ms = None; self.clock_synced = false; self.screen = Screen::Lobby; } } /// The signed-in username, if any. pub fn user(&self) -> Option<&str> { self.logged_in.as_deref() } pub fn set_logged_in(&mut self, user: Option) { if user.is_some() { self.login_error = None; } self.logged_in = user; } pub fn set_balance(&mut self, balance: i64) { self.balance = Some(balance); self.game.set_balance(balance); } pub fn balance(&self) -> Option { self.balance } pub fn set_host_info(&mut self, host: Option, fingerprint: Option) { self.host = host; self.fingerprint = fingerprint; } pub fn is_in_game(&self) -> bool { self.game.is_in_game() } /// True while a round is running and leaving would forfeit a reserved bet. /// The terminal may shrink during one of these without interrupting it. pub fn is_live_game(&self) -> bool { match self .game .get_current_state() .map(|state| state.game_type.as_str()) { Some("blackjack") => self .game .blackjack_state() .is_some_and(|hand| hand.phase == "player"), Some("crash") => self.crash_started.is_some(), _ => false, } } /// Redraw the current board, for terminal resizes. pub fn refresh_board(&self) -> Vec { self.board_effects() } /// Record the server's clock and the local time it was received. The /// offset is for display; cash-out still settles on the server. pub fn set_server_time(&mut self, server_ms: u64) { self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64; self.clock_synced = true; } /// The game server's clock, as this client currently estimates it. pub fn server_now_ms(&self) -> u64 { (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64 } /// Allocate a correlation id for a request the adapter sends directly /// (the terminal's login prompt bypasses `ClientCore`). pub fn allocate_request_id(&mut self) -> u64 { let id = self.next_request_id; self.next_request_id = self.next_request_id.wrapping_add(1).max(1); id } fn begin_request(&mut self, pending: Pending, payload: String) -> Effect { let id = self.allocate_request_id(); self.pending = Some(pending); self.pending_id = Some(id); Effect::SendRequest { id, payload } } /// Route a reply by correlation id. A reply we no longer expect (a late /// response after a timeout) is ignored instead of corrupting the board. pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec { if self.pending_id != Some(id) { return Vec::new(); } self.handle_reply(reply) } pub fn handle_reply(&mut self, reply: &str) -> Vec { self.pending_id = None; match self.pending.take() { Some(Pending::Spin) => match parse_spin_response(reply) { Ok(result) => { self.balance = Some(result.balance); self.game.apply_result(result); self.board_effects() } Err(message) => { self.game.set_error(message); self.board_effects() } }, Some(Pending::RouletteSpin) => match parse_roulette_response(reply) { Ok(result) => { self.balance = Some(result.balance); self.game.apply_roulette_result(result); self.board_effects() } Err(message) => { self.game.set_error(message); self.board_effects() } }, Some(Pending::BlackjackStart) | Some(Pending::BlackjackAction) => { match parse_blackjack_response(reply) { Ok(state) => { self.balance = Some(state.balance); self.game.apply_blackjack_state(state); self.board_effects() } Err(message) => { self.game.set_error(message); self.board_effects() } } } Some(Pending::BlackjackForfeit) => match parse_blackjack_response(reply) { Ok(state) => { self.game.end_game(); let message = format!( "Game exited. Active hand forfeited ({} Kr lost).", state.bet ); vec![Effect::ClearRegion, Effect::Print(vec![message])] } Err(message) => { self.game.set_error(message); 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 ({} Kr 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::History) => match parse_history_response(reply) { Ok(records) => { self.play_history = records.clone(); vec![Effect::Print(history_lines(&records))] } Err(message) => print_line(message), }, Some(Pending::Login { user }) => match auth_result(reply) { Ok(()) => { self.logged_in = Some(user.clone()); self.login_error = None; self.screen = Screen::Lobby; print_line(format!("Logged in as {user}")) } Err(message) => { self.login_error = Some(message.clone()); print_line(format!("Authentication failed: {message}")) } }, Some(Pending::Register) => match register_result(reply) { Ok(()) => print_line("Account created. Log in to play."), Err(message) => { self.login_error = Some(message.clone()); print_line(format!("Registration failed: {message}")) } }, Some(Pending::Ping) => { if let Some(server_ms) = parse_pong(reply) { self.set_server_time(server_ms); print_line("Pong") } else { self.connected = false; self.logged_in = None; vec![ Effect::Disconnect, Effect::Print(vec!["Connection lost".to_string()]), ] } } Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), Some(Pending::Info) => { // The same reply feeds the balance and the account number. let balance = info_balance(reply); if let Some(account) = info_account(reply) { self.account = Some(account); } if let Some(balance) = balance { self.balance = Some(balance); self.game.set_balance(balance); } if self.game.is_in_game() { // A failure is not fatal: the board keeps showing `?`. self.board_effects() } else { vec![Effect::Print(info_lines(reply))] } } None => print_line(reply.trim()), } } fn spin(&mut self) -> Vec { let Some(request) = self.game.spin_request() else { return Vec::new(); }; if self.game.begin_spin().is_err() { return Vec::new(); } let mut effects = self.board_effects(); effects.push(self.begin_request(Pending::Spin, request)); effects } /// Apply a semantic command. Both frontends drive the core through this. pub fn command(&mut self, command: Command) -> Vec { let game = self.current_game_type().map(str::to_string); match command { Command::Login { user, credential } => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; vec![self.begin_request( Pending::Login { user: user.clone() }, format!("AUTH {user} {credential}"), )] } Command::Register { user, credential } => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))] } Command::Games => vec![Effect::Print(game_lines(&self.games()))], Command::Ping => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } vec![self.begin_request(Pending::Ping, "PING".to_string())] } Command::Echo(message) => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } if message.trim().is_empty() { return print_line("Usage: send "); } vec![self.begin_request(Pending::Echo, message)] } Command::Info => self.info_effects(), Command::Play { game, bet } => self.play_game(&game, bet), Command::History { limit } => self.load_history(limit), Command::ShowLobby => { self.screen = Screen::Lobby; Vec::new() } Command::Logout => { self.logged_in = None; self.login_error = None; self.screen = Screen::Lobby; print_line("Logged out") } Command::SetBet(bet) => { if game.as_deref() == Some("roulette") { return self.command(Command::SetChip(bet)); } if self.game.set_bet(bet).is_ok() { self.board_effects() } else { Vec::new() } } Command::AdjustBet(delta) => { if game.as_deref() == Some("roulette") { return self.command(Command::AdjustChip(delta)); } if self.game.adjust_bet(delta).is_ok() { self.board_effects() } else { Vec::new() } } Command::Leave => self.leave_current_game(), Command::Spin => match game.as_deref() { Some("slots") => self.spin(), Some("roulette") => self.spin_roulette(), _ => Vec::new(), }, Command::Deal => self.start_blackjack(), Command::Hit => self.blackjack_action("hit"), Command::Stand => self.blackjack_action("stand"), Command::Double => self.blackjack_action("double"), Command::Start => self.start_crash(), Command::CashOut => self.cash_out_crash(), Command::Forfeit => match game.as_deref() { Some("blackjack") => self.forfeit_blackjack(), Some("crash") => self.forfeit_crash(), _ => Vec::new(), }, Command::MoveCursor(direction) => { let cursor = self.game.roulette_cursor().moved(direction); self.game.set_roulette_cursor(&cursor); self.board_effects() } Command::PlaceBet => match self.game.roulette_cursor().target() { Some(target) => self.place_roulette_chip_on(target), None => Vec::new(), }, Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), Command::RemoveBetAt(target) => self.remove_roulette_chip_on(target), Command::ClearBets => { self.game.set_roulette_bets(&Bets::default()); self.game.set_roulette_status("place"); self.board_effects() } Command::SetChip(chip) => { self.game.set_roulette_chip(chip.max(10)); self.board_effects() } Command::AdjustChip(delta) => { let chip = (self.game.roulette_chip() as i64 + delta).max(10) as u64; self.game.set_roulette_chip(chip); self.board_effects() } } } pub fn current_game_type(&self) -> Option<&str> { self.game .get_current_state() .map(|state| state.game_type.as_str()) } fn leave_current_game(&mut self) -> Vec { match self.current_game_type().map(str::to_string).as_deref() { Some("blackjack") => { if self .game .blackjack_state() .is_some_and(|hand| hand.phase == "player") { return self.forfeit_blackjack(); } } Some("crash") => { if self.game.crash_status() == "running" { return self.forfeit_crash(); } } _ => {} } self.exit_game() } fn exit_game(&mut self) -> Vec { self.crash_started = None; self.crash_started_ms = None; self.screen = Screen::Lobby; self.game.end_game(); vec![ Effect::ClearRegion, Effect::Print(vec!["Game exited.".to_string()]), ] } fn forfeit_blackjack(&mut self) -> Vec { let Some(hand) = self .game .blackjack_state() .filter(|hand| hand.phase == "player") else { return self.exit_game(); }; self.game.set_blackjack_status("forfeiting"); vec![self.begin_request( Pending::BlackjackForfeit, format!("BLACKJACK_ACTION {} forfeit", hand.session_id), )] } fn forfeit_crash(&mut self) -> Vec { let Some(session) = self.game.crash_session_id() else { return self.exit_game(); }; vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))] } fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec { let mut bets = self.game.roulette_bets(); bets.add(target, self.game.roulette_chip()); self.place_roulette_bets(bets) } fn remove_roulette_chip_on(&mut self, target: BetTarget) -> Vec { let mut bets = self.game.roulette_bets(); bets.reduce(&target, self.game.roulette_chip()); self.place_roulette_bets(bets) } fn place_roulette_bets(&mut self, bets: Bets) -> Vec { if let Some(balance) = self.balance { if bets.total() > balance.max(0) as u64 { self.game.set_error("Insufficient balance"); return self.board_effects(); } } self.game.set_roulette_bets(&bets); self.game.set_roulette_status("place"); self.board_effects() } fn spin_roulette(&mut self) -> Vec { let bets = self.game.roulette_bets(); if bets.is_empty() { self.game.set_error("Place at least one bet"); return self.board_effects(); } if let Some(balance) = self.balance { if bets.total() > balance.max(0) as u64 { self.game.set_error("Insufficient balance"); return self.board_effects(); } } let Some(request) = self.game.spin_request() else { return Vec::new(); }; if self.game.begin_spin().is_err() { return Vec::new(); } let mut effects = self.board_effects(); effects.push(self.begin_request(Pending::RouletteSpin, request)); effects } fn start_blackjack(&mut self) -> Vec { let status = self .game .get_current_state() .and_then(|state| state.data.get("status")) .cloned() .unwrap_or_else(|| "ready".to_string()); if !matches!(status.as_str(), "ready" | "done") { 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_blackjack_status("dealing"); let mut effects = self.board_effects(); effects.push(self.begin_request(Pending::BlackjackStart, request)); effects } fn blackjack_action(&mut self, action: &str) -> Vec { let Some(hand) = self.game.blackjack_state() else { return Vec::new(); }; if hand.phase != "player" { return Vec::new(); } if action == "double" && !hand.actions.iter().any(|available| available == "double") { return Vec::new(); } vec![self.begin_request( Pending::BlackjackAction, format!("BLACKJACK_ACTION {} {}", hand.session_id, action), )] } /// Redraw the rising multiplier. Called by the terminal loop on a timer. pub fn tick(&mut self) -> Vec { if self.clock_synced { if let Some(started_ms) = self.crash_started_ms { let now = unix_time_ms() as i64 + self.server_offset_ms; let elapsed_ms = (now - started_ms as i64).max(0) as u64; return self.tick_at(Duration::from_millis(elapsed_ms)); } } 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 { let line = line.trim_start(); line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ") } /// 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 let Some(tick) = line.trim_start().strip_prefix("crash.tick ") { return Some(self.apply_crash_tick(tick)); } 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()); } if state.started_at > 0 { self.crash_started_ms = Some(state.started_at); } } else { self.crash_started = None; self.crash_started_ms = None; } self.game.apply_crash_state(state); self.board_effects() } /// Apply `crash.tick session= t= m=`. fn apply_crash_tick(&mut self, tick: &str) -> Vec { let mut session = None; let mut multiplier = None; for token in tick.split_whitespace() { if let Some(value) = token.strip_prefix("session=") { session = Some(value); } else if let Some(value) = token.strip_prefix("m=") { multiplier = value.parse::().ok(); } } let Some(multiplier) = multiplier else { return Vec::new(); }; if let Some(session) = session { if self.game.crash_session_id().as_deref() != Some(session) { return Vec::new(); } } if self.game.crash_status() != "running" { return Vec::new(); } self.game.set_crash_multiplier(multiplier); self.board_effects() } 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"); let mut effects = self.board_effects(); effects.push(self.begin_request(Pending::CrashStart, request)); effects } fn cash_out_crash(&mut self) -> Vec { let Some(session) = self.game.crash_session_id() else { return Vec::new(); }; vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))] } fn play_game(&mut self, game_type: &str, bet: Option) -> Vec { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } if self.logged_in.is_none() { return print_line("Not logged in. Use 'login' first."); } if self.game.is_in_game() { return print_line("Already in a game. Use 'quit' to exit current game."); } match self.game.enter_game(game_type) { Ok(()) => { if let Some(bet) = bet { let _ = self.game.set_bet(bet); } if let Some(balance) = self.balance { self.game.set_balance(balance); } let mut effects = self.board_effects(); effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } Err(_) => print_line("Failed to enter game"), } } fn load_history(&mut self, limit: u32) -> Vec { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } if self.logged_in.is_none() { return print_line("Not logged in. Use 'login' first."); } if !(1..=MAX_HISTORY).contains(&limit) { return print_line(format!("Usage: history [1-{MAX_HISTORY}]")); } self.screen = Screen::History; let request = if limit == crate::history::DEFAULT_HISTORY { "HISTORY".to_string() } else { format!("HISTORY {limit}") }; vec![self.begin_request(Pending::History, request)] } fn info_effects(&mut self) -> Vec { if !self.connected { return print_line("Not connected to any host."); } let mut effects = Vec::new(); match &self.host { Some(host) => effects.push(Effect::Print(vec![format!("Connected to: {host}")])), None => effects.push(Effect::Print(vec!["Connected to: (unknown)".to_string()])), } match &self.fingerprint { Some(fingerprint) => { effects.push(Effect::Print(vec![format!("Fingerprint: {fingerprint}")])) } None => effects.push(Effect::Print(vec![ "Fingerprint: (not trusted)".to_string(), ])), } effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } /// The games a frontend can offer. pub fn games(&self) -> Vec<(String, String)> { self.game .get_available_games() .into_iter() .map(|(game, name)| (game.to_string(), name.to_string())) .collect() } /// Reset connection-dependent state after the adapter closed the socket. /// /// The transport belongs to the frontend, so it tells the core when the /// link is gone. What to say about it is the frontend's business. pub fn reset_after_disconnect(&mut self) { self.set_connected(false); self.game.end_game(); } pub fn view(&self) -> Option { if let Some(state) = self.game.get_current_state() { return Some(match state.game_type.as_str() { "slots" => View::Slots(SlotsView::from_state(state)), "roulette" => View::Roulette(RouletteView::from_state(state)), "blackjack" => View::Blackjack(BlackjackView::from_state(state)), "crash" => View::Crash(CrashView::from_state(state)), _ => return None, }); } Some(match self.screen { Screen::History => View::History(HistoryView { records: self.play_history.clone(), }), Screen::Lobby => View::Lobby(LobbyView { connected: self.connected, user: self.logged_in.clone(), account: self.account.clone(), balance: self.balance, games: self.games(), login_error: self.login_error.clone(), }), }) } fn board_effects(&self) -> Vec { if !self.is_in_game() { return Vec::new(); } self.view().map(Effect::Render).into_iter().collect() } } fn auth_result(reply: &str) -> Result<(), String> { let reply = reply.trim(); if reply.starts_with("OK") { return Ok(()); } Err(reply .strip_prefix("ERROR") .map(|message| message.trim().to_string()) .unwrap_or_else(|| reply.to_string())) } fn register_result(reply: &str) -> Result<(), String> { let reply = reply.trim(); if reply.starts_with("REGISTERED") { return Ok(()); } Err(reply .strip_prefix("ERROR") .map(|message| message.trim().to_string()) .unwrap_or_else(|| reply.to_string())) } fn print_line(message: impl Into) -> Vec { vec![Effect::Print(vec![message.into()])] } fn info_account(reply: &str) -> Option { let parts: Vec<&str> = reply.split_whitespace().collect(); if parts.first() == Some(&"INFO") && parts.len() == 4 { Some(parts[2].to_string()).filter(|account| account != "-") } else { None } } fn info_balance(reply: &str) -> Option { let parts: Vec<&str> = reply.split_whitespace().collect(); if parts.first() == Some(&"INFO") && parts.len() == 4 { parts[3].parse().ok() } else { None } } fn info_lines(reply: &str) -> Vec { let parts: Vec<&str> = reply.split_whitespace().collect(); if parts.first() == Some(&"INFO") && parts.len() == 4 { vec![ format!("User: {}", parts[1]), format!("Account: {}", parts[2]), format!("Balance: {} Kr", parts[3]), ] } else { vec![reply.trim().to_string()] } }