From 50fdaa8e5ad9454e783f01e2c369767834120d11 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 18:08:38 +0100 Subject: v4.0.3: lobby and history screens behind commands --- Cargo.toml | 2 +- docs/GUI-PLAN.md | 6 +- src/client/core.rs | 151 ++++++++++++++++++++++++++++++++++++++------------- src/client/tui.rs | 2 + src/client/view.rs | 21 +++++++ tests/client_core.rs | 80 +++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 40 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fce7a90..877ec86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "4.0.2" +version = "4.0.3" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 83bab1c..c3d9b3f 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -28,8 +28,10 @@ existing game flows unchanged, new tests for version parsing and gating. **In progress.** `Command`/`View` exist and all four games are ported: the core builds a typed view, frontends send `Command`, and the TUI renders it. -Remaining for this phase: the app screens (`Connect`, `Trust`, `Login`, -`Lobby`, `History`) and removing `Key` from the core. +Done so far: `Lobby` and `History` are structured views driven by +`Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the +connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with +the transport moved into the library, and removing `Key` from the core. - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, wheel animation, interactive prompts) into the TUI adapter. diff --git a/src/client/core.rs b/src/client/core.rs index 3e3126a..37ba916 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -4,13 +4,15 @@ use crate::client::games::response::{ parse_roulette_response, parse_spin_response, }; use crate::client::games::roulette::Direction; -use crate::client::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View}; +use crate::client::view::{ + BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, +}; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, history_lines, input_cursor_column, parse_bet_arg, record_history, }; use crate::crash::{CrashState, multiplier_at}; -use crate::history::MAX_HISTORY; +use crate::history::{PlayRecord, MAX_HISTORY}; use crate::roulette::{BetTarget, Bets}; use std::time::{Duration, Instant}; @@ -80,6 +82,12 @@ enum Pending { Info, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Screen { + Lobby, + History, +} + pub struct ClientCore { input: String, history: Vec, @@ -93,6 +101,10 @@ pub struct ClientCore { fingerprint: Option, /// Local anchor for the crash multiplier, set when a round starts. crash_started: Option, + /// App screen shown when no game is open. + screen: Screen, + /// Last plays fetched with `history`. + play_history: Vec, } impl ClientCore { @@ -109,6 +121,8 @@ impl ClientCore { host: None, fingerprint: None, crash_started: None, + screen: Screen::Lobby, + play_history: Vec::new(), } } @@ -118,6 +132,7 @@ impl ClientCore { self.logged_in = None; self.pending = None; self.crash_started = None; + self.screen = Screen::Lobby; } } @@ -291,7 +306,10 @@ impl ClientCore { } }, Some(Pending::History) => match parse_history_response(reply) { - Ok(records) => vec![Effect::Print(history_lines(&records))], + Ok(records) => { + self.play_history = records.clone(); + vec![Effect::Print(history_lines(&records))] + } Err(message) => print_line(message), }, Some(Pending::Ping) => { @@ -386,6 +404,17 @@ impl ClientCore { let game = self.current_game_type().map(str::to_string); match command { + Command::Play { game, bet } => self.play_game(&game, bet), + Command::History { limit } => self.load_history(limit), + Command::ShowLobby => { + self.screen = Screen::Lobby; + Vec::new() + } + Command::Logout => { + self.logged_in = None; + self.screen = Screen::Lobby; + print_line("Logged out") + } Command::SetBet(bet) => { if game.as_deref() == Some("roulette") { return self.command(Command::SetChip(bet)); @@ -479,6 +508,7 @@ impl ClientCore { fn exit_game(&mut self) -> Vec { self.crash_started = None; + self.screen = Screen::Lobby; self.game.end_game(); vec![ Effect::ClearRegion, @@ -850,30 +880,18 @@ impl ClientCore { } } - fn cmd_play(&mut self, parts: &[&str]) -> Vec { + fn play_game(&mut self, game_type: &str, bet: Option) -> Vec { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } if self.logged_in.is_none() { return print_line("Not logged in. Use 'login' first."); } - if parts.len() < 2 { - return self.available_games(Some("Usage: play [bet]")); - } - - let bet = match parts.get(2) { - Some(raw) => match parse_bet_arg(raw) { - Ok(bet) => Some(bet), - Err(message) => return print_line(message), - }, - None => None, - }; - if self.game.is_in_game() { return print_line("Already in a game. Use 'quit' to exit current game."); } - match self.game.enter_game(parts[1]) { + match self.game.enter_game(game_type) { Ok(()) => { if let Some(bet) = bet { let _ = self.game.set_bet(bet); @@ -891,6 +909,52 @@ impl ClientCore { } } + fn load_history(&mut self, limit: u32) -> Vec { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + if self.logged_in.is_none() { + return print_line("Not logged in. Use 'login' first."); + } + if !(1..=MAX_HISTORY).contains(&limit) { + return print_line(format!("Usage: history [1-{MAX_HISTORY}]")); + } + + self.screen = Screen::History; + self.pending = Some(Pending::History); + let request = if limit == crate::history::DEFAULT_HISTORY { + "HISTORY".to_string() + } else { + format!("HISTORY {limit}") + }; + vec![Effect::SendLine(request)] + } + + fn cmd_play(&mut self, parts: &[&str]) -> Vec { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + if self.logged_in.is_none() { + return print_line("Not logged in. Use 'login' first."); + } + if parts.len() < 2 { + return self.available_games(Some("Usage: play [bet]")); + } + + let bet = match parts.get(2) { + Some(raw) => match parse_bet_arg(raw) { + Ok(bet) => Some(bet), + Err(message) => return print_line(message), + }, + None => None, + }; + + self.command(Command::Play { + game: parts[1].to_string(), + bet, + }) + } + fn available_games(&self, usage: Option<&str>) -> Vec { let mut lines = vec!["Available games:".to_string()]; for (game_type, name) in self.game.get_available_games() { @@ -904,23 +968,15 @@ impl ClientCore { } fn cmd_history(&mut self, parts: &[&str]) -> Vec { - if !self.connected { - return print_line("Not connected. Use 'connect :' first."); - } - if self.logged_in.is_none() { - return print_line("Not logged in. Use 'login' first."); - } - - let request = match parts.get(1) { + let limit = match parts.get(1) { Some(raw) => match raw.parse::() { - Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), - _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), + Ok(count) => count, + Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), }, - None => "HISTORY".to_string(), + None => crate::history::DEFAULT_HISTORY, }; - self.pending = Some(Pending::History); - vec![Effect::SendLine(request)] + self.command(Command::History { limit }) } fn cmd_info(&mut self) -> Vec { @@ -990,17 +1046,38 @@ impl ClientCore { /// Snapshot of the current screen for a frontend to draw. pub fn view(&self) -> Option { - let state = self.game.get_current_state()?; - Some(match state.game_type.as_str() { - "slots" => View::Slots(SlotsView::from_state(state)), - "roulette" => View::Roulette(RouletteView::from_state(state)), - "blackjack" => View::Blackjack(BlackjackView::from_state(state)), - "crash" => View::Crash(CrashView::from_state(state)), - _ => return None, + if let Some(state) = self.game.get_current_state() { + return Some(match state.game_type.as_str() { + "slots" => View::Slots(SlotsView::from_state(state)), + "roulette" => View::Roulette(RouletteView::from_state(state)), + "blackjack" => View::Blackjack(BlackjackView::from_state(state)), + "crash" => View::Crash(CrashView::from_state(state)), + _ => return None, + }); + } + + Some(match self.screen { + Screen::History => View::History(HistoryView { + records: self.play_history.clone(), + }), + Screen::Lobby => View::Lobby(LobbyView { + connected: self.connected, + user: self.logged_in.clone(), + balance: self.balance, + games: self + .game + .get_available_games() + .into_iter() + .map(|(game, name)| (game.to_string(), name.to_string())) + .collect(), + }), }) } fn board_effects(&self) -> Vec { + if !self.is_in_game() { + return Vec::new(); + } self.view().map(Effect::Render).into_iter().collect() } diff --git a/src/client/tui.rs b/src/client/tui.rs index ddea5e2..9853edf 100644 --- a/src/client/tui.rs +++ b/src/client/tui.rs @@ -16,6 +16,8 @@ pub fn render_view(view: &View) -> Vec { View::Roulette(roulette) => RouletteGame::render_view(roulette), View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack), View::Crash(crash) => CrashGame::render_view(crash), + // The terminal adapter keeps its command prompt for these screens. + View::Lobby(_) | View::History(_) => String::new(), }; rendered.lines().map(str::to_string).collect() } diff --git a/src/client/view.rs b/src/client/view.rs index 77efec6..903403c 100644 --- a/src/client/view.rs +++ b/src/client/view.rs @@ -241,9 +241,26 @@ impl CrashView { } } +/// The lobby: who is logged in, the balance, and what can be played. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LobbyView { + pub connected: bool, + pub user: Option, + pub balance: Option, + pub games: Vec<(String, String)>, +} + +/// The play log, already parsed. +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryView { + pub records: Vec, +} + /// Everything a frontend can draw. #[derive(Debug, Clone, PartialEq)] pub enum View { + Lobby(LobbyView), + History(HistoryView), Slots(SlotsView), Roulette(RouletteView), Blackjack(BlackjackView), @@ -253,6 +270,10 @@ pub enum View { /// Semantic input. Frontends turn keys and clicks into these. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { + Play { game: String, bet: Option }, + History { limit: u32 }, + ShowLobby, + Logout, SetBet(u64), AdjustBet(i64), Leave, diff --git a/tests/client_core.rs b/tests/client_core.rs index 0a2cb7b..d070a5b 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -1407,3 +1407,83 @@ fn the_crash_view_follows_the_round() { assert!((view.multiplier - 2.0).abs() < 1e-6); assert_eq!(view.potential, 20); } + +#[test] +fn the_lobby_view_lists_the_games_and_the_account() { + let mut h = Harness::new(); + h.core.set_balance(1000); + + let Some(View::Lobby(lobby)) = h.core.view() else { + panic!("expected the lobby"); + }; + assert!(lobby.connected); + assert_eq!(lobby.user.as_deref(), Some("alice")); + assert_eq!(lobby.balance, Some(1000)); + + let games: Vec<&str> = lobby.games.iter().map(|(game, _)| game.as_str()).collect(); + assert_eq!(games, vec!["blackjack", "crash", "roulette", "slots"]); +} + +#[test] +fn a_play_command_opens_the_game_view() { + let mut h = Harness::new(); + + let effects = h.core.command(Command::Play { + game: "slots".to_string(), + bet: Some(25), + }); + h.apply(effects); + + let Some(View::Slots(view)) = h.core.view() else { + panic!("expected a slots view"); + }; + assert_eq!(view.bet, 25); + assert!(h.sent.iter().any(|line| line == "INFO")); + assert_eq!(h.board().len(), 10); +} + +#[test] +fn history_is_a_screen_and_keeps_its_records() { + let mut h = Harness::new(); + + let effects = h.core.command(Command::History { limit: 5 }); + h.apply(effects); + assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY 5")); + + let json = r#"[{"game":"crash","bet":10,"winnings":11,"balance_after":1011,"at":"2026-11-01 12:00:00","details":{"result":"cashed","multiplier":1.13}}]"#; + h.reply(&format!("HISTORY {json}")); + + let Some(View::History(view)) = h.core.view() else { + panic!("expected the history screen"); + }; + assert_eq!(view.records.len(), 1); + assert_eq!(view.records[0].game, "crash"); + assert_eq!(view.records[0].summary(), "cashed 1.13x"); + + // The terminal adapter still prints the table. + assert!(h + .screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)"))); +} + +#[test] +fn logging_out_and_showing_the_lobby_switch_the_screen() { + let mut h = Harness::new(); + h.core.command(Command::History { limit: 5 }); + assert!(matches!(h.core.view(), Some(View::History(_)))); + + h.core.command(Command::ShowLobby); + let Some(View::Lobby(lobby)) = h.core.view() else { + panic!("expected the lobby"); + }; + assert_eq!(lobby.user.as_deref(), Some("alice")); + + let effects = h.core.command(Command::Logout); + h.apply(effects); + let Some(View::Lobby(lobby)) = h.core.view() else { + panic!("expected the lobby"); + }; + assert_eq!(lobby.user, None); +} -- cgit v1.2.3