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::view::{ BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, }; use crate::client::ui::{help_lines, history_lines, parse_bet_arg}; use crate::crash::{CrashState, multiplier_at}; use crate::history::{PlayRecord, MAX_HISTORY}; use crate::roulette::{BetTarget, Bets}; use std::time::{Duration, Instant}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum InteractiveCommand { Login, Register, SetCred, Connect(Option), } /// 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), /// Run the roulette wheel animation, then draw the final view. AnimateRoulette { winning: u8, view: View }, /// Remove the game board region. ClearRegion, /// Send one protocol line and feed the reply to `handle_reply`. SendLine(String), /// Delegate an interactive prompt flow to the terminal adapter. Interactive(InteractiveCommand), /// Drop the connection. Disconnect, /// Let the adapter read the host database. ListHosts, /// Let the adapter delete a host from the database. ForgetHost(String), /// 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, balance: Option, host: Option, fingerprint: Option, /// Local anchor for the crash multiplier, set when a round starts. crash_started: Option, /// 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, balance: None, host: None, fingerprint: None, crash_started: None, 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.crash_started = None; self.screen = Screen::Lobby; } } 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 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() } pub fn handle_reply(&mut self, reply: &str) -> Vec { 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); let winning = result.number; self.game.apply_roulette_result(result); let Some(view) = self.view() else { return Vec::new(); }; vec![Effect::AnimateRoulette { winning, view }] } 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 reply.trim() == "PONG" { 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) => { if self.game.is_in_game() { // Balance fetch while entering a game. A failure is not // fatal: the board keeps showing `?`. if let Some(balance) = info_balance(reply) { self.balance = Some(balance); self.game.set_balance(balance); return self.board_effects(); } Vec::new() } 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(); } self.pending = Some(Pending::Spin); let mut effects = self.board_effects(); effects.push(Effect::SendLine(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; self.pending = Some(Pending::Login { user: user.clone() }); vec![Effect::SendLine(format!("AUTH {user} {credential}"))] } Command::Register { user, credential } => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; self.pending = Some(Pending::Register); vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] } 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::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.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"); self.pending = Some(Pending::BlackjackForfeit); vec![Effect::SendLine(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(); }; self.pending = Some(Pending::CrashForfeit); vec![Effect::SendLine(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 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(); } self.pending = Some(Pending::RouletteSpin); let mut effects = self.board_effects(); effects.push(Effect::SendLine(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"); self.pending = Some(Pending::BlackjackStart); let mut effects = self.board_effects(); effects.push(Effect::SendLine(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(); } self.pending = Some(Pending::BlackjackAction); vec![Effect::SendLine(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 { 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 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}"))] } /// Run one command line. The interactive frontend owns the buffer and the /// history; the core only sees the finished line. pub fn submit_line(&mut self, line: &str) -> Vec { let line = line.trim().to_string(); if line.is_empty() { return Vec::new(); } let parts: Vec<&str> = line.splitn(3, ' ').collect(); match parts[0] { "play" => self.cmd_play(&parts), "games" => self.available_games(None), "help" => vec![Effect::Print(help_lines())], "connect" => { if self.connected { print_line("Already connected. Disconnect first.") } else { let target = parts.get(1).map(|value| value.to_string()); vec![Effect::Interactive(InteractiveCommand::Connect(target))] } } "login" => { if !self.connected { print_line("Not connected. Use 'connect :' first.") } else if self.logged_in.is_some() { print_line(format!( "Already logged in as {}", self.logged_in.as_deref().unwrap_or("?") )) } else { vec![Effect::Interactive(InteractiveCommand::Login)] } } "register" => { if !self.connected { print_line("Not connected. Use 'connect :' first.") } else { vec![Effect::Interactive(InteractiveCommand::Register)] } } "setcred" => { if self.host.is_none() { print_line("No host connected. Use 'connect :' first.") } else { vec![Effect::Interactive(InteractiveCommand::SetCred)] } } "logout" => { self.logged_in = None; print_line("Logged out") } "disconnect" => { self.connected = false; self.logged_in = None; self.pending = None; self.crash_started = None; self.game.end_game(); vec![ Effect::ClearRegion, Effect::Disconnect, Effect::Print(vec!["Disconnected".to_string()]), ] } "ping" => { if !self.connected { print_line("Not connected. Use 'connect :' first.") } else { self.pending = Some(Pending::Ping); vec![Effect::SendLine("PING".to_string())] } } "send" => { if !self.connected { print_line("Not connected. Use 'connect :' first.") } else if parts.len() < 2 { print_line("Usage: send ") } else { let message = parts[1..].join(" "); self.pending = Some(Pending::Echo); vec![Effect::SendLine(message)] } } "info" => self.cmd_info(), "history" => self.cmd_history(&parts), "hosts" => vec![Effect::ListHosts], "forget-host" => { let target = parts .get(1) .map(|value| value.to_string()) .or_else(|| self.host.clone()); match target { Some(target) => vec![Effect::ForgetHost(target)], None => print_line("Usage: forget-host : (or connect first)"), } } "quit" => vec![Effect::Quit], _ => vec![Effect::Print(help_lines())], } } 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(); self.pending = Some(Pending::Info); effects.push(Effect::SendLine("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; self.pending = Some(Pending::History); let request = if limit == crate::history::DEFAULT_HISTORY { "HISTORY".to_string() } else { format!("HISTORY {limit}") }; vec![Effect::SendLine(request)] } fn cmd_play(&mut self, parts: &[&str]) -> 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 parts.len() < 2 { return self.available_games(Some("Usage: play [bet]")); } let bet = match parts.get(2) { Some(raw) => match parse_bet_arg(raw) { Ok(bet) => Some(bet), Err(message) => return print_line(message), }, None => None, }; self.command(Command::Play { game: parts[1].to_string(), bet, }) } fn available_games(&self, usage: Option<&str>) -> Vec { let mut lines = vec!["Available games:".to_string()]; for (game_type, name) in self.game.get_available_games() { lines.push(format!(" - {} ({})", game_type, name)); } if let Some(usage) = usage { lines.push(String::new()); lines.push(usage.to_string()); } vec![Effect::Print(lines)] } fn cmd_history(&mut self, parts: &[&str]) -> Vec { let limit = match parts.get(1) { Some(raw) => match raw.parse::() { Ok(count) => count, Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), }, None => crate::history::DEFAULT_HISTORY, }; self.command(Command::History { limit }) } fn cmd_info(&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(), ])), } self.pending = Some(Pending::Info); effects.push(Effect::SendLine("INFO".to_string())); effects } /// Snapshot of the current screen for a frontend to draw. 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(), balance: self.balance, games: self .game .get_available_games() .into_iter() .map(|(game, name)| (game.to_string(), name.to_string())) .collect(), 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_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()] } }