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). --- AGENTS.md | 3 +- Cargo.toml | 2 +- docs/GUI-PLAN.md | 38 ++-- docs/adr/0001-testable-client-server-cores.md | 10 +- src/bin/client/repl.rs | 190 +++++++++++++------ 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 + tests/client_core.rs | 51 +++--- tests/client_shell.rs | 152 ++++++++++++++++ tests/client_ui.rs | 3 +- tests/storage.rs | 21 +++ 15 files changed, 566 insertions(+), 299 deletions(-) create mode 100644 src/client/shell.rs create mode 100644 tests/client_shell.rs diff --git a/AGENTS.md b/AGENTS.md index efa8b74..3908c2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,8 @@ by host. - `forget_host` removes one address. Drop the credential only when no address with that fingerprint remains, or two addresses for one server would stop - sharing a login. + sharing a login. `forget-login` is the narrower action: it drops the + credential and keeps the pinned key. - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... DO UPDATE SET last_used` — never `INSERT OR REPLACE`. - The client transport lives in `client::transport`. It records the key during diff --git a/Cargo.toml b/Cargo.toml index ed5d306..9e650f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "4.0.6" +version = "4.0.7" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index cb0db91..2789b81 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -24,18 +24,25 @@ Breaking wire change, released for client and server together. **Gate:** mismatch refused in both directions, `HELLO` required before auth, existing game flows unchanged, new tests for version parsing and gating. -## Phase 1 — core boundary and roulette - -**In progress.** `Command`/`View` exist and all four games are ported: the -core builds a typed view, frontends send `Command`, and the TUI renders it. -Done so far: every game plus `Lobby` and `History` are structured views; the -client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in -`client::transport` behind a trust callback; login and registration are -`Command`s, so a GUI form never needs a terminal prompt. The command-line -editor and key mapping moved to `client::input`; `ClientCore` no longer knows -keys, the prompt or its history. Remaining: the `Connect` and `Trust` adapter -screens, and replacing the last TUI-shaped effects (`Interactive`, host -listing, roulette animation) with commands. +## Phase 1 — core boundary and roulette (done) + +Complete in v4.0.7. Every game plus `Lobby` and `History` are structured views; +the core builds the view and frontends send `Command`. The client transport +(TCP, rustls, TOFU, `HELLO`, auto-login) lives in `client::transport` behind a +trust callback. The command line moved out of the core in two pieces: +`client::input` owns the editor, history and key mapping, and `client::shell` +parses a finished line into a core `Command` or an adapter action (connect, +login, register, set-credential, host maintenance). `ClientCore` no longer +holds a `Key`, a prompt buffer, or any of the old TUI-shaped effects +(`Interactive`, `SetPrompt`, `ListHosts`, `ForgetHost`, `AnimateRoulette`); the +roulette wheel animation is derived from the `Result` view by the adapter. +"Log out" (`logout`) and "forget saved login" (`forget-login`) are distinct: the +latter drops the credential for the pinned key and keeps trust. + +The `Connect` and `Trust` screens are adapter-owned for now: the terminal asks +in the transcript, and the GUI will draw them as its own screens over the same +`client::transport` callback. They are not core state because the core never +sees a certificate. - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, wheel animation, interactive prompts) into the TUI adapter. @@ -43,13 +50,14 @@ listing, roulette animation) with commands. `History`, `Game`. - Port roulette first: `PlaceBet(BetTarget)`, `SetChip`, `ClearBets`, `Spin`, and a `FeltView` carrying chips, cursor, and the last result. -- Add `ForgetLogin` so "log out" and "forget saved login" are distinct in both +- Add `forget-login` so "log out" and "forget saved login" are distinct in both frontends. - Auto-login stays: a stored credential for the pinned fingerprint logs in automatically after the trust check. -**Gate:** the TUI behaves exactly as today, its tests now assert on `View` -instead of text where practical, and `ClientCore` contains no terminal types. +**Gate:** met. The TUI behaves as before (same boards, keys and prompts), its +tests drive the real adapter path (`shell::parse`, `Editor`, `game_input`), and +`ClientCore` contains no terminal types. ## Phase 2 — the other three games diff --git a/docs/adr/0001-testable-client-server-cores.md b/docs/adr/0001-testable-client-server-cores.md index 2e3cd1e..c19c01a 100644 --- a/docs/adr/0001-testable-client-server-cores.md +++ b/docs/adr/0001-testable-client-server-cores.md @@ -16,10 +16,12 @@ exercised from `tests/`. - The library (`src/lib.rs`) owns shared logic. `src/bin/client` and `src/bin/server` are thin adapters for crossterm, TLS, environment variables, and interactive prompts. -- `client::core::ClientCore` owns input, history, commands, game state, and - pending replies. It returns `Effect`s (`Print`, `ReplaceRegion`, `ClearRegion`, - `SetPrompt`, `SendLine`, `Interactive`, ...). The binary applies them; the - tests apply them to a headless screen. +- `client::core::ClientCore` owns commands, game state, the current screen, and + pending replies. It returns `Effect`s (`Print`, `Render(View)`, `ClearRegion`, + `SendLine`, `Disconnect`, `Quit`). The binary applies them; the tests apply + them to a headless screen. The prompt buffer, history and key mapping live in + `client::input`, and `client::shell` parses a finished line, so the core never + sees a `Key` or a raw command line. - `server::core::ServerCore` owns message handling and receives an injected `Connection`, `GameRegistry`, and bcrypt cost. The binary only owns the TLS listener and per-connection thread. diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index bc4238d..cfdcdd5 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -13,12 +13,14 @@ use crossterm::{ use sha2::{Digest, Sha256}; -use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; -use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; +use zagan_bet::client::core::{ClientCore, Effect}; +use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; +use zagan_bet::client::shell::{self, Shell}; +use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; use zagan_bet::client::ui::{ - display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, - terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, + display_width, format_pong, help_lines, host_lines, input_cursor_column, + lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; use zagan_bet::protocol::io_failure_message; use zagan_bet::roulette::{WHEEL, color as roulette_color}; @@ -42,6 +44,8 @@ pub struct ClientState { terminal_size: (u16, u16), /// Size we already warned about, so a resize does not spam the transcript. size_warned: Option<(u16, u16)>, + /// Last roulette result the wheel animation ran for. + last_roulette: Option, } impl ClientState { @@ -67,6 +71,7 @@ impl ClientState { prompt_text: String::new(), terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), size_warned: None, + last_roulette: None, }) } @@ -129,7 +134,7 @@ impl ClientState { } else { match self.editor.handle(key) { Input::Quit => vec![Effect::Quit], - Input::Submit(line) => self.core.submit_line(&line), + Input::Submit(line) => self.submit_line(&line), Input::Print(lines) => vec![Effect::Print(lines)], Input::Prompt | Input::None => Vec::new(), } @@ -233,17 +238,26 @@ impl ClientState { } } Effect::Render(view) => { + if let View::Roulette(roulette) = &view { + if roulette.phase == RoulettePhase::Result { + if let Some(number) = roulette.number { + if self.last_roulette != Some(number) { + self.animate_wheel(number); + self.last_roulette = Some(number); + while crossterm::event::poll(std::time::Duration::ZERO) + .unwrap_or(false) + { + let _ = crossterm::event::read(); + } + } + } + } else { + self.last_roulette = None; + } + } let lines = zagan_bet::client::tui::render_view(&view); self.draw_region(&lines); } - Effect::AnimateRoulette { winning, view } => { - self.animate_wheel(winning); - let lines = zagan_bet::client::tui::render_view(&view); - self.draw_region(&lines); - while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { - let _ = crossterm::event::read(); - } - } Effect::ClearRegion => self.clear_region(), Effect::SendLine(line) => { let is_ping = line == "PING"; @@ -269,37 +283,11 @@ impl ClientState { return true; } } - Effect::Interactive(command) => { - self.commit_prompt_line(); - self.run_interactive(command); - } Effect::Disconnect => { self.stream = None; + self.core.reset_after_disconnect(); self.clear_region(); } - Effect::ListHosts => { - self.commit_prompt_line(); - self.list_hosts(); - } - Effect::ForgetHost(target) => { - self.commit_prompt_line(); - match database::forget_host(&self.conn, &target) { - Ok(outcome) if outcome.credentials_removed => println!( - "Cleared trust and stored credentials for {}.", - target - ), - Ok(outcome) => { - println!("Cleared trust for {}.", target); - if !outcome.siblings.is_empty() { - println!( - "Stored credentials kept: this server is still reachable as {}.", - outcome.siblings.join(", ") - ); - } - } - Err(error) => println!("Failed to forget host: {}", error), - } - } Effect::Quit => { let had_board = self.game_lines > 0; self.clear_region(); @@ -545,28 +533,112 @@ impl ClientState { } } - fn run_interactive(&mut self, command: InteractiveCommand) { - match command { - InteractiveCommand::Login => self.do_login(), - InteractiveCommand::Register => self.do_register(), - InteractiveCommand::SetCred => self.do_setcred(), - InteractiveCommand::Connect(target) => { - if self.stream.is_some() { - println!("Already connected. Disconnect first."); + /// Run one command line from the prompt. + fn submit_line(&mut self, line: &str) -> Vec { + match shell::parse(line) { + Shell::Run(command) => self.core.command(command), + Shell::Login => { + self.do_login(); + Vec::new() + } + Shell::Register => { + self.do_register(); + Vec::new() + } + Shell::SetCred => { + self.do_setcred(); + Vec::new() + } + Shell::Connect(target) => { + self.do_connect(target); + Vec::new() + } + Shell::Disconnect => { + self.stream = None; + self.core.reset_after_disconnect(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Disconnected".to_string()]), + ] + } + Shell::Hosts => { + self.list_hosts(); + Vec::new() + } + Shell::ForgetHost(target) => { + self.forget_host(target); + Vec::new() + } + Shell::ForgetLogin(target) => { + self.forget_login(target); + Vec::new() + } + Shell::Help => vec![Effect::Print(help_lines())], + Shell::Quit => vec![Effect::Quit], + Shell::Empty => Vec::new(), + Shell::Usage(message) => vec![Effect::Print(vec![message])], + } + } + + fn do_connect(&mut self, target: Option) { + if self.stream.is_some() { + println!("Already connected. Disconnect first."); + return; + } + let addr = match target { + Some(target) => self.normalize_addr(target), + None => match &self.addr { + Some(addr) => addr.clone(), + None => { + println!("No host specified. Usage: connect :"); return; } - let addr = match target { - Some(target) => self.normalize_addr(target), - None => match &self.addr { - Some(addr) => addr.clone(), - None => { - println!("No host specified. Usage: connect :"); - return; - } - }, - }; - self.connect_to(&addr); + }, + }; + self.connect_to(&addr); + } + + /// Drop the stored login for one server, keeping the pinned key. + fn forget_login(&mut self, target: Option) { + let Some(host) = target.or_else(|| self.addr.clone()) else { + println!("Usage: forget-login [ip:port] (or connect first)"); + return; + }; + let Some(identity) = database::get_fingerprint(&self.conn, &host) else { + println!("No trusted server is remembered as {}.", host); + return; + }; + match database::forget_credentials(&self.conn, &identity) { + Ok(true) => println!( + "Forgot the saved login for server {} ({}). Trust is unchanged.", + short_fingerprint(&identity), + host + ), + Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), + Err(error) => println!("Failed to forget the saved login: {}", error), + } + } + + fn forget_host(&mut self, target: Option) { + let Some(target) = target.or_else(|| self.addr.clone()) else { + println!("Usage: forget-host : (or connect first)"); + return; + }; + match database::forget_host(&self.conn, &target) { + Ok(outcome) if outcome.credentials_removed => println!( + "Cleared trust and stored credentials for {}.", + target + ), + Ok(outcome) => { + println!("Cleared trust for {}.", target); + if !outcome.siblings.is_empty() { + println!( + "Stored credentials kept: this server is still reachable as {}.", + outcome.siblings.join(", ") + ); + } } + Err(error) => println!("Failed to forget host: {}", error), } } 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 }, diff --git a/tests/client_core.rs b/tests/client_core.rs index 722cf0d..49abca2 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -6,8 +6,10 @@ use std::time::Duration; use zagan_bet::blackjack::BlackjackState; -use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; +use zagan_bet::client::core::{ClientCore, Effect}; use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; +use zagan_bet::client::shell::{self, Shell}; +use zagan_bet::client::ui::help_lines; use zagan_bet::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_spin_response, }; @@ -31,12 +33,7 @@ struct Screen { } impl Screen { - fn apply( - &mut self, - effects: &[Effect], - sent: &mut Vec, - interactive: &mut Vec, - ) { + fn apply(&mut self, effects: &[Effect], sent: &mut Vec) { for effect in effects { match effect { Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), @@ -57,19 +54,8 @@ impl Screen { self.region_start = None; self.region_len = 0; } - Effect::AnimateRoulette { view, .. } => { - let lines = zagan_bet::client::tui::render_view(view); - if let Some(start) = self.region_start { - self.lines.truncate(start); - } else { - self.region_start = Some(self.lines.len()); - } - self.lines.extend(lines.iter().cloned()); - self.region_len = lines.len(); - } Effect::SendLine(line) => sent.push(line.clone()), - Effect::Interactive(command) => interactive.push(command.clone()), - Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} + Effect::Quit | Effect::Disconnect => {} } } } @@ -87,7 +73,6 @@ struct Harness { editor: Editor, screen: Screen, sent: Vec, - interactive: Vec, } impl Harness { @@ -100,13 +85,11 @@ impl Harness { editor: Editor::new(), screen: Screen::default(), sent: Vec::new(), - interactive: Vec::new(), } } fn apply(&mut self, effects: Vec) { - self.screen - .apply(&effects, &mut self.sent, &mut self.interactive); + self.screen.apply(&effects, &mut self.sent); } fn key(&mut self, key: Key) { @@ -119,7 +102,7 @@ impl Harness { } else { match self.editor.handle(key) { Input::Quit => vec![Effect::Quit], - Input::Submit(line) => self.core.submit_line(&line), + Input::Submit(line) => self.dispatch(&line), Input::Print(lines) => vec![Effect::Print(lines)], Input::Prompt | Input::None => Vec::new(), } @@ -128,6 +111,26 @@ impl Harness { self.apply_prompt(); } + /// Route a submitted line the way the terminal adapter does. + fn dispatch(&mut self, line: &str) -> Vec { + match shell::parse(line) { + Shell::Run(command) => self.core.command(command), + Shell::Disconnect => { + self.core.reset_after_disconnect(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Disconnected".to_string()]), + ] + } + Shell::Help => vec![Effect::Print(help_lines())], + Shell::Quit => vec![Effect::Quit], + Shell::Empty => Vec::new(), + Shell::Usage(message) => vec![Effect::Print(vec![message])], + // Connect/login prompts are adapter flows, not core state. + _ => Vec::new(), + } + } + fn apply_prompt(&mut self) { if self.core.is_in_game() { return; diff --git a/tests/client_shell.rs b/tests/client_shell.rs new file mode 100644 index 0000000..d286b46 --- /dev/null +++ b/tests/client_shell.rs @@ -0,0 +1,152 @@ +//! The terminal command line is a thin parser over `Command`. +//! +//! Anything the core can do on its own becomes `Shell::Run`; connecting, +//! prompting for credentials and host maintenance stay with the adapter. + +use zagan_bet::client::shell::{Shell, parse}; +use zagan_bet::client::view::Command; +use zagan_bet::history::{DEFAULT_HISTORY, MAX_HISTORY}; + +fn run(line: &str) -> Command { + match parse(line) { + Shell::Run(command) => command, + other => panic!("expected a command, got {other:?}"), + } +} + +#[test] +fn blank_lines_do_nothing() { + assert_eq!(parse(""), Shell::Empty); + assert_eq!(parse(" \t "), Shell::Empty); +} + +#[test] +fn play_without_a_game_prints_usage() { + assert_eq!( + parse("play"), + Shell::Usage("Usage: play [bet]".to_string()) + ); +} + +#[test] +fn play_carries_the_game_and_an_optional_bet() { + assert_eq!( + run("play slots"), + Command::Play { + game: "slots".to_string(), + bet: None, + } + ); + assert_eq!( + run("play roulette 25"), + Command::Play { + game: "roulette".to_string(), + bet: Some(25), + } + ); +} + +#[test] +fn play_rejects_a_bet_below_the_table_minimum() { + assert_eq!( + parse("play slots 5"), + Shell::Usage("Bet must be at least 10 Kr".to_string()) + ); + assert_eq!( + parse("play slots ten"), + Shell::Usage("Invalid bet: ten".to_string()) + ); +} + +#[test] +fn history_defaults_to_the_recent_limit() { + let expected = Command::History { + limit: DEFAULT_HISTORY, + }; + assert_eq!(run("history"), expected); + assert_eq!(run("history 5"), Command::History { limit: 5 }); +} + +#[test] +fn history_rejects_counts_outside_the_range() { + let usage = Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")); + assert_eq!(parse("history 0"), usage); + assert_eq!(parse(&format!("history {}", MAX_HISTORY + 1)), usage); + assert_eq!(parse("history many"), usage); +} + +#[test] +fn simple_commands_are_pure_core_commands() { + assert_eq!(run("games"), Command::Games); + assert_eq!(run("logout"), Command::Logout); + assert_eq!(run("ping"), Command::Ping); + assert_eq!(run("info"), Command::Info); +} + +#[test] +fn send_keeps_the_whole_message_including_extra_spaces() { + assert_eq!( + run("send hello there friend"), + Command::Echo("hello there friend".to_string()) + ); + assert_eq!( + parse("send"), + Shell::Usage("Usage: send ".to_string()) + ); +} + +#[test] +fn connection_flows_stay_with_the_adapter() { + assert_eq!(parse("connect"), Shell::Connect(None)); + assert_eq!( + parse("connect 127.0.0.1:11525"), + Shell::Connect(Some("127.0.0.1:11525".to_string())) + ); + assert_eq!(parse("login"), Shell::Login); + assert_eq!(parse("register"), Shell::Register); + assert_eq!(parse("setcred"), Shell::SetCred); + assert_eq!(parse("disconnect"), Shell::Disconnect); +} + +#[test] +fn host_maintenance_is_adapter_owned() { + assert_eq!(parse("hosts"), Shell::Hosts); + assert_eq!(parse("forget-host"), Shell::ForgetHost(None)); + assert_eq!( + parse("forget-host 10.0.0.2:11525"), + Shell::ForgetHost(Some("10.0.0.2:11525".to_string())) + ); +} + +#[test] +fn forgetting_a_saved_login_is_separate_from_forgetting_trust() { + assert_eq!(parse("forget-login"), Shell::ForgetLogin(None)); + assert_eq!( + parse("forget-login 10.0.0.2:11525"), + Shell::ForgetLogin(Some("10.0.0.2:11525".to_string())) + ); +} + +#[test] +fn help_and_quit_are_terminal_only() { + assert_eq!(parse("help"), Shell::Help); + assert_eq!(parse("quit"), Shell::Quit); +} + +#[test] +fn an_unknown_word_falls_back_to_help() { + assert_eq!(parse("frobnicate"), Shell::Help); + assert_eq!(parse("quit now"), Shell::Quit); +} + +#[test] +fn surrounding_whitespace_is_ignored() { + assert_eq!(run(" ping "), Command::Ping); + assert_eq!( + run(" play slots 30 "), + Command::Play { + game: "slots".to_string(), + bet: Some(30), + } + ); +} diff --git a/tests/client_ui.rs b/tests/client_ui.rs index 6e00903..bd9ccbc 100644 --- a/tests/client_ui.rs +++ b/tests/client_ui.rs @@ -27,7 +27,8 @@ fn help_documents_every_command() { fn completion_knows_every_command() { let commands = command_names(); for expected in [ - "connect", "games", "play", "hosts", "send", "logout", "quit", + "connect", "games", "play", "hosts", "forget-host", "forget-login", "send", "logout", + "quit", ] { assert!( commands.contains(&expected), diff --git a/tests/storage.rs b/tests/storage.rs index e6ad2d1..21e76d4 100644 --- a/tests/storage.rs +++ b/tests/storage.rs @@ -88,6 +88,27 @@ fn forgetting_one_address_keeps_the_shared_credentials() { assert_eq!(client_database::get_credentials(&conn, "server-a"), None); } +#[test] +fn forgetting_the_login_keeps_the_trusted_key() { + let (_, conn) = tmp_db("creds-forget-login"); + client_database::init_db(&conn); + + client_database::save_host(&conn, "127.0.0.1:11525"); + client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap(); + client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap(); + + assert!(client_database::forget_credentials(&conn, "server-a").unwrap()); + assert_eq!(client_database::get_credentials(&conn, "server-a"), None); + assert_eq!( + client_database::get_fingerprint(&conn, "127.0.0.1:11525"), + Some("server-a".to_string()), + "forgetting the login must not unpin the key" + ); + + // A second call is a no-op, not an error. + assert!(!client_database::forget_credentials(&conn, "server-a").unwrap()); +} + #[test] fn client_last_host_tracking() { let (_, conn) = tmp_db("hosts"); -- cgit v1.2.3