diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 19:19:49 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 19:19:49 +0100 |
| commit | 24e3bb54db579fb9aad8df2a591527dc77fe4cb4 (patch) | |
| tree | af9a0c290f2675068e8396823f641f24bbe4d308 | |
| parent | v4.0.6: move the prompt editor and key mapping out of the core (diff) | |
| download | zagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.tar.gz zagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.zip | |
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).
| -rw-r--r-- | AGENTS.md | 3 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 38 | ||||
| -rw-r--r-- | docs/adr/0001-testable-client-server-cores.md | 10 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 190 | ||||
| -rw-r--r-- | src/client/core.rs | 251 | ||||
| -rw-r--r-- | src/client/database.rs | 15 | ||||
| -rw-r--r-- | src/client/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/shell.rs | 110 | ||||
| -rw-r--r-- | src/client/ui.rs | 14 | ||||
| -rw-r--r-- | src/client/view.rs | 4 | ||||
| -rw-r--r-- | tests/client_core.rs | 51 | ||||
| -rw-r--r-- | tests/client_shell.rs | 152 | ||||
| -rw-r--r-- | tests/client_ui.rs | 3 | ||||
| -rw-r--r-- | tests/storage.rs | 21 |
15 files changed, 566 insertions, 299 deletions
| @@ -103,7 +103,8 @@ | |||
| 103 | by host. | 103 | by host. |
| 104 | - `forget_host` removes one address. Drop the credential only when no address | 104 | - `forget_host` removes one address. Drop the credential only when no address |
| 105 | with that fingerprint remains, or two addresses for one server would stop | 105 | with that fingerprint remains, or two addresses for one server would stop |
| 106 | sharing a login. | 106 | sharing a login. `forget-login` is the narrower action: it drops the |
| 107 | credential and keeps the pinned key. | ||
| 107 | - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... | 108 | - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... |
| 108 | DO UPDATE SET last_used` — never `INSERT OR REPLACE`. | 109 | DO UPDATE SET last_used` — never `INSERT OR REPLACE`. |
| 109 | - The client transport lives in `client::transport`. It records the key during | 110 | - The client transport lives in `client::transport`. It records the key during |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.6" | 3 | version = "4.0.7" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
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. | |||
| 24 | **Gate:** mismatch refused in both directions, `HELLO` required before auth, | 24 | **Gate:** mismatch refused in both directions, `HELLO` required before auth, |
| 25 | existing game flows unchanged, new tests for version parsing and gating. | 25 | existing game flows unchanged, new tests for version parsing and gating. |
| 26 | 26 | ||
| 27 | ## Phase 1 — core boundary and roulette | 27 | ## Phase 1 — core boundary and roulette (done) |
| 28 | 28 | ||
| 29 | **In progress.** `Command`/`View` exist and all four games are ported: the | 29 | Complete in v4.0.7. Every game plus `Lobby` and `History` are structured views; |
| 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. | 30 | the core builds the view and frontends send `Command`. The client transport |
| 31 | Done so far: every game plus `Lobby` and `History` are structured views; the | 31 | (TCP, rustls, TOFU, `HELLO`, auto-login) lives in `client::transport` behind a |
| 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in | 32 | trust callback. The command line moved out of the core in two pieces: |
| 33 | `client::transport` behind a trust callback; login and registration are | 33 | `client::input` owns the editor, history and key mapping, and `client::shell` |
| 34 | `Command`s, so a GUI form never needs a terminal prompt. The command-line | 34 | parses a finished line into a core `Command` or an adapter action (connect, |
| 35 | editor and key mapping moved to `client::input`; `ClientCore` no longer knows | 35 | login, register, set-credential, host maintenance). `ClientCore` no longer |
| 36 | keys, the prompt or its history. Remaining: the `Connect` and `Trust` adapter | 36 | holds a `Key`, a prompt buffer, or any of the old TUI-shaped effects |
| 37 | screens, and replacing the last TUI-shaped effects (`Interactive`, host | 37 | (`Interactive`, `SetPrompt`, `ListHosts`, `ForgetHost`, `AnimateRoulette`); the |
| 38 | listing, roulette animation) with commands. | 38 | roulette wheel animation is derived from the `Result` view by the adapter. |
| 39 | "Log out" (`logout`) and "forget saved login" (`forget-login`) are distinct: the | ||
| 40 | latter drops the credential for the pinned key and keeps trust. | ||
| 41 | |||
| 42 | The `Connect` and `Trust` screens are adapter-owned for now: the terminal asks | ||
| 43 | in the transcript, and the GUI will draw them as its own screens over the same | ||
| 44 | `client::transport` callback. They are not core state because the core never | ||
| 45 | sees a certificate. | ||
| 39 | 46 | ||
| 40 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 47 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 41 | wheel animation, interactive prompts) into the TUI adapter. | 48 | wheel animation, interactive prompts) into the TUI adapter. |
| @@ -43,13 +50,14 @@ listing, roulette animation) with commands. | |||
| 43 | `History`, `Game`. | 50 | `History`, `Game`. |
| 44 | - Port roulette first: `PlaceBet(BetTarget)`, `SetChip`, `ClearBets`, `Spin`, | 51 | - Port roulette first: `PlaceBet(BetTarget)`, `SetChip`, `ClearBets`, `Spin`, |
| 45 | and a `FeltView` carrying chips, cursor, and the last result. | 52 | and a `FeltView` carrying chips, cursor, and the last result. |
| 46 | - Add `ForgetLogin` so "log out" and "forget saved login" are distinct in both | 53 | - Add `forget-login` so "log out" and "forget saved login" are distinct in both |
| 47 | frontends. | 54 | frontends. |
| 48 | - Auto-login stays: a stored credential for the pinned fingerprint logs in | 55 | - Auto-login stays: a stored credential for the pinned fingerprint logs in |
| 49 | automatically after the trust check. | 56 | automatically after the trust check. |
| 50 | 57 | ||
| 51 | **Gate:** the TUI behaves exactly as today, its tests now assert on `View` | 58 | **Gate:** met. The TUI behaves as before (same boards, keys and prompts), its |
| 52 | instead of text where practical, and `ClientCore` contains no terminal types. | 59 | tests drive the real adapter path (`shell::parse`, `Editor`, `game_input`), and |
| 60 | `ClientCore` contains no terminal types. | ||
| 53 | 61 | ||
| 54 | ## Phase 2 — the other three games | 62 | ## Phase 2 — the other three games |
| 55 | 63 | ||
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/`. | |||
| 16 | - The library (`src/lib.rs`) owns shared logic. `src/bin/client` and | 16 | - The library (`src/lib.rs`) owns shared logic. `src/bin/client` and |
| 17 | `src/bin/server` are thin adapters for crossterm, TLS, environment variables, | 17 | `src/bin/server` are thin adapters for crossterm, TLS, environment variables, |
| 18 | and interactive prompts. | 18 | and interactive prompts. |
| 19 | - `client::core::ClientCore` owns input, history, commands, game state, and | 19 | - `client::core::ClientCore` owns commands, game state, the current screen, and |
| 20 | pending replies. It returns `Effect`s (`Print`, `ReplaceRegion`, `ClearRegion`, | 20 | pending replies. It returns `Effect`s (`Print`, `Render(View)`, `ClearRegion`, |
| 21 | `SetPrompt`, `SendLine`, `Interactive`, ...). The binary applies them; the | 21 | `SendLine`, `Disconnect`, `Quit`). The binary applies them; the tests apply |
| 22 | tests apply them to a headless screen. | 22 | them to a headless screen. The prompt buffer, history and key mapping live in |
| 23 | `client::input`, and `client::shell` parses a finished line, so the core never | ||
| 24 | sees a `Key` or a raw command line. | ||
| 23 | - `server::core::ServerCore` owns message handling and receives an injected | 25 | - `server::core::ServerCore` owns message handling and receives an injected |
| 24 | `Connection`, `GameRegistry`, and bcrypt cost. The binary only owns the TLS | 26 | `Connection`, `GameRegistry`, and bcrypt cost. The binary only owns the TLS |
| 25 | listener and per-connection thread. | 27 | 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::{ | |||
| 13 | 13 | ||
| 14 | use sha2::{Digest, Sha256}; | 14 | use sha2::{Digest, Sha256}; |
| 15 | 15 | ||
| 16 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; | 16 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 17 | use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; | 17 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; |
| 18 | use zagan_bet::client::shell::{self, Shell}; | ||
| 19 | use zagan_bet::client::view::{RoulettePhase, View}; | ||
| 18 | use zagan_bet::client::database; | 20 | use zagan_bet::client::database; |
| 19 | use zagan_bet::client::ui::{ | 21 | use zagan_bet::client::ui::{ |
| 20 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, | 22 | display_width, format_pong, help_lines, host_lines, input_cursor_column, |
| 21 | terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, | 23 | lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, |
| 22 | }; | 24 | }; |
| 23 | use zagan_bet::protocol::io_failure_message; | 25 | use zagan_bet::protocol::io_failure_message; |
| 24 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; | 26 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; |
| @@ -42,6 +44,8 @@ pub struct ClientState { | |||
| 42 | terminal_size: (u16, u16), | 44 | terminal_size: (u16, u16), |
| 43 | /// Size we already warned about, so a resize does not spam the transcript. | 45 | /// Size we already warned about, so a resize does not spam the transcript. |
| 44 | size_warned: Option<(u16, u16)>, | 46 | size_warned: Option<(u16, u16)>, |
| 47 | /// Last roulette result the wheel animation ran for. | ||
| 48 | last_roulette: Option<u8>, | ||
| 45 | } | 49 | } |
| 46 | 50 | ||
| 47 | impl ClientState { | 51 | impl ClientState { |
| @@ -67,6 +71,7 @@ impl ClientState { | |||
| 67 | prompt_text: String::new(), | 71 | prompt_text: String::new(), |
| 68 | terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), | 72 | terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), |
| 69 | size_warned: None, | 73 | size_warned: None, |
| 74 | last_roulette: None, | ||
| 70 | }) | 75 | }) |
| 71 | } | 76 | } |
| 72 | 77 | ||
| @@ -129,7 +134,7 @@ impl ClientState { | |||
| 129 | } else { | 134 | } else { |
| 130 | match self.editor.handle(key) { | 135 | match self.editor.handle(key) { |
| 131 | Input::Quit => vec![Effect::Quit], | 136 | Input::Quit => vec![Effect::Quit], |
| 132 | Input::Submit(line) => self.core.submit_line(&line), | 137 | Input::Submit(line) => self.submit_line(&line), |
| 133 | Input::Print(lines) => vec![Effect::Print(lines)], | 138 | Input::Print(lines) => vec![Effect::Print(lines)], |
| 134 | Input::Prompt | Input::None => Vec::new(), | 139 | Input::Prompt | Input::None => Vec::new(), |
| 135 | } | 140 | } |
| @@ -233,17 +238,26 @@ impl ClientState { | |||
| 233 | } | 238 | } |
| 234 | } | 239 | } |
| 235 | Effect::Render(view) => { | 240 | Effect::Render(view) => { |
| 241 | if let View::Roulette(roulette) = &view { | ||
| 242 | if roulette.phase == RoulettePhase::Result { | ||
| 243 | if let Some(number) = roulette.number { | ||
| 244 | if self.last_roulette != Some(number) { | ||
| 245 | self.animate_wheel(number); | ||
| 246 | self.last_roulette = Some(number); | ||
| 247 | while crossterm::event::poll(std::time::Duration::ZERO) | ||
| 248 | .unwrap_or(false) | ||
| 249 | { | ||
| 250 | let _ = crossterm::event::read(); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } else { | ||
| 255 | self.last_roulette = None; | ||
| 256 | } | ||
| 257 | } | ||
| 236 | let lines = zagan_bet::client::tui::render_view(&view); | 258 | let lines = zagan_bet::client::tui::render_view(&view); |
| 237 | self.draw_region(&lines); | 259 | self.draw_region(&lines); |
| 238 | } | 260 | } |
| 239 | Effect::AnimateRoulette { winning, view } => { | ||
| 240 | self.animate_wheel(winning); | ||
| 241 | let lines = zagan_bet::client::tui::render_view(&view); | ||
| 242 | self.draw_region(&lines); | ||
| 243 | while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { | ||
| 244 | let _ = crossterm::event::read(); | ||
| 245 | } | ||
| 246 | } | ||
| 247 | Effect::ClearRegion => self.clear_region(), | 261 | Effect::ClearRegion => self.clear_region(), |
| 248 | Effect::SendLine(line) => { | 262 | Effect::SendLine(line) => { |
| 249 | let is_ping = line == "PING"; | 263 | let is_ping = line == "PING"; |
| @@ -269,37 +283,11 @@ impl ClientState { | |||
| 269 | return true; | 283 | return true; |
| 270 | } | 284 | } |
| 271 | } | 285 | } |
| 272 | Effect::Interactive(command) => { | ||
| 273 | self.commit_prompt_line(); | ||
| 274 | self.run_interactive(command); | ||
| 275 | } | ||
| 276 | Effect::Disconnect => { | 286 | Effect::Disconnect => { |
| 277 | self.stream = None; | 287 | self.stream = None; |
| 288 | self.core.reset_after_disconnect(); | ||
| 278 | self.clear_region(); | 289 | self.clear_region(); |
| 279 | } | 290 | } |
| 280 | Effect::ListHosts => { | ||
| 281 | self.commit_prompt_line(); | ||
| 282 | self.list_hosts(); | ||
| 283 | } | ||
| 284 | Effect::ForgetHost(target) => { | ||
| 285 | self.commit_prompt_line(); | ||
| 286 | match database::forget_host(&self.conn, &target) { | ||
| 287 | Ok(outcome) if outcome.credentials_removed => println!( | ||
| 288 | "Cleared trust and stored credentials for {}.", | ||
| 289 | target | ||
| 290 | ), | ||
| 291 | Ok(outcome) => { | ||
| 292 | println!("Cleared trust for {}.", target); | ||
| 293 | if !outcome.siblings.is_empty() { | ||
| 294 | println!( | ||
| 295 | "Stored credentials kept: this server is still reachable as {}.", | ||
| 296 | outcome.siblings.join(", ") | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | } | ||
| 300 | Err(error) => println!("Failed to forget host: {}", error), | ||
| 301 | } | ||
| 302 | } | ||
| 303 | Effect::Quit => { | 291 | Effect::Quit => { |
| 304 | let had_board = self.game_lines > 0; | 292 | let had_board = self.game_lines > 0; |
| 305 | self.clear_region(); | 293 | self.clear_region(); |
| @@ -545,28 +533,112 @@ impl ClientState { | |||
| 545 | } | 533 | } |
| 546 | } | 534 | } |
| 547 | 535 | ||
| 548 | fn run_interactive(&mut self, command: InteractiveCommand) { | 536 | /// Run one command line from the prompt. |
| 549 | match command { | 537 | fn submit_line(&mut self, line: &str) -> Vec<Effect> { |
| 550 | InteractiveCommand::Login => self.do_login(), | 538 | match shell::parse(line) { |
| 551 | InteractiveCommand::Register => self.do_register(), | 539 | Shell::Run(command) => self.core.command(command), |
| 552 | InteractiveCommand::SetCred => self.do_setcred(), | 540 | Shell::Login => { |
| 553 | InteractiveCommand::Connect(target) => { | 541 | self.do_login(); |
| 554 | if self.stream.is_some() { | 542 | Vec::new() |
| 555 | println!("Already connected. Disconnect first."); | 543 | } |
| 544 | Shell::Register => { | ||
| 545 | self.do_register(); | ||
| 546 | Vec::new() | ||
| 547 | } | ||
| 548 | Shell::SetCred => { | ||
| 549 | self.do_setcred(); | ||
| 550 | Vec::new() | ||
| 551 | } | ||
| 552 | Shell::Connect(target) => { | ||
| 553 | self.do_connect(target); | ||
| 554 | Vec::new() | ||
| 555 | } | ||
| 556 | Shell::Disconnect => { | ||
| 557 | self.stream = None; | ||
| 558 | self.core.reset_after_disconnect(); | ||
| 559 | vec![ | ||
| 560 | Effect::ClearRegion, | ||
| 561 | Effect::Print(vec!["Disconnected".to_string()]), | ||
| 562 | ] | ||
| 563 | } | ||
| 564 | Shell::Hosts => { | ||
| 565 | self.list_hosts(); | ||
| 566 | Vec::new() | ||
| 567 | } | ||
| 568 | Shell::ForgetHost(target) => { | ||
| 569 | self.forget_host(target); | ||
| 570 | Vec::new() | ||
| 571 | } | ||
| 572 | Shell::ForgetLogin(target) => { | ||
| 573 | self.forget_login(target); | ||
| 574 | Vec::new() | ||
| 575 | } | ||
| 576 | Shell::Help => vec![Effect::Print(help_lines())], | ||
| 577 | Shell::Quit => vec![Effect::Quit], | ||
| 578 | Shell::Empty => Vec::new(), | ||
| 579 | Shell::Usage(message) => vec![Effect::Print(vec![message])], | ||
| 580 | } | ||
| 581 | } | ||
| 582 | |||
| 583 | fn do_connect(&mut self, target: Option<String>) { | ||
| 584 | if self.stream.is_some() { | ||
| 585 | println!("Already connected. Disconnect first."); | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | let addr = match target { | ||
| 589 | Some(target) => self.normalize_addr(target), | ||
| 590 | None => match &self.addr { | ||
| 591 | Some(addr) => addr.clone(), | ||
| 592 | None => { | ||
| 593 | println!("No host specified. Usage: connect <ip>:<port>"); | ||
| 556 | return; | 594 | return; |
| 557 | } | 595 | } |
| 558 | let addr = match target { | 596 | }, |
| 559 | Some(target) => self.normalize_addr(target), | 597 | }; |
| 560 | None => match &self.addr { | 598 | self.connect_to(&addr); |
| 561 | Some(addr) => addr.clone(), | 599 | } |
| 562 | None => { | 600 | |
| 563 | println!("No host specified. Usage: connect <ip>:<port>"); | 601 | /// Drop the stored login for one server, keeping the pinned key. |
| 564 | return; | 602 | fn forget_login(&mut self, target: Option<String>) { |
| 565 | } | 603 | let Some(host) = target.or_else(|| self.addr.clone()) else { |
| 566 | }, | 604 | println!("Usage: forget-login [ip:port] (or connect first)"); |
| 567 | }; | 605 | return; |
| 568 | self.connect_to(&addr); | 606 | }; |
| 607 | let Some(identity) = database::get_fingerprint(&self.conn, &host) else { | ||
| 608 | println!("No trusted server is remembered as {}.", host); | ||
| 609 | return; | ||
| 610 | }; | ||
| 611 | match database::forget_credentials(&self.conn, &identity) { | ||
| 612 | Ok(true) => println!( | ||
| 613 | "Forgot the saved login for server {} ({}). Trust is unchanged.", | ||
| 614 | short_fingerprint(&identity), | ||
| 615 | host | ||
| 616 | ), | ||
| 617 | Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), | ||
| 618 | Err(error) => println!("Failed to forget the saved login: {}", error), | ||
| 619 | } | ||
| 620 | } | ||
| 621 | |||
| 622 | fn forget_host(&mut self, target: Option<String>) { | ||
| 623 | let Some(target) = target.or_else(|| self.addr.clone()) else { | ||
| 624 | println!("Usage: forget-host <ip>:<port> (or connect first)"); | ||
| 625 | return; | ||
| 626 | }; | ||
| 627 | match database::forget_host(&self.conn, &target) { | ||
| 628 | Ok(outcome) if outcome.credentials_removed => println!( | ||
| 629 | "Cleared trust and stored credentials for {}.", | ||
| 630 | target | ||
| 631 | ), | ||
| 632 | Ok(outcome) => { | ||
| 633 | println!("Cleared trust for {}.", target); | ||
| 634 | if !outcome.siblings.is_empty() { | ||
| 635 | println!( | ||
| 636 | "Stored credentials kept: this server is still reachable as {}.", | ||
| 637 | outcome.siblings.join(", ") | ||
| 638 | ); | ||
| 639 | } | ||
| 569 | } | 640 | } |
| 641 | Err(error) => println!("Failed to forget host: {}", error), | ||
| 570 | } | 642 | } |
| 571 | } | 643 | } |
| 572 | 644 | ||
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::{ | |||
| 6 | use crate::client::view::{ | 6 | use crate::client::view::{ |
| 7 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, | 7 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, |
| 8 | }; | 8 | }; |
| 9 | use crate::client::ui::{help_lines, history_lines, parse_bet_arg}; | 9 | use crate::client::ui::{game_lines, history_lines}; |
| 10 | use crate::crash::{CrashState, multiplier_at}; | 10 | use crate::crash::{CrashState, multiplier_at}; |
| 11 | use crate::history::{PlayRecord, MAX_HISTORY}; | 11 | use crate::history::{PlayRecord, MAX_HISTORY}; |
| 12 | use crate::roulette::{BetTarget, Bets}; | 12 | use crate::roulette::{BetTarget, Bets}; |
| 13 | use std::time::{Duration, Instant}; | 13 | use std::time::{Duration, Instant}; |
| 14 | 14 | ||
| 15 | 15 | ||
| 16 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 17 | pub enum InteractiveCommand { | ||
| 18 | Login, | ||
| 19 | Register, | ||
| 20 | SetCred, | ||
| 21 | Connect(Option<String>), | ||
| 22 | } | ||
| 23 | 16 | ||
| 24 | /// High-level actions produced by the client core. The terminal adapter is | 17 | /// High-level actions produced by the client core. The terminal adapter is |
| 25 | /// responsible for turning these into crossterm calls and network writes. | 18 | /// responsible for turning these into crossterm calls and network writes. |
| @@ -29,20 +22,12 @@ pub enum Effect { | |||
| 29 | Print(Vec<String>), | 22 | Print(Vec<String>), |
| 30 | /// Draw the current view in the board region. | 23 | /// Draw the current view in the board region. |
| 31 | Render(View), | 24 | Render(View), |
| 32 | /// Run the roulette wheel animation, then draw the final view. | ||
| 33 | AnimateRoulette { winning: u8, view: View }, | ||
| 34 | /// Remove the game board region. | 25 | /// Remove the game board region. |
| 35 | ClearRegion, | 26 | ClearRegion, |
| 36 | /// Send one protocol line and feed the reply to `handle_reply`. | 27 | /// Send one protocol line and feed the reply to `handle_reply`. |
| 37 | SendLine(String), | 28 | SendLine(String), |
| 38 | /// Delegate an interactive prompt flow to the terminal adapter. | ||
| 39 | Interactive(InteractiveCommand), | ||
| 40 | /// Drop the connection. | 29 | /// Drop the connection. |
| 41 | Disconnect, | 30 | Disconnect, |
| 42 | /// Let the adapter read the host database. | ||
| 43 | ListHosts, | ||
| 44 | /// Let the adapter delete a host from the database. | ||
| 45 | ForgetHost(String), | ||
| 46 | /// Leave the program. | 31 | /// Leave the program. |
| 47 | Quit, | 32 | Quit, |
| 48 | } | 33 | } |
| @@ -175,12 +160,8 @@ impl ClientCore { | |||
| 175 | Some(Pending::RouletteSpin) => match parse_roulette_response(reply) { | 160 | Some(Pending::RouletteSpin) => match parse_roulette_response(reply) { |
| 176 | Ok(result) => { | 161 | Ok(result) => { |
| 177 | self.balance = Some(result.balance); | 162 | self.balance = Some(result.balance); |
| 178 | let winning = result.number; | ||
| 179 | self.game.apply_roulette_result(result); | 163 | self.game.apply_roulette_result(result); |
| 180 | let Some(view) = self.view() else { | 164 | self.board_effects() |
| 181 | return Vec::new(); | ||
| 182 | }; | ||
| 183 | vec![Effect::AnimateRoulette { winning, view }] | ||
| 184 | } | 165 | } |
| 185 | Err(message) => { | 166 | Err(message) => { |
| 186 | self.game.set_error(message); | 167 | self.game.set_error(message); |
| @@ -331,22 +312,41 @@ impl ClientCore { | |||
| 331 | 312 | ||
| 332 | match command { | 313 | match command { |
| 333 | Command::Login { user, credential } => { | 314 | Command::Login { user, credential } => { |
| 334 | if !self.connected { | 315 | if !self.connected { |
| 335 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 316 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 336 | } | 317 | } |
| 337 | self.login_error = None; | 318 | self.login_error = None; |
| 338 | self.pending = Some(Pending::Login { user: user.clone() }); | 319 | self.pending = Some(Pending::Login { user: user.clone() }); |
| 339 | vec![Effect::SendLine(format!("AUTH {user} {credential}"))] | 320 | vec![Effect::SendLine(format!("AUTH {user} {credential}"))] |
| 340 | } | 321 | } |
| 341 | Command::Register { user, credential } => { | 322 | Command::Register { user, credential } => { |
| 342 | if !self.connected { | 323 | if !self.connected { |
| 343 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 324 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 344 | } | 325 | } |
| 345 | self.login_error = None; | 326 | self.login_error = None; |
| 346 | self.pending = Some(Pending::Register); | 327 | self.pending = Some(Pending::Register); |
| 347 | vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] | 328 | vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] |
| 348 | } | 329 | } |
| 349 | Command::Play { game, bet } => self.play_game(&game, bet), | 330 | Command::Games => vec![Effect::Print(game_lines(&self.games()))], |
| 331 | Command::Ping => { | ||
| 332 | if !self.connected { | ||
| 333 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 334 | } | ||
| 335 | self.pending = Some(Pending::Ping); | ||
| 336 | vec![Effect::SendLine("PING".to_string())] | ||
| 337 | } | ||
| 338 | Command::Echo(message) => { | ||
| 339 | if !self.connected { | ||
| 340 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 341 | } | ||
| 342 | if message.trim().is_empty() { | ||
| 343 | return print_line("Usage: send <message>"); | ||
| 344 | } | ||
| 345 | self.pending = Some(Pending::Echo); | ||
| 346 | vec![Effect::SendLine(message)] | ||
| 347 | } | ||
| 348 | Command::Info => self.info_effects(), | ||
| 349 | Command::Play { game, bet } => self.play_game(&game, bet), | ||
| 350 | Command::History { limit } => self.load_history(limit), | 350 | Command::History { limit } => self.load_history(limit), |
| 351 | Command::ShowLobby => { | 351 | Command::ShowLobby => { |
| 352 | self.screen = Screen::Lobby; | 352 | self.screen = Screen::Lobby; |
| @@ -672,106 +672,6 @@ impl ClientCore { | |||
| 672 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] | 672 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] |
| 673 | } | 673 | } |
| 674 | 674 | ||
| 675 | /// Run one command line. The interactive frontend owns the buffer and the | ||
| 676 | /// history; the core only sees the finished line. | ||
| 677 | pub fn submit_line(&mut self, line: &str) -> Vec<Effect> { | ||
| 678 | let line = line.trim().to_string(); | ||
| 679 | if line.is_empty() { | ||
| 680 | return Vec::new(); | ||
| 681 | } | ||
| 682 | |||
| 683 | let parts: Vec<&str> = line.splitn(3, ' ').collect(); | ||
| 684 | match parts[0] { | ||
| 685 | "play" => self.cmd_play(&parts), | ||
| 686 | "games" => self.available_games(None), | ||
| 687 | "help" => vec![Effect::Print(help_lines())], | ||
| 688 | "connect" => { | ||
| 689 | if self.connected { | ||
| 690 | print_line("Already connected. Disconnect first.") | ||
| 691 | } else { | ||
| 692 | let target = parts.get(1).map(|value| value.to_string()); | ||
| 693 | vec![Effect::Interactive(InteractiveCommand::Connect(target))] | ||
| 694 | } | ||
| 695 | } | ||
| 696 | "login" => { | ||
| 697 | if !self.connected { | ||
| 698 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 699 | } else if self.logged_in.is_some() { | ||
| 700 | print_line(format!( | ||
| 701 | "Already logged in as {}", | ||
| 702 | self.logged_in.as_deref().unwrap_or("?") | ||
| 703 | )) | ||
| 704 | } else { | ||
| 705 | vec![Effect::Interactive(InteractiveCommand::Login)] | ||
| 706 | } | ||
| 707 | } | ||
| 708 | "register" => { | ||
| 709 | if !self.connected { | ||
| 710 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 711 | } else { | ||
| 712 | vec![Effect::Interactive(InteractiveCommand::Register)] | ||
| 713 | } | ||
| 714 | } | ||
| 715 | "setcred" => { | ||
| 716 | if self.host.is_none() { | ||
| 717 | print_line("No host connected. Use 'connect <ip>:<port>' first.") | ||
| 718 | } else { | ||
| 719 | vec![Effect::Interactive(InteractiveCommand::SetCred)] | ||
| 720 | } | ||
| 721 | } | ||
| 722 | "logout" => { | ||
| 723 | self.logged_in = None; | ||
| 724 | print_line("Logged out") | ||
| 725 | } | ||
| 726 | "disconnect" => { | ||
| 727 | self.connected = false; | ||
| 728 | self.logged_in = None; | ||
| 729 | self.pending = None; | ||
| 730 | self.crash_started = None; | ||
| 731 | self.game.end_game(); | ||
| 732 | vec![ | ||
| 733 | Effect::ClearRegion, | ||
| 734 | Effect::Disconnect, | ||
| 735 | Effect::Print(vec!["Disconnected".to_string()]), | ||
| 736 | ] | ||
| 737 | } | ||
| 738 | "ping" => { | ||
| 739 | if !self.connected { | ||
| 740 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 741 | } else { | ||
| 742 | self.pending = Some(Pending::Ping); | ||
| 743 | vec![Effect::SendLine("PING".to_string())] | ||
| 744 | } | ||
| 745 | } | ||
| 746 | "send" => { | ||
| 747 | if !self.connected { | ||
| 748 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 749 | } else if parts.len() < 2 { | ||
| 750 | print_line("Usage: send <message>") | ||
| 751 | } else { | ||
| 752 | let message = parts[1..].join(" "); | ||
| 753 | self.pending = Some(Pending::Echo); | ||
| 754 | vec![Effect::SendLine(message)] | ||
| 755 | } | ||
| 756 | } | ||
| 757 | "info" => self.cmd_info(), | ||
| 758 | "history" => self.cmd_history(&parts), | ||
| 759 | "hosts" => vec![Effect::ListHosts], | ||
| 760 | "forget-host" => { | ||
| 761 | let target = parts | ||
| 762 | .get(1) | ||
| 763 | .map(|value| value.to_string()) | ||
| 764 | .or_else(|| self.host.clone()); | ||
| 765 | match target { | ||
| 766 | Some(target) => vec![Effect::ForgetHost(target)], | ||
| 767 | None => print_line("Usage: forget-host <ip>:<port> (or connect first)"), | ||
| 768 | } | ||
| 769 | } | ||
| 770 | "quit" => vec![Effect::Quit], | ||
| 771 | _ => vec![Effect::Print(help_lines())], | ||
| 772 | } | ||
| 773 | } | ||
| 774 | |||
| 775 | fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { | 675 | fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { |
| 776 | if !self.connected { | 676 | if !self.connected { |
| 777 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 677 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| @@ -822,56 +722,7 @@ impl ClientCore { | |||
| 822 | vec![Effect::SendLine(request)] | 722 | vec![Effect::SendLine(request)] |
| 823 | } | 723 | } |
| 824 | 724 | ||
| 825 | fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { | 725 | fn info_effects(&mut self) -> Vec<Effect> { |
| 826 | if !self.connected { | ||
| 827 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 828 | } | ||
| 829 | if self.logged_in.is_none() { | ||
| 830 | return print_line("Not logged in. Use 'login' first."); | ||
| 831 | } | ||
| 832 | if parts.len() < 2 { | ||
| 833 | return self.available_games(Some("Usage: play <game> [bet]")); | ||
| 834 | } | ||
| 835 | |||
| 836 | let bet = match parts.get(2) { | ||
| 837 | Some(raw) => match parse_bet_arg(raw) { | ||
| 838 | Ok(bet) => Some(bet), | ||
| 839 | Err(message) => return print_line(message), | ||
| 840 | }, | ||
| 841 | None => None, | ||
| 842 | }; | ||
| 843 | |||
| 844 | self.command(Command::Play { | ||
| 845 | game: parts[1].to_string(), | ||
| 846 | bet, | ||
| 847 | }) | ||
| 848 | } | ||
| 849 | |||
| 850 | fn available_games(&self, usage: Option<&str>) -> Vec<Effect> { | ||
| 851 | let mut lines = vec!["Available games:".to_string()]; | ||
| 852 | for (game_type, name) in self.game.get_available_games() { | ||
| 853 | lines.push(format!(" - {} ({})", game_type, name)); | ||
| 854 | } | ||
| 855 | if let Some(usage) = usage { | ||
| 856 | lines.push(String::new()); | ||
| 857 | lines.push(usage.to_string()); | ||
| 858 | } | ||
| 859 | vec![Effect::Print(lines)] | ||
| 860 | } | ||
| 861 | |||
| 862 | fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { | ||
| 863 | let limit = match parts.get(1) { | ||
| 864 | Some(raw) => match raw.parse::<u32>() { | ||
| 865 | Ok(count) => count, | ||
| 866 | Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), | ||
| 867 | }, | ||
| 868 | None => crate::history::DEFAULT_HISTORY, | ||
| 869 | }; | ||
| 870 | |||
| 871 | self.command(Command::History { limit }) | ||
| 872 | } | ||
| 873 | |||
| 874 | fn cmd_info(&mut self) -> Vec<Effect> { | ||
| 875 | if !self.connected { | 726 | if !self.connected { |
| 876 | return print_line("Not connected to any host."); | 727 | return print_line("Not connected to any host."); |
| 877 | } | 728 | } |
| @@ -895,7 +746,24 @@ impl ClientCore { | |||
| 895 | effects | 746 | effects |
| 896 | } | 747 | } |
| 897 | 748 | ||
| 898 | /// Snapshot of the current screen for a frontend to draw. | 749 | /// The games a frontend can offer. |
| 750 | pub fn games(&self) -> Vec<(String, String)> { | ||
| 751 | self.game | ||
| 752 | .get_available_games() | ||
| 753 | .into_iter() | ||
| 754 | .map(|(game, name)| (game.to_string(), name.to_string())) | ||
| 755 | .collect() | ||
| 756 | } | ||
| 757 | |||
| 758 | /// Reset connection-dependent state after the adapter closed the socket. | ||
| 759 | /// | ||
| 760 | /// The transport belongs to the frontend, so it tells the core when the | ||
| 761 | /// link is gone. What to say about it is the frontend's business. | ||
| 762 | pub fn reset_after_disconnect(&mut self) { | ||
| 763 | self.set_connected(false); | ||
| 764 | self.game.end_game(); | ||
| 765 | } | ||
| 766 | |||
| 899 | pub fn view(&self) -> Option<View> { | 767 | pub fn view(&self) -> Option<View> { |
| 900 | if let Some(state) = self.game.get_current_state() { | 768 | if let Some(state) = self.game.get_current_state() { |
| 901 | return Some(match state.game_type.as_str() { | 769 | return Some(match state.game_type.as_str() { |
| @@ -915,12 +783,7 @@ impl ClientCore { | |||
| 915 | connected: self.connected, | 783 | connected: self.connected, |
| 916 | user: self.logged_in.clone(), | 784 | user: self.logged_in.clone(), |
| 917 | balance: self.balance, | 785 | balance: self.balance, |
| 918 | games: self | 786 | games: self.games(), |
| 919 | .game | ||
| 920 | .get_available_games() | ||
| 921 | .into_iter() | ||
| 922 | .map(|(game, name)| (game.to_string(), name.to_string())) | ||
| 923 | .collect(), | ||
| 924 | login_error: self.login_error.clone(), | 787 | login_error: self.login_error.clone(), |
| 925 | }), | 788 | }), |
| 926 | }) | 789 | }) |
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<ForgetOutcome, rusql | |||
| 151 | }) | 151 | }) |
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | /// Forget the saved login for a server identity ("forget saved login"). | ||
| 155 | /// | ||
| 156 | /// Trust is untouched: the pinned key stays in `hosts`, so the next connection | ||
| 157 | /// only asks for a password. Returns whether a credential was actually stored. | ||
| 158 | pub fn forget_credentials( | ||
| 159 | conn: &Connection, | ||
| 160 | fingerprint: &str, | ||
| 161 | ) -> Result<bool, rusqlite::Error> { | ||
| 162 | let removed = conn.execute( | ||
| 163 | "DELETE FROM credentials WHERE fingerprint = ?1", | ||
| 164 | [fingerprint], | ||
| 165 | )?; | ||
| 166 | Ok(removed > 0) | ||
| 167 | } | ||
| 168 | |||
| 154 | /// List all remembered hosts with their fingerprints. | 169 | /// List all remembered hosts with their fingerprints. |
| 155 | pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> { | 170 | pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> { |
| 156 | let mut stmt = conn | 171 | 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; | |||
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod input; | 4 | pub mod input; |
| 5 | pub mod paths; | 5 | pub mod paths; |
| 6 | pub mod shell; | ||
| 6 | pub mod transport; | 7 | pub mod transport; |
| 7 | pub mod tui; | 8 | pub mod tui; |
| 8 | pub mod ui; | 9 | 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 @@ | |||
| 1 | //! Parser for the terminal's command line. | ||
| 2 | //! | ||
| 3 | //! The core speaks `Command`; this module turns one typed line into either a | ||
| 4 | //! core command or an action only an interactive frontend can perform | ||
| 5 | //! (connecting, prompting for a password, listing hosts). | ||
| 6 | |||
| 7 | use crate::client::ui::parse_bet_arg; | ||
| 8 | use crate::client::view::Command; | ||
| 9 | use crate::history::{DEFAULT_HISTORY, MAX_HISTORY}; | ||
| 10 | |||
| 11 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 12 | pub enum Shell { | ||
| 13 | /// A command the core can run on its own. | ||
| 14 | Run(Command), | ||
| 15 | Login, | ||
| 16 | Register, | ||
| 17 | SetCred, | ||
| 18 | Connect(Option<String>), | ||
| 19 | Disconnect, | ||
| 20 | Hosts, | ||
| 21 | ForgetHost(Option<String>), | ||
| 22 | ForgetLogin(Option<String>), | ||
| 23 | Help, | ||
| 24 | Quit, | ||
| 25 | Empty, | ||
| 26 | Usage(String), | ||
| 27 | } | ||
| 28 | |||
| 29 | /// Split off the first whitespace-delimited word. | ||
| 30 | /// | ||
| 31 | /// The remainder keeps its internal spacing (so `send` can echo a message | ||
| 32 | /// verbatim) but loses the separator run before it. | ||
| 33 | fn split_word(line: &str) -> (&str, &str) { | ||
| 34 | match line.find(char::is_whitespace) { | ||
| 35 | Some(index) => (&line[..index], line[index..].trim_start()), | ||
| 36 | None => (line, ""), | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | pub fn parse(line: &str) -> Shell { | ||
| 41 | let line = line.trim(); | ||
| 42 | if line.is_empty() { | ||
| 43 | return Shell::Empty; | ||
| 44 | } | ||
| 45 | |||
| 46 | let (word, rest) = split_word(line); | ||
| 47 | match word { | ||
| 48 | "play" => { | ||
| 49 | let (game, bet) = split_word(rest); | ||
| 50 | if game.is_empty() { | ||
| 51 | return Shell::Usage("Usage: play <game> [bet]".to_string()); | ||
| 52 | } | ||
| 53 | let bet = if bet.is_empty() { | ||
| 54 | None | ||
| 55 | } else { | ||
| 56 | match parse_bet_arg(bet) { | ||
| 57 | Ok(bet) => Some(bet), | ||
| 58 | Err(message) => return Shell::Usage(message), | ||
| 59 | } | ||
| 60 | }; | ||
| 61 | Shell::Run(Command::Play { | ||
| 62 | game: game.to_string(), | ||
| 63 | bet, | ||
| 64 | }) | ||
| 65 | } | ||
| 66 | "history" => { | ||
| 67 | let (raw, _) = split_word(rest); | ||
| 68 | let limit = if raw.is_empty() { | ||
| 69 | DEFAULT_HISTORY | ||
| 70 | } else { | ||
| 71 | match raw.parse::<u32>() { | ||
| 72 | Ok(count) if (1..=MAX_HISTORY).contains(&count) => count, | ||
| 73 | _ => return Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")), | ||
| 74 | } | ||
| 75 | }; | ||
| 76 | Shell::Run(Command::History { limit }) | ||
| 77 | } | ||
| 78 | "games" => Shell::Run(Command::Games), | ||
| 79 | "logout" => Shell::Run(Command::Logout), | ||
| 80 | "ping" => Shell::Run(Command::Ping), | ||
| 81 | "send" => { | ||
| 82 | if rest.is_empty() { | ||
| 83 | Shell::Usage("Usage: send <message>".to_string()) | ||
| 84 | } else { | ||
| 85 | Shell::Run(Command::Echo(rest.to_string())) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | "info" => Shell::Run(Command::Info), | ||
| 89 | "help" => Shell::Help, | ||
| 90 | "connect" => Shell::Connect(optional_word(rest)), | ||
| 91 | "login" => Shell::Login, | ||
| 92 | "register" => Shell::Register, | ||
| 93 | "setcred" => Shell::SetCred, | ||
| 94 | "disconnect" => Shell::Disconnect, | ||
| 95 | "hosts" => Shell::Hosts, | ||
| 96 | "forget-host" => Shell::ForgetHost(optional_word(rest)), | ||
| 97 | "forget-login" => Shell::ForgetLogin(optional_word(rest)), | ||
| 98 | "quit" => Shell::Quit, | ||
| 99 | _ => Shell::Help, | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | fn optional_word(rest: &str) -> Option<String> { | ||
| 104 | let (word, _) = split_word(rest); | ||
| 105 | if word.is_empty() { | ||
| 106 | None | ||
| 107 | } else { | ||
| 108 | Some(word.to_string()) | ||
| 109 | } | ||
| 110 | } | ||
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 | |||
| @@ -76,6 +76,11 @@ const COMMANDS: &[CommandHelp] = &[ | |||
| 76 | description: "clear trust for one address (credentials stay while the key is in use)", | 76 | description: "clear trust for one address (credentials stay while the key is in use)", |
| 77 | }, | 77 | }, |
| 78 | CommandHelp { | 78 | CommandHelp { |
| 79 | name: "forget-login", | ||
| 80 | usage: "forget-login [ip:port]", | ||
| 81 | description: "forget the saved login (trust is kept)", | ||
| 82 | }, | ||
| 83 | CommandHelp { | ||
| 79 | name: "hosts", | 84 | name: "hosts", |
| 80 | usage: "hosts", | 85 | usage: "hosts", |
| 81 | description: "list remembered hosts", | 86 | description: "list remembered hosts", |
| @@ -272,6 +277,15 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option<String> { | |||
| 272 | )) | 277 | )) |
| 273 | } | 278 | } |
| 274 | 279 | ||
| 280 | /// List of games for the terminal's `games` command. | ||
| 281 | pub fn game_lines(games: &[(String, String)]) -> Vec<String> { | ||
| 282 | let mut lines = vec!["Available games:".to_string()]; | ||
| 283 | for (game, name) in games { | ||
| 284 | lines.push(format!(" - {game} ({name})")); | ||
| 285 | } | ||
| 286 | lines | ||
| 287 | } | ||
| 288 | |||
| 275 | /// One line per settled play, newest first. | 289 | /// One line per settled play, newest first. |
| 276 | pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { | 290 | pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { |
| 277 | if records.is_empty() { | 291 | 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 { | |||
| 274 | pub enum Command { | 274 | pub enum Command { |
| 275 | /// The credential is already hashed by the frontend. | 275 | /// The credential is already hashed by the frontend. |
| 276 | Login { user: String, credential: String }, | 276 | Login { user: String, credential: String }, |
| 277 | Games, | ||
| 278 | Ping, | ||
| 279 | Echo(String), | ||
| 280 | Info, | ||
| 277 | Register { user: String, credential: String }, | 281 | Register { user: String, credential: String }, |
| 278 | Play { game: String, bet: Option<u64> }, | 282 | Play { game: String, bet: Option<u64> }, |
| 279 | History { limit: u32 }, | 283 | 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 @@ | |||
| 6 | 6 | ||
| 7 | use std::time::Duration; | 7 | use std::time::Duration; |
| 8 | use zagan_bet::blackjack::BlackjackState; | 8 | use zagan_bet::blackjack::BlackjackState; |
| 9 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; | 9 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 10 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | 10 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; |
| 11 | use zagan_bet::client::shell::{self, Shell}; | ||
| 12 | use zagan_bet::client::ui::help_lines; | ||
| 11 | use zagan_bet::client::games::response::{ | 13 | use zagan_bet::client::games::response::{ |
| 12 | parse_blackjack_response, parse_crash_response, parse_spin_response, | 14 | parse_blackjack_response, parse_crash_response, parse_spin_response, |
| 13 | }; | 15 | }; |
| @@ -31,12 +33,7 @@ struct Screen { | |||
| 31 | } | 33 | } |
| 32 | 34 | ||
| 33 | impl Screen { | 35 | impl Screen { |
| 34 | fn apply( | 36 | fn apply(&mut self, effects: &[Effect], sent: &mut Vec<String>) { |
| 35 | &mut self, | ||
| 36 | effects: &[Effect], | ||
| 37 | sent: &mut Vec<String>, | ||
| 38 | interactive: &mut Vec<InteractiveCommand>, | ||
| 39 | ) { | ||
| 40 | for effect in effects { | 37 | for effect in effects { |
| 41 | match effect { | 38 | match effect { |
| 42 | Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), | 39 | Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), |
| @@ -57,19 +54,8 @@ impl Screen { | |||
| 57 | self.region_start = None; | 54 | self.region_start = None; |
| 58 | self.region_len = 0; | 55 | self.region_len = 0; |
| 59 | } | 56 | } |
| 60 | Effect::AnimateRoulette { view, .. } => { | ||
| 61 | let lines = zagan_bet::client::tui::render_view(view); | ||
| 62 | if let Some(start) = self.region_start { | ||
| 63 | self.lines.truncate(start); | ||
| 64 | } else { | ||
| 65 | self.region_start = Some(self.lines.len()); | ||
| 66 | } | ||
| 67 | self.lines.extend(lines.iter().cloned()); | ||
| 68 | self.region_len = lines.len(); | ||
| 69 | } | ||
| 70 | Effect::SendLine(line) => sent.push(line.clone()), | 57 | Effect::SendLine(line) => sent.push(line.clone()), |
| 71 | Effect::Interactive(command) => interactive.push(command.clone()), | 58 | Effect::Quit | Effect::Disconnect => {} |
| 72 | Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} | ||
| 73 | } | 59 | } |
| 74 | } | 60 | } |
| 75 | } | 61 | } |
| @@ -87,7 +73,6 @@ struct Harness { | |||
| 87 | editor: Editor, | 73 | editor: Editor, |
| 88 | screen: Screen, | 74 | screen: Screen, |
| 89 | sent: Vec<String>, | 75 | sent: Vec<String>, |
| 90 | interactive: Vec<InteractiveCommand>, | ||
| 91 | } | 76 | } |
| 92 | 77 | ||
| 93 | impl Harness { | 78 | impl Harness { |
| @@ -100,13 +85,11 @@ impl Harness { | |||
| 100 | editor: Editor::new(), | 85 | editor: Editor::new(), |
| 101 | screen: Screen::default(), | 86 | screen: Screen::default(), |
| 102 | sent: Vec::new(), | 87 | sent: Vec::new(), |
| 103 | interactive: Vec::new(), | ||
| 104 | } | 88 | } |
| 105 | } | 89 | } |
| 106 | 90 | ||
| 107 | fn apply(&mut self, effects: Vec<Effect>) { | 91 | fn apply(&mut self, effects: Vec<Effect>) { |
| 108 | self.screen | 92 | self.screen.apply(&effects, &mut self.sent); |
| 109 | .apply(&effects, &mut self.sent, &mut self.interactive); | ||
| 110 | } | 93 | } |
| 111 | 94 | ||
| 112 | fn key(&mut self, key: Key) { | 95 | fn key(&mut self, key: Key) { |
| @@ -119,7 +102,7 @@ impl Harness { | |||
| 119 | } else { | 102 | } else { |
| 120 | match self.editor.handle(key) { | 103 | match self.editor.handle(key) { |
| 121 | Input::Quit => vec![Effect::Quit], | 104 | Input::Quit => vec![Effect::Quit], |
| 122 | Input::Submit(line) => self.core.submit_line(&line), | 105 | Input::Submit(line) => self.dispatch(&line), |
| 123 | Input::Print(lines) => vec![Effect::Print(lines)], | 106 | Input::Print(lines) => vec![Effect::Print(lines)], |
| 124 | Input::Prompt | Input::None => Vec::new(), | 107 | Input::Prompt | Input::None => Vec::new(), |
| 125 | } | 108 | } |
| @@ -128,6 +111,26 @@ impl Harness { | |||
| 128 | self.apply_prompt(); | 111 | self.apply_prompt(); |
| 129 | } | 112 | } |
| 130 | 113 | ||
| 114 | /// Route a submitted line the way the terminal adapter does. | ||
| 115 | fn dispatch(&mut self, line: &str) -> Vec<Effect> { | ||
| 116 | match shell::parse(line) { | ||
| 117 | Shell::Run(command) => self.core.command(command), | ||
| 118 | Shell::Disconnect => { | ||
| 119 | self.core.reset_after_disconnect(); | ||
| 120 | vec![ | ||
| 121 | Effect::ClearRegion, | ||
| 122 | Effect::Print(vec!["Disconnected".to_string()]), | ||
| 123 | ] | ||
| 124 | } | ||
| 125 | Shell::Help => vec![Effect::Print(help_lines())], | ||
| 126 | Shell::Quit => vec![Effect::Quit], | ||
| 127 | Shell::Empty => Vec::new(), | ||
| 128 | Shell::Usage(message) => vec![Effect::Print(vec![message])], | ||
| 129 | // Connect/login prompts are adapter flows, not core state. | ||
| 130 | _ => Vec::new(), | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 131 | fn apply_prompt(&mut self) { | 134 | fn apply_prompt(&mut self) { |
| 132 | if self.core.is_in_game() { | 135 | if self.core.is_in_game() { |
| 133 | return; | 136 | 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 @@ | |||
| 1 | //! The terminal command line is a thin parser over `Command`. | ||
| 2 | //! | ||
| 3 | //! Anything the core can do on its own becomes `Shell::Run`; connecting, | ||
| 4 | //! prompting for credentials and host maintenance stay with the adapter. | ||
| 5 | |||
| 6 | use zagan_bet::client::shell::{Shell, parse}; | ||
| 7 | use zagan_bet::client::view::Command; | ||
| 8 | use zagan_bet::history::{DEFAULT_HISTORY, MAX_HISTORY}; | ||
| 9 | |||
| 10 | fn run(line: &str) -> Command { | ||
| 11 | match parse(line) { | ||
| 12 | Shell::Run(command) => command, | ||
| 13 | other => panic!("expected a command, got {other:?}"), | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | #[test] | ||
| 18 | fn blank_lines_do_nothing() { | ||
| 19 | assert_eq!(parse(""), Shell::Empty); | ||
| 20 | assert_eq!(parse(" \t "), Shell::Empty); | ||
| 21 | } | ||
| 22 | |||
| 23 | #[test] | ||
| 24 | fn play_without_a_game_prints_usage() { | ||
| 25 | assert_eq!( | ||
| 26 | parse("play"), | ||
| 27 | Shell::Usage("Usage: play <game> [bet]".to_string()) | ||
| 28 | ); | ||
| 29 | } | ||
| 30 | |||
| 31 | #[test] | ||
| 32 | fn play_carries_the_game_and_an_optional_bet() { | ||
| 33 | assert_eq!( | ||
| 34 | run("play slots"), | ||
| 35 | Command::Play { | ||
| 36 | game: "slots".to_string(), | ||
| 37 | bet: None, | ||
| 38 | } | ||
| 39 | ); | ||
| 40 | assert_eq!( | ||
| 41 | run("play roulette 25"), | ||
| 42 | Command::Play { | ||
| 43 | game: "roulette".to_string(), | ||
| 44 | bet: Some(25), | ||
| 45 | } | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | #[test] | ||
| 50 | fn play_rejects_a_bet_below_the_table_minimum() { | ||
| 51 | assert_eq!( | ||
| 52 | parse("play slots 5"), | ||
| 53 | Shell::Usage("Bet must be at least 10 Kr".to_string()) | ||
| 54 | ); | ||
| 55 | assert_eq!( | ||
| 56 | parse("play slots ten"), | ||
| 57 | Shell::Usage("Invalid bet: ten".to_string()) | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | |||
| 61 | #[test] | ||
| 62 | fn history_defaults_to_the_recent_limit() { | ||
| 63 | let expected = Command::History { | ||
| 64 | limit: DEFAULT_HISTORY, | ||
| 65 | }; | ||
| 66 | assert_eq!(run("history"), expected); | ||
| 67 | assert_eq!(run("history 5"), Command::History { limit: 5 }); | ||
| 68 | } | ||
| 69 | |||
| 70 | #[test] | ||
| 71 | fn history_rejects_counts_outside_the_range() { | ||
| 72 | let usage = Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")); | ||
| 73 | assert_eq!(parse("history 0"), usage); | ||
| 74 | assert_eq!(parse(&format!("history {}", MAX_HISTORY + 1)), usage); | ||
| 75 | assert_eq!(parse("history many"), usage); | ||
| 76 | } | ||
| 77 | |||
| 78 | #[test] | ||
| 79 | fn simple_commands_are_pure_core_commands() { | ||
| 80 | assert_eq!(run("games"), Command::Games); | ||
| 81 | assert_eq!(run("logout"), Command::Logout); | ||
| 82 | assert_eq!(run("ping"), Command::Ping); | ||
| 83 | assert_eq!(run("info"), Command::Info); | ||
| 84 | } | ||
| 85 | |||
| 86 | #[test] | ||
| 87 | fn send_keeps_the_whole_message_including_extra_spaces() { | ||
| 88 | assert_eq!( | ||
| 89 | run("send hello there friend"), | ||
| 90 | Command::Echo("hello there friend".to_string()) | ||
| 91 | ); | ||
| 92 | assert_eq!( | ||
| 93 | parse("send"), | ||
| 94 | Shell::Usage("Usage: send <message>".to_string()) | ||
| 95 | ); | ||
| 96 | } | ||
| 97 | |||
| 98 | #[test] | ||
| 99 | fn connection_flows_stay_with_the_adapter() { | ||
| 100 | assert_eq!(parse("connect"), Shell::Connect(None)); | ||
| 101 | assert_eq!( | ||
| 102 | parse("connect 127.0.0.1:11525"), | ||
| 103 | Shell::Connect(Some("127.0.0.1:11525".to_string())) | ||
| 104 | ); | ||
| 105 | assert_eq!(parse("login"), Shell::Login); | ||
| 106 | assert_eq!(parse("register"), Shell::Register); | ||
| 107 | assert_eq!(parse("setcred"), Shell::SetCred); | ||
| 108 | assert_eq!(parse("disconnect"), Shell::Disconnect); | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn host_maintenance_is_adapter_owned() { | ||
| 113 | assert_eq!(parse("hosts"), Shell::Hosts); | ||
| 114 | assert_eq!(parse("forget-host"), Shell::ForgetHost(None)); | ||
| 115 | assert_eq!( | ||
| 116 | parse("forget-host 10.0.0.2:11525"), | ||
| 117 | Shell::ForgetHost(Some("10.0.0.2:11525".to_string())) | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | |||
| 121 | #[test] | ||
| 122 | fn forgetting_a_saved_login_is_separate_from_forgetting_trust() { | ||
| 123 | assert_eq!(parse("forget-login"), Shell::ForgetLogin(None)); | ||
| 124 | assert_eq!( | ||
| 125 | parse("forget-login 10.0.0.2:11525"), | ||
| 126 | Shell::ForgetLogin(Some("10.0.0.2:11525".to_string())) | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | fn help_and_quit_are_terminal_only() { | ||
| 132 | assert_eq!(parse("help"), Shell::Help); | ||
| 133 | assert_eq!(parse("quit"), Shell::Quit); | ||
| 134 | } | ||
| 135 | |||
| 136 | #[test] | ||
| 137 | fn an_unknown_word_falls_back_to_help() { | ||
| 138 | assert_eq!(parse("frobnicate"), Shell::Help); | ||
| 139 | assert_eq!(parse("quit now"), Shell::Quit); | ||
| 140 | } | ||
| 141 | |||
| 142 | #[test] | ||
| 143 | fn surrounding_whitespace_is_ignored() { | ||
| 144 | assert_eq!(run(" ping "), Command::Ping); | ||
| 145 | assert_eq!( | ||
| 146 | run(" play slots 30 "), | ||
| 147 | Command::Play { | ||
| 148 | game: "slots".to_string(), | ||
| 149 | bet: Some(30), | ||
| 150 | } | ||
| 151 | ); | ||
| 152 | } | ||
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() { | |||
| 27 | fn completion_knows_every_command() { | 27 | fn completion_knows_every_command() { |
| 28 | let commands = command_names(); | 28 | let commands = command_names(); |
| 29 | for expected in [ | 29 | for expected in [ |
| 30 | "connect", "games", "play", "hosts", "send", "logout", "quit", | 30 | "connect", "games", "play", "hosts", "forget-host", "forget-login", "send", "logout", |
| 31 | "quit", | ||
| 31 | ] { | 32 | ] { |
| 32 | assert!( | 33 | assert!( |
| 33 | commands.contains(&expected), | 34 | 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 | |||
| @@ -89,6 +89,27 @@ fn forgetting_one_address_keeps_the_shared_credentials() { | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | #[test] | 91 | #[test] |
| 92 | fn forgetting_the_login_keeps_the_trusted_key() { | ||
| 93 | let (_, conn) = tmp_db("creds-forget-login"); | ||
| 94 | client_database::init_db(&conn); | ||
| 95 | |||
| 96 | client_database::save_host(&conn, "127.0.0.1:11525"); | ||
| 97 | client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap(); | ||
| 98 | client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap(); | ||
| 99 | |||
| 100 | assert!(client_database::forget_credentials(&conn, "server-a").unwrap()); | ||
| 101 | assert_eq!(client_database::get_credentials(&conn, "server-a"), None); | ||
| 102 | assert_eq!( | ||
| 103 | client_database::get_fingerprint(&conn, "127.0.0.1:11525"), | ||
| 104 | Some("server-a".to_string()), | ||
| 105 | "forgetting the login must not unpin the key" | ||
| 106 | ); | ||
| 107 | |||
| 108 | // A second call is a no-op, not an error. | ||
| 109 | assert!(!client_database::forget_credentials(&conn, "server-a").unwrap()); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 92 | fn client_last_host_tracking() { | 113 | fn client_last_host_tracking() { |
| 93 | let (_, conn) = tmp_db("hosts"); | 114 | let (_, conn) = tmp_db("hosts"); |
| 94 | client_database::init_db(&conn); | 115 | client_database::init_db(&conn); |
