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 /src/client/core.rs | |
| 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).
Diffstat (limited to 'src/client/core.rs')
| -rw-r--r-- | src/client/core.rs | 251 |
1 files changed, 57 insertions, 194 deletions
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 | }) |
