From 24e3bb54db579fb9aad8df2a591527dc77fe4cb4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 19:19:49 +0100 Subject: v4.0.7: finish the Phase 1 boundary with a command shell The terminal command line moves to client::shell: a typed line becomes a core Command or an adapter action (connect, login, register, set-credential, host maintenance). ClientCore loses submit_line and the last TUI-shaped effects (Interactive, SetPrompt, ListHosts, ForgetHost, AnimateRoulette); the adapter animates roulette from the Result view. Adds forget-login, which drops the stored credential for the pinned key and keeps trust, distinct from logout (session only) and forget-host (trust too). --- src/client/core.rs | 251 +++++++++++-------------------------------------- src/client/database.rs | 15 +++ src/client/mod.rs | 1 + src/client/shell.rs | 110 ++++++++++++++++++++++ src/client/ui.rs | 14 +++ src/client/view.rs | 4 + 6 files changed, 201 insertions(+), 194 deletions(-) create mode 100644 src/client/shell.rs (limited to 'src/client') diff --git a/src/client/core.rs b/src/client/core.rs index 280eb77..21c1cb2 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -6,20 +6,13 @@ use crate::client::games::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::client::ui::{game_lines, history_lines}; 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. @@ -29,20 +22,12 @@ pub enum Effect { 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, } @@ -175,12 +160,8 @@ impl ClientCore { 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 }] + self.board_effects() } Err(message) => { self.game.set_error(message); @@ -331,22 +312,41 @@ impl ClientCore { 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), + 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::Games => vec![Effect::Print(game_lines(&self.games()))], + Command::Ping => { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + self.pending = Some(Pending::Ping); + vec![Effect::SendLine("PING".to_string())] + } + Command::Echo(message) => { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + if message.trim().is_empty() { + return print_line("Usage: send "); + } + self.pending = Some(Pending::Echo); + vec![Effect::SendLine(message)] + } + Command::Info => self.info_effects(), + Command::Play { game, bet } => self.play_game(&game, bet), Command::History { limit } => self.load_history(limit), Command::ShowLobby => { self.screen = Screen::Lobby; @@ -672,106 +672,6 @@ impl ClientCore { 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."); @@ -822,56 +722,7 @@ impl ClientCore { 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 { + fn info_effects(&mut self) -> Vec { if !self.connected { return print_line("Not connected to any host."); } @@ -895,7 +746,24 @@ impl ClientCore { effects } - /// Snapshot of the current screen for a frontend to draw. + /// The games a frontend can offer. + pub fn games(&self) -> Vec<(String, String)> { + self.game + .get_available_games() + .into_iter() + .map(|(game, name)| (game.to_string(), name.to_string())) + .collect() + } + + /// Reset connection-dependent state after the adapter closed the socket. + /// + /// The transport belongs to the frontend, so it tells the core when the + /// link is gone. What to say about it is the frontend's business. + pub fn reset_after_disconnect(&mut self) { + self.set_connected(false); + self.game.end_game(); + } + pub fn view(&self) -> Option { if let Some(state) = self.game.get_current_state() { return Some(match state.game_type.as_str() { @@ -915,12 +783,7 @@ impl ClientCore { 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(), + games: self.games(), login_error: self.login_error.clone(), }), }) diff --git a/src/client/database.rs b/src/client/database.rs index c4601b5..40ee8b4 100644 --- a/src/client/database.rs +++ b/src/client/database.rs @@ -151,6 +151,21 @@ pub fn forget_host(conn: &Connection, host: &str) -> Result Result { + let removed = conn.execute( + "DELETE FROM credentials WHERE fingerprint = ?1", + [fingerprint], + )?; + Ok(removed > 0) +} + /// List all remembered hosts with their fingerprints. pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option)> { let mut stmt = conn diff --git a/src/client/mod.rs b/src/client/mod.rs index 1d79d97..e3bc1b3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -3,6 +3,7 @@ pub mod database; pub mod games; pub mod input; pub mod paths; +pub mod shell; pub mod transport; pub mod tui; pub mod ui; diff --git a/src/client/shell.rs b/src/client/shell.rs new file mode 100644 index 0000000..73843c8 --- /dev/null +++ b/src/client/shell.rs @@ -0,0 +1,110 @@ +//! Parser for the terminal's command line. +//! +//! The core speaks `Command`; this module turns one typed line into either a +//! core command or an action only an interactive frontend can perform +//! (connecting, prompting for a password, listing hosts). + +use crate::client::ui::parse_bet_arg; +use crate::client::view::Command; +use crate::history::{DEFAULT_HISTORY, MAX_HISTORY}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Shell { + /// A command the core can run on its own. + Run(Command), + Login, + Register, + SetCred, + Connect(Option), + Disconnect, + Hosts, + ForgetHost(Option), + ForgetLogin(Option), + Help, + Quit, + Empty, + Usage(String), +} + +/// Split off the first whitespace-delimited word. +/// +/// The remainder keeps its internal spacing (so `send` can echo a message +/// verbatim) but loses the separator run before it. +fn split_word(line: &str) -> (&str, &str) { + match line.find(char::is_whitespace) { + Some(index) => (&line[..index], line[index..].trim_start()), + None => (line, ""), + } +} + +pub fn parse(line: &str) -> Shell { + let line = line.trim(); + if line.is_empty() { + return Shell::Empty; + } + + let (word, rest) = split_word(line); + match word { + "play" => { + let (game, bet) = split_word(rest); + if game.is_empty() { + return Shell::Usage("Usage: play [bet]".to_string()); + } + let bet = if bet.is_empty() { + None + } else { + match parse_bet_arg(bet) { + Ok(bet) => Some(bet), + Err(message) => return Shell::Usage(message), + } + }; + Shell::Run(Command::Play { + game: game.to_string(), + bet, + }) + } + "history" => { + let (raw, _) = split_word(rest); + let limit = if raw.is_empty() { + DEFAULT_HISTORY + } else { + match raw.parse::() { + Ok(count) if (1..=MAX_HISTORY).contains(&count) => count, + _ => return Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")), + } + }; + Shell::Run(Command::History { limit }) + } + "games" => Shell::Run(Command::Games), + "logout" => Shell::Run(Command::Logout), + "ping" => Shell::Run(Command::Ping), + "send" => { + if rest.is_empty() { + Shell::Usage("Usage: send ".to_string()) + } else { + Shell::Run(Command::Echo(rest.to_string())) + } + } + "info" => Shell::Run(Command::Info), + "help" => Shell::Help, + "connect" => Shell::Connect(optional_word(rest)), + "login" => Shell::Login, + "register" => Shell::Register, + "setcred" => Shell::SetCred, + "disconnect" => Shell::Disconnect, + "hosts" => Shell::Hosts, + "forget-host" => Shell::ForgetHost(optional_word(rest)), + "forget-login" => Shell::ForgetLogin(optional_word(rest)), + "quit" => Shell::Quit, + _ => Shell::Help, + } +} + +fn optional_word(rest: &str) -> Option { + let (word, _) = split_word(rest); + if word.is_empty() { + None + } else { + Some(word.to_string()) + } +} diff --git a/src/client/ui.rs b/src/client/ui.rs index bb5b55f..7439e0f 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -75,6 +75,11 @@ const COMMANDS: &[CommandHelp] = &[ usage: "forget-host [ip:port]", description: "clear trust for one address (credentials stay while the key is in use)", }, + CommandHelp { + name: "forget-login", + usage: "forget-login [ip:port]", + description: "forget the saved login (trust is kept)", + }, CommandHelp { name: "hosts", usage: "hosts", @@ -272,6 +277,15 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option { )) } +/// List of games for the terminal's `games` command. +pub fn game_lines(games: &[(String, String)]) -> Vec { + let mut lines = vec!["Available games:".to_string()]; + for (game, name) in games { + lines.push(format!(" - {game} ({name})")); + } + lines +} + /// One line per settled play, newest first. pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec { if records.is_empty() { diff --git a/src/client/view.rs b/src/client/view.rs index 9443726..d2ccadb 100644 --- a/src/client/view.rs +++ b/src/client/view.rs @@ -274,6 +274,10 @@ pub enum View { pub enum Command { /// The credential is already hashed by the frontend. Login { user: String, credential: String }, + Games, + Ping, + Echo(String), + Info, Register { user: String, credential: String }, Play { game: String, bet: Option }, History { limit: u32 }, -- cgit v1.2.3