use crate::client::games::manager::GameManager; use crate::client::games::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 { Char(char), Enter, Backspace, Up, Down, Tab, Esc, CtrlC, } #[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, Eq)] pub enum Effect { /// Append lines to the scrolling transcript. Print(Vec), /// Replace the game board region in place. ReplaceRegion(Vec), /// Run the roulette wheel animation, then draw the final board lines. AnimateRoulette { winning: u8, final_lines: Vec, }, /// Remove the game board region. ClearRegion, /// Redraw the command prompt. `text` is the input without the `> ` prefix. SetPrompt { text: String, cursor_col: u16 }, /// 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, Copy, PartialEq, Eq)] enum Pending { Spin, RouletteSpin, BlackjackStart, BlackjackAction, BlackjackForfeit, CrashStart, CrashCashout, CrashForfeit, Ping, Echo, Info, } #[derive(Debug, Clone, Copy)] enum RouletteSide { Red, Black, Even, Odd, } pub struct ClientCore { input: String, history: Vec, history_index: usize, 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, } impl ClientCore { pub fn new() -> Self { Self { input: String::new(), history: Vec::new(), history_index: 0, game: GameManager::new(), connected: false, logged_in: None, pending: None, balance: None, host: None, fingerprint: None, crash_started: 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; } } pub fn set_logged_in(&mut self, user: Option) { 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() } pub fn prompt_effect(&self) -> Option { if self.is_in_game() { return None; } Some(Effect::SetPrompt { text: self.input.clone(), cursor_col: input_cursor_column(2, display_width(&self.input)), }) } pub fn handle_key(&mut self, key: Key) -> Vec { if self.game.is_in_game() { return self.handle_game_key(key); } match key { Key::CtrlC => vec![Effect::Quit], Key::Enter => self.submit(), Key::Backspace => { self.input.pop(); self.prompt_effects() } Key::Char(c) => { self.input.push(c); self.prompt_effects() } Key::Up => self.history_previous(), Key::Down => self.history_next(), Key::Tab => self.complete(), Key::Esc => Vec::new(), } } 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 final_lines = self.board_lines().unwrap_or_default(); vec![Effect::AnimateRoulette { winning, final_lines, }] } 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 ({} PLN 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 ({} 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") } 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 handle_game_key(&mut self, key: Key) -> Vec { let game_type = self .game .get_current_state() .map(|state| state.game_type.as_str()); if game_type == Some("roulette") { return self.handle_roulette_key(key); } 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], Key::Esc | Key::Char('q') => { self.game.end_game(); vec![ Effect::ClearRegion, Effect::Print(vec!["Game exited.".to_string()]), ] } Key::Char(' ') => self.spin(), Key::Char('+') | Key::Char('=') => { let _ = self.game.adjust_bet(10); self.board_effects() } Key::Char('-') => { let _ = self.game.adjust_bet(-10); self.board_effects() } _ => Vec::new(), } } 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 } fn handle_roulette_key(&mut self, key: Key) -> Vec { match key { Key::CtrlC => vec![Effect::Quit], Key::Char('q') => { self.game.end_game(); vec![ Effect::ClearRegion, Effect::Print(vec!["Game exited.".to_string()]), ] } Key::Esc => { self.game.set_roulette_number_input(""); self.board_effects() } Key::Char(' ') => self.spin_roulette(), Key::Char('r') => self.add_roulette_side(RouletteSide::Red), Key::Char('b') => self.add_roulette_side(RouletteSide::Black), Key::Char('e') => self.add_roulette_side(RouletteSide::Even), Key::Char('o') => self.add_roulette_side(RouletteSide::Odd), Key::Char('c') => { self.game.set_roulette_bets(&Bets::default()); self.game.set_roulette_number_input(""); self.game.set_roulette_status("place"); self.board_effects() } Key::Char('+') | Key::Char('=') => { let chip = self.game.roulette_chip().saturating_add(10); self.game.set_roulette_chip(chip); self.board_effects() } Key::Char('-') => { let chip = self.game.roulette_chip().saturating_sub(10).max(10); self.game.set_roulette_chip(chip); self.board_effects() } Key::Char(c) if c.is_ascii_digit() => { let mut input = self.roulette_input(); if input.len() < 2 { input.push(c); self.game.set_roulette_number_input(&input); } self.board_effects() } Key::Backspace => { let mut input = self.roulette_input(); input.pop(); self.game.set_roulette_number_input(&input); self.board_effects() } Key::Enter => { let input = self.roulette_input(); if !input.is_empty() { match input.parse::() { Ok(number) if number <= 36 => { self.add_roulette_number(number); self.game.set_roulette_number_input(""); } _ => { self.game.set_roulette_number_input(""); self.game.set_error("Number must be 0-36"); } } } self.board_effects() } _ => Vec::new(), } } fn add_roulette_side(&mut self, side: RouletteSide) -> Vec { let chip = self.game.roulette_chip(); let mut bets = self.game.roulette_bets(); match side { RouletteSide::Red => bets.red = bets.red.saturating_add(chip), RouletteSide::Black => bets.black = bets.black.saturating_add(chip), RouletteSide::Even => bets.even = bets.even.saturating_add(chip), RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip), } self.place_roulette_bets(bets) } fn add_roulette_number(&mut self, number: u8) -> Vec { let chip = self.game.roulette_chip(); let mut bets = self.game.roulette_bets(); if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { existing.amount = existing.amount.saturating_add(chip); } else { bets.numbers.push(NumberBet { number, amount: 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 roulette_input(&self) -> String { self.game .get_current_state() .and_then(|state| state.data.get("number_input")) .cloned() .unwrap_or_default() } fn handle_blackjack_key(&mut self, key: Key) -> Vec { let status = self .game .get_current_state() .and_then(|state| state.data.get("status")) .cloned() .unwrap_or_else(|| "ready".to_string()); let hand = self.game.blackjack_state(); match key { Key::CtrlC => vec![Effect::Quit], Key::Char('q') => { if let Some(hand) = hand.filter(|hand| hand.phase == "player") { self.game.set_blackjack_status("forfeiting"); self.pending = Some(Pending::BlackjackForfeit); vec![Effect::SendLine(format!( "BLACKJACK_ACTION {} forfeit", hand.session_id ))] } else { self.game.end_game(); vec![ Effect::ClearRegion, Effect::Print(vec!["Game exited.".to_string()]), ] } } Key::Char(' ') => self.start_blackjack(), Key::Char('h') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => { self.blackjack_action("hit") } Key::Char('s') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => { self.blackjack_action("stand") } Key::Char('d') if hand.as_ref().is_some_and(|hand| { hand.phase == "player" && hand.actions.iter().any(|action| action == "double") }) => { self.blackjack_action("double") } Key::Char('+') | Key::Char('=') if matches!(status.as_str(), "ready" | "done") => { let _ = self.game.adjust_bet(10); self.board_effects() } Key::Char('-') if matches!(status.as_str(), "ready" | "done") => { let _ = self.game.adjust_bet(-10); self.board_effects() } _ => Vec::new(), } } 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 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(); if line.is_empty() { return Vec::new(); } record_history(&mut self.history, &line); self.history_index = self.history.len(); 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(), "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 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, }; if self.game.is_in_game() { return print_line("Already in a game. Use 'quit' to exit current game."); } match self.game.enter_game(parts[1]) { 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 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_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 } fn history_previous(&mut self) -> Vec { if self.history_index == 0 { return Vec::new(); } self.history_index -= 1; self.input = self.history[self.history_index].clone(); self.prompt_effects() } fn history_next(&mut self) -> Vec { if self.history_index >= self.history.len() { return Vec::new(); } self.history_index += 1; self.input = if self.history_index == self.history.len() { String::new() } else { self.history[self.history_index].clone() }; self.prompt_effects() } fn complete(&mut self) -> Vec { let prefix = self.input.trim(); let matches: Vec<&'static str> = command_names() .into_iter() .filter(|command| command.starts_with(prefix)) .collect(); let mut effects = Vec::new(); if matches.len() == 1 { self.input = matches[0].to_string(); } else if matches.len() > 1 { effects.push(Effect::Print(completion_lines(&matches, 76))); } if let Some(prompt) = self.prompt_effect() { effects.push(prompt); } effects } fn board_lines(&self) -> Option> { let game = self.game.get_current_game()?; let state = self.game.get_current_state()?; Some(game.render(state).lines().map(str::to_string).collect()) } fn board_effects(&self) -> Vec { self.board_lines() .map(Effect::ReplaceRegion) .into_iter() .collect() } fn prompt_effects(&self) -> Vec { self.prompt_effect().into_iter().collect() } } 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: {} PLN", parts[3]), ] } else { vec![reply.trim().to_string()] } }