From 86d5f832c8452aad2313489b82eb8fd514215b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:28:20 +0100 Subject: v0.2.3: add ClientCore and fix the game board rendering --- src/client/core.rs | 494 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 src/client/core.rs (limited to 'src/client/core.rs') diff --git a/src/client/core.rs b/src/client/core.rs new file mode 100644 index 0000000..0e54fba --- /dev/null +++ b/src/client/core.rs @@ -0,0 +1,494 @@ +use crate::client::games::manager::GameManager; +use crate::client::games::response::parse_spin_response; +use crate::client::ui::{ + command_names, 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, self.input.chars().count()), + }) + } + + 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 { + let mut lines = vec!["Possible completions:".to_string()]; + lines.push(matches.join(" ")); + effects.push(Effect::Print(lines)); + } + 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()] + } +} -- cgit v1.2.3