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 +++++++++++++++++++++++++++++++++++++++++++ src/client/games/manager.rs | 140 +++++++----- src/client/games/mod.rs | 1 + src/client/games/response.rs | 60 ++++++ src/client/games/slots.rs | 58 +++-- src/client/mod.rs | 2 + src/client/ui.rs | 143 +++++++++++++ 7 files changed, 831 insertions(+), 67 deletions(-) create mode 100644 src/client/core.rs create mode 100644 src/client/games/response.rs create mode 100644 src/client/ui.rs (limited to 'src/client') 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()] + } +} diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index be96b1a..71cd28c 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -1,11 +1,10 @@ +use super::response::SpinResult; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; use std::collections::HashMap; -use std::io::Write; pub struct GameManager { current_session: Option, - pending_game_type: Option, games: HashMap>, } @@ -16,16 +15,18 @@ impl GameManager { Self { current_session: None, - pending_game_type: None, games, } } pub fn get_available_games(&self) -> Vec<(&str, &str)> { - self.games + let mut games: Vec<(&str, &str)> = self + .games .iter() - .map(|(k, v)| (k.as_str(), v.get_name())) - .collect() + .map(|(key, game)| (key.as_str(), game.get_name())) + .collect(); + games.sort_unstable_by_key(|(key, _)| *key); + games } pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { @@ -37,8 +38,6 @@ impl GameManager { return Err(GameError::InvalidAction); } - self.pending_game_type = Some(game_type.to_string()); - self.current_session = Some(GameState { game_type: game_type.to_string(), bet_amount: 10, @@ -49,28 +48,75 @@ impl GameManager { Ok(()) } - pub fn start_game(&mut self, stream: &mut W) -> Result<(), GameError> { - let game_type = self - .pending_game_type - .as_ref() - .ok_or(GameError::InvalidAction)?; + /// The wire request for a spin, if the current phase allows one. + pub fn spin_request(&self) -> Option { + let session = self.current_session.as_ref()?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return None; + } + Some(format!( + "PLAY_START {} {}", + session.game_type, session.bet_amount + )) + } - let state = self + /// Move the board into its spinning state and drop the previous result + /// so stale reels/winnings cannot leak into the new spin. + pub fn begin_spin(&mut self) -> Result<(), GameError> { + let session = self .current_session .as_mut() .ok_or(GameError::InvalidAction)?; - - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { return Err(GameError::InvalidAction); } - let bet_amount = state.bet_amount; + session.phase = GamePhase::Playing; + session.data.remove("error"); + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + Ok(()) + } - let writer = stream; - writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) - .map_err(|_| GameError::NetworkError)?; + /// Store the settled spin so `render()` can draw it in place. + pub fn apply_result(&mut self, result: SpinResult) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::Finished; + session.bet_amount = result.bet_amount; + session.data.remove("error"); + session.data.insert("reels".to_string(), result.reels); + session + .data + .insert("win_type".to_string(), result.win_type); + session + .data + .insert("winnings".to_string(), result.winnings.to_string()); + session + .data + .insert("balance".to_string(), result.balance.to_string()); + } - Ok(()) + /// Show a failed spin without leaving the player stuck in `Playing`. + pub fn set_error(&mut self, message: impl Into) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::WaitingToStart; + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + session.data.insert("error".to_string(), message.into()); + } + + pub fn set_balance(&mut self, balance: i64) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("balance".to_string(), balance.to_string()); + } } pub fn get_current_game(&self) -> Option<&dyn Game> { @@ -83,49 +129,37 @@ impl GameManager { self.current_session.as_ref() } - pub fn get_current_render_lines(&self) -> Option { - let session = self.current_session.as_ref()?; - let game = self.games.get(&session.game_type)?; - Some(game.render_lines(session)) - } - pub fn is_in_game(&self) -> bool { self.current_session.is_some() } - pub fn is_game_started(&self) -> bool { - self.current_session - .as_ref() - .map(|s| s.phase != GamePhase::WaitingToStart) - .unwrap_or(false) + pub fn end_game(&mut self) { + self.current_session = None; } - pub fn reset_phase_to_waiting(&mut self) { - if let Some(ref mut session) = self.current_session { - session.phase = GamePhase::WaitingToStart; - session.data.clear(); + pub fn set_bet(&mut self, bet: u64) -> Result { + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); } - } - - pub fn end_game(&mut self) { - self.current_session = None; - self.pending_game_type = None; + session.bet_amount = bet.max(10); + Ok(session.bet_amount) } pub fn adjust_bet(&mut self, delta: i64) -> Result { - if let Some(ref session) = self.current_session { - if session.phase != GamePhase::WaitingToStart { - return Err(GameError::InvalidAction); - } + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); } - if let Some(ref mut session) = self.current_session { - let current_bet = session.bet_amount as i64; - let new_bet = (current_bet + delta).max(10) as u64; - session.bet_amount = new_bet; - Ok(new_bet) - } else { - Err(GameError::InvalidAction) - } + let current_bet = session.bet_amount as i64; + session.bet_amount = (current_bet + delta).max(10) as u64; + Ok(session.bet_amount) } } diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index aa74574..63cc579 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs @@ -1,3 +1,4 @@ pub mod manager; +pub mod response; pub mod slots; pub mod traits; diff --git a/src/client/games/response.rs b/src/client/games/response.rs new file mode 100644 index 0000000..6948805 --- /dev/null +++ b/src/client/games/response.rs @@ -0,0 +1,60 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpinResult { + pub session_id: String, + pub winnings: u64, + pub bet_amount: u64, + pub balance: i64, + pub reels: String, + pub win_type: String, +} + +/// Parse one server reply to `PLAY_START`. +/// +/// `Ok` carries the settled spin; `Err` carries a message that is safe to +/// show to the player (server errors are reported without the `ERROR ` prefix). +pub fn parse_spin_response(response: &str) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let parts: Vec<&str> = response.split_whitespace().collect(); + if parts.first() != Some(&"GAME_RESULT") { + return Err(format!("Unexpected server response: {response}")); + } + if parts.len() < 7 { + return Err("Malformed GAME_RESULT from server".to_string()); + } + + let winnings = parts[2] + .parse::() + .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?; + let bet_amount = parts[3] + .parse::() + .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?; + let balance = parts[4] + .parse::() + .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; + + if parts[5].chars().count() != 3 { + return Err(format!("Invalid reels from server: {}", parts[5])); + } + + Ok(SpinResult { + session_id: parts[1].to_string(), + winnings, + bet_amount, + balance, + reels: parts[5].to_string(), + win_type: parts[6].to_string(), + }) +} diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 6e2ebbe..5eed34d 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,4 +1,8 @@ -use super::traits::{Game, GameState}; +use super::traits::{Game, GamePhase, GameState}; + +/// Height of the game board. It must never depend on the spin result, or an +/// in-place redraw would move to the wrong line. +pub const SLOT_BOARD_LINES: usize = 7; pub struct SlotsGame; @@ -8,17 +12,36 @@ impl SlotsGame { } fn render_reels(&self, reels: &str) -> String { - if reels.is_empty() { - "[ ? ] [ ? ] [ ? ]".to_string() - } else { - let chars: Vec = reels.chars().collect(); - format!( - "[ {} ] [ {} ] [ {} ]\r", - chars.get(0).unwrap_or(&'?'), - chars.get(1).unwrap_or(&'?'), - chars.get(2).unwrap_or(&'?') - ) + let mut chars = reels.chars(); + format!( + "[ {} ] [ {} ] [ {} ]", + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?') + ) + } + + /// The fifth board line: current status, result, or error. + fn render_status(&self, state: &GameState) -> String { + if let Some(error) = state.data.get("error") { + return format!("ERROR: {error}"); + } + if state.phase == GamePhase::Playing { + return "Spinning...".to_string(); + } + if state.phase == GamePhase::Finished { + let winnings = state + .data + .get("winnings") + .map(String::as_str) + .unwrap_or("0"); + return match state.data.get("win_type").map(String::as_str) { + Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), + Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), + _ => "No win.".to_string(), + }; } + "Press [SPACE] to spin!".to_string() } } @@ -29,16 +52,23 @@ impl Game for SlotsGame { fn render(&self, state: &GameState) -> String { let reels = state.data.get("reels").cloned().unwrap_or_default(); + let balance = state + .data + .get("balance") + .map(String::as_str) + .unwrap_or("?"); format!( - "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", + "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", state.bet_amount, - self.render_reels(&reels) + balance, + self.render_reels(&reels), + self.render_status(state) ) } fn render_lines(&self, _state: &GameState) -> usize { - 6 + SLOT_BOARD_LINES } fn get_help(&self) -> String { diff --git a/src/client/mod.rs b/src/client/mod.rs index 5b38158..dc8423b 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,2 +1,4 @@ +pub mod core; pub mod database; pub mod games; +pub mod ui; diff --git a/src/client/ui.rs b/src/client/ui.rs new file mode 100644 index 0000000..036c36a --- /dev/null +++ b/src/client/ui.rs @@ -0,0 +1,143 @@ +//! Pure text/history helpers for the client TUI. +//! +//! Nothing here touches the terminal, so it is unit-tested from `tests/` +//! without a PTY. + +struct CommandHelp { + name: &'static str, + usage: &'static str, + description: &'static str, +} + +const COMMANDS: &[CommandHelp] = &[ + CommandHelp { + name: "connect", + usage: "connect :", + description: "connect to the server", + }, + CommandHelp { + name: "send", + usage: "send ", + description: "send a message to the server", + }, + CommandHelp { + name: "disconnect", + usage: "disconnect", + description: "disconnect from the server", + }, + CommandHelp { + name: "ping", + usage: "ping", + description: "ping the server", + }, + CommandHelp { + name: "login", + usage: "login", + description: "login to the server", + }, + CommandHelp { + name: "register", + usage: "register", + description: "register a new user", + }, + CommandHelp { + name: "setcred", + usage: "setcred", + description: "set credentials for a host", + }, + CommandHelp { + name: "logout", + usage: "logout", + description: "logout from the server", + }, + CommandHelp { + name: "info", + usage: "info", + description: "show account and connection info", + }, + CommandHelp { + name: "play", + usage: "play [bet]", + description: "start a game (e.g. play slots 50)", + }, + CommandHelp { + name: "games", + usage: "games", + description: "list available games", + }, + CommandHelp { + name: "help", + usage: "help", + description: "show this message", + }, + CommandHelp { + name: "forget-host", + usage: "forget-host [ip:port]", + description: "clear saved trust fingerprint and credentials", + }, + CommandHelp { + name: "hosts", + usage: "hosts", + description: "list remembered hosts", + }, + CommandHelp { + name: "quit", + usage: "quit", + description: "quit the program", + }, +]; + +pub fn command_names() -> Vec<&'static str> { + COMMANDS.iter().map(|command| command.name).collect() +} + +pub fn help_lines() -> Vec { + let mut lines = vec!["Available commands:".to_string()]; + for command in COMMANDS { + lines.push(format!( + " {:<28} - {}", + command.usage, command.description + )); + } + lines +} + +/// Add a command to the history, skipping blanks and consecutive duplicates. +pub fn record_history(history: &mut Vec, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + if history.last().is_some_and(|last| last == line) { + return; + } + history.push(line.to_string()); +} + +/// Parse the optional bet argument of `play [bet]`. +pub fn parse_bet_arg(raw: &str) -> Result { + let bet = raw + .trim() + .parse::() + .map_err(|_| format!("Invalid bet: {raw}"))?; + if bet < 10 { + return Err("Bet must be at least 10 PLN".to_string()); + } + Ok(bet) +} + +/// Lines to move the cursor up to reach the first line of a region that ends +/// on the current line. `render()` has no trailing newline, so a 7-line board +/// needs `MoveUp(6)`. +pub fn lines_to_move_up(previous_lines: usize) -> u16 { + previous_lines + .saturating_sub(1) + .min(u16::MAX as usize) as u16 +} + +/// Column (0-based) to move to after the given prompt and input text. +pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { + prompt_len + .saturating_add(input_len) + .min(u16::MAX as usize) as u16 +} -- cgit v1.2.3