use crate::client::games::manager::GameManager; use crate::client::games::response::parse_spin_response; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, record_history, }; #[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), /// 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, Ping, Echo, Info, } 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, } 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, } } pub fn set_connected(&mut self, connected: bool) { self.connected = connected; if !connected { self.logged_in = None; self.pending = 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::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 { 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('+') => { 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 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.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()] } }