summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
commit24e3bb54db579fb9aad8df2a591527dc77fe4cb4 (patch)
treeaf9a0c290f2675068e8396823f641f24bbe4d308 /src
parentv4.0.6: move the prompt editor and key mapping out of the core (diff)
downloadzagan-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).
Diffstat (limited to 'src')
-rw-r--r--src/bin/client/repl.rs190
-rw-r--r--src/client/core.rs251
-rw-r--r--src/client/database.rs15
-rw-r--r--src/client/mod.rs1
-rw-r--r--src/client/shell.rs110
-rw-r--r--src/client/ui.rs14
-rw-r--r--src/client/view.rs4
7 files changed, 332 insertions, 253 deletions
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
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; 16use zagan_bet::client::core::{ClientCore, Effect};
17use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; 17use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
18use zagan_bet::client::shell::{self, Shell};
19use zagan_bet::client::view::{RoulettePhase, View};
18use zagan_bet::client::database; 20use zagan_bet::client::database;
19use zagan_bet::client::ui::{ 21use 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};
23use zagan_bet::protocol::io_failure_message; 25use zagan_bet::protocol::io_failure_message;
24use zagan_bet::roulette::{WHEEL, color as roulette_color}; 26use 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
47impl ClientState { 51impl 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::{
6use crate::client::view::{ 6use crate::client::view::{
7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
8}; 8};
9use crate::client::ui::{help_lines, history_lines, parse_bet_arg}; 9use crate::client::ui::{game_lines, history_lines};
10use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
11use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{PlayRecord, MAX_HISTORY};
12use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
13use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
14 14
15 15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub 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.
158pub 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.
155pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> { 170pub 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;
3pub mod games; 3pub mod games;
4pub mod input; 4pub mod input;
5pub mod paths; 5pub mod paths;
6pub mod shell;
6pub mod transport; 7pub mod transport;
7pub mod tui; 8pub mod tui;
8pub mod ui; 9pub 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
7use crate::client::ui::parse_bet_arg;
8use crate::client::view::Command;
9use crate::history::{DEFAULT_HISTORY, MAX_HISTORY};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub 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.
33fn 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
40pub 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
103fn 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.
281pub 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.
276pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { 290pub 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 {
274pub enum Command { 274pub 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 },