summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 18:08:38 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 18:08:38 +0100
commit50fdaa8e5ad9454e783f01e2c369767834120d11 (patch)
treeb68fabbb7a8750a363cf41c1301acd0903cd0c73
parentv4.0.2: port slots, blackjack and crash to structured views (diff)
downloadzagan-bet-50fdaa8e5ad9454e783f01e2c369767834120d11.tar.gz
zagan-bet-50fdaa8e5ad9454e783f01e2c369767834120d11.zip
v4.0.3: lobby and history screens behind commands
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md6
-rw-r--r--src/client/core.rs151
-rw-r--r--src/client/tui.rs2
-rw-r--r--src/client/view.rs21
-rw-r--r--tests/client_core.rs80
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 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.2" 3version = "4.0.3"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6 6
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.
28 28
29**In progress.** `Command`/`View` exist and all four games are ported: the 29**In progress.** `Command`/`View` exist and all four games are ported: the
30core builds a typed view, frontends send `Command`, and the TUI renders it. 30core builds a typed view, frontends send `Command`, and the TUI renders it.
31Remaining for this phase: the app screens (`Connect`, `Trust`, `Login`, 31Done so far: `Lobby` and `History` are structured views driven by
32`Lobby`, `History`) and removing `Key` from the core. 32`Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the
33connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with
34the transport moved into the library, and removing `Key` from the core.
33 35
34- Introduce `Command` and `View`; move terminal-only concerns (prompt editing, 36- Introduce `Command` and `View`; move terminal-only concerns (prompt editing,
35 wheel animation, interactive prompts) into the TUI adapter. 37 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::{
4 parse_roulette_response, parse_spin_response, 4 parse_roulette_response, parse_spin_response,
5}; 5};
6use crate::client::games::roulette::Direction; 6use crate::client::games::roulette::Direction;
7use crate::client::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View}; 7use crate::client::view::{
8 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
9};
8use crate::client::ui::{ 10use crate::client::ui::{
9 command_names, completion_lines, display_width, help_lines, history_lines, 11 command_names, completion_lines, display_width, help_lines, history_lines,
10 input_cursor_column, parse_bet_arg, record_history, 12 input_cursor_column, parse_bet_arg, record_history,
11}; 13};
12use crate::crash::{CrashState, multiplier_at}; 14use crate::crash::{CrashState, multiplier_at};
13use crate::history::MAX_HISTORY; 15use crate::history::{PlayRecord, MAX_HISTORY};
14use crate::roulette::{BetTarget, Bets}; 16use crate::roulette::{BetTarget, Bets};
15use std::time::{Duration, Instant}; 17use std::time::{Duration, Instant};
16 18
@@ -80,6 +82,12 @@ enum Pending {
80 Info, 82 Info,
81} 83}
82 84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum Screen {
87 Lobby,
88 History,
89}
90
83pub struct ClientCore { 91pub struct ClientCore {
84 input: String, 92 input: String,
85 history: Vec<String>, 93 history: Vec<String>,
@@ -93,6 +101,10 @@ pub struct ClientCore {
93 fingerprint: Option<String>, 101 fingerprint: Option<String>,
94 /// Local anchor for the crash multiplier, set when a round starts. 102 /// Local anchor for the crash multiplier, set when a round starts.
95 crash_started: Option<Instant>, 103 crash_started: Option<Instant>,
104 /// App screen shown when no game is open.
105 screen: Screen,
106 /// Last plays fetched with `history`.
107 play_history: Vec<PlayRecord>,
96} 108}
97 109
98impl ClientCore { 110impl ClientCore {
@@ -109,6 +121,8 @@ impl ClientCore {
109 host: None, 121 host: None,
110 fingerprint: None, 122 fingerprint: None,
111 crash_started: None, 123 crash_started: None,
124 screen: Screen::Lobby,
125 play_history: Vec::new(),
112 } 126 }
113 } 127 }
114 128
@@ -118,6 +132,7 @@ impl ClientCore {
118 self.logged_in = None; 132 self.logged_in = None;
119 self.pending = None; 133 self.pending = None;
120 self.crash_started = None; 134 self.crash_started = None;
135 self.screen = Screen::Lobby;
121 } 136 }
122 } 137 }
123 138
@@ -291,7 +306,10 @@ impl ClientCore {
291 } 306 }
292 }, 307 },
293 Some(Pending::History) => match parse_history_response(reply) { 308 Some(Pending::History) => match parse_history_response(reply) {
294 Ok(records) => vec![Effect::Print(history_lines(&records))], 309 Ok(records) => {
310 self.play_history = records.clone();
311 vec![Effect::Print(history_lines(&records))]
312 }
295 Err(message) => print_line(message), 313 Err(message) => print_line(message),
296 }, 314 },
297 Some(Pending::Ping) => { 315 Some(Pending::Ping) => {
@@ -386,6 +404,17 @@ impl ClientCore {
386 let game = self.current_game_type().map(str::to_string); 404 let game = self.current_game_type().map(str::to_string);
387 405
388 match command { 406 match command {
407 Command::Play { game, bet } => self.play_game(&game, bet),
408 Command::History { limit } => self.load_history(limit),
409 Command::ShowLobby => {
410 self.screen = Screen::Lobby;
411 Vec::new()
412 }
413 Command::Logout => {
414 self.logged_in = None;
415 self.screen = Screen::Lobby;
416 print_line("Logged out")
417 }
389 Command::SetBet(bet) => { 418 Command::SetBet(bet) => {
390 if game.as_deref() == Some("roulette") { 419 if game.as_deref() == Some("roulette") {
391 return self.command(Command::SetChip(bet)); 420 return self.command(Command::SetChip(bet));
@@ -479,6 +508,7 @@ impl ClientCore {
479 508
480 fn exit_game(&mut self) -> Vec<Effect> { 509 fn exit_game(&mut self) -> Vec<Effect> {
481 self.crash_started = None; 510 self.crash_started = None;
511 self.screen = Screen::Lobby;
482 self.game.end_game(); 512 self.game.end_game();
483 vec![ 513 vec![
484 Effect::ClearRegion, 514 Effect::ClearRegion,
@@ -850,30 +880,18 @@ impl ClientCore {
850 } 880 }
851 } 881 }
852 882
853 fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { 883 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> {
854 if !self.connected { 884 if !self.connected {
855 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 885 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
856 } 886 }
857 if self.logged_in.is_none() { 887 if self.logged_in.is_none() {
858 return print_line("Not logged in. Use 'login' first."); 888 return print_line("Not logged in. Use 'login' first.");
859 } 889 }
860 if parts.len() < 2 {
861 return self.available_games(Some("Usage: play <game> [bet]"));
862 }
863
864 let bet = match parts.get(2) {
865 Some(raw) => match parse_bet_arg(raw) {
866 Ok(bet) => Some(bet),
867 Err(message) => return print_line(message),
868 },
869 None => None,
870 };
871
872 if self.game.is_in_game() { 890 if self.game.is_in_game() {
873 return print_line("Already in a game. Use 'quit' to exit current game."); 891 return print_line("Already in a game. Use 'quit' to exit current game.");
874 } 892 }
875 893
876 match self.game.enter_game(parts[1]) { 894 match self.game.enter_game(game_type) {
877 Ok(()) => { 895 Ok(()) => {
878 if let Some(bet) = bet { 896 if let Some(bet) = bet {
879 let _ = self.game.set_bet(bet); 897 let _ = self.game.set_bet(bet);
@@ -891,6 +909,52 @@ impl ClientCore {
891 } 909 }
892 } 910 }
893 911
912 fn load_history(&mut self, limit: u32) -> Vec<Effect> {
913 if !self.connected {
914 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
915 }
916 if self.logged_in.is_none() {
917 return print_line("Not logged in. Use 'login' first.");
918 }
919 if !(1..=MAX_HISTORY).contains(&limit) {
920 return print_line(format!("Usage: history [1-{MAX_HISTORY}]"));
921 }
922
923 self.screen = Screen::History;
924 self.pending = Some(Pending::History);
925 let request = if limit == crate::history::DEFAULT_HISTORY {
926 "HISTORY".to_string()
927 } else {
928 format!("HISTORY {limit}")
929 };
930 vec![Effect::SendLine(request)]
931 }
932
933 fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> {
934 if !self.connected {
935 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
936 }
937 if self.logged_in.is_none() {
938 return print_line("Not logged in. Use 'login' first.");
939 }
940 if parts.len() < 2 {
941 return self.available_games(Some("Usage: play <game> [bet]"));
942 }
943
944 let bet = match parts.get(2) {
945 Some(raw) => match parse_bet_arg(raw) {
946 Ok(bet) => Some(bet),
947 Err(message) => return print_line(message),
948 },
949 None => None,
950 };
951
952 self.command(Command::Play {
953 game: parts[1].to_string(),
954 bet,
955 })
956 }
957
894 fn available_games(&self, usage: Option<&str>) -> Vec<Effect> { 958 fn available_games(&self, usage: Option<&str>) -> Vec<Effect> {
895 let mut lines = vec!["Available games:".to_string()]; 959 let mut lines = vec!["Available games:".to_string()];
896 for (game_type, name) in self.game.get_available_games() { 960 for (game_type, name) in self.game.get_available_games() {
@@ -904,23 +968,15 @@ impl ClientCore {
904 } 968 }
905 969
906 fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { 970 fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> {
907 if !self.connected { 971 let limit = match parts.get(1) {
908 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
909 }
910 if self.logged_in.is_none() {
911 return print_line("Not logged in. Use 'login' first.");
912 }
913
914 let request = match parts.get(1) {
915 Some(raw) => match raw.parse::<u32>() { 972 Some(raw) => match raw.parse::<u32>() {
916 Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), 973 Ok(count) => count,
917 _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), 974 Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")),
918 }, 975 },
919 None => "HISTORY".to_string(), 976 None => crate::history::DEFAULT_HISTORY,
920 }; 977 };
921 978
922 self.pending = Some(Pending::History); 979 self.command(Command::History { limit })
923 vec![Effect::SendLine(request)]
924 } 980 }
925 981
926 fn cmd_info(&mut self) -> Vec<Effect> { 982 fn cmd_info(&mut self) -> Vec<Effect> {
@@ -990,17 +1046,38 @@ impl ClientCore {
990 1046
991 /// Snapshot of the current screen for a frontend to draw. 1047 /// Snapshot of the current screen for a frontend to draw.
992 pub fn view(&self) -> Option<View> { 1048 pub fn view(&self) -> Option<View> {
993 let state = self.game.get_current_state()?; 1049 if let Some(state) = self.game.get_current_state() {
994 Some(match state.game_type.as_str() { 1050 return Some(match state.game_type.as_str() {
995 "slots" => View::Slots(SlotsView::from_state(state)), 1051 "slots" => View::Slots(SlotsView::from_state(state)),
996 "roulette" => View::Roulette(RouletteView::from_state(state)), 1052 "roulette" => View::Roulette(RouletteView::from_state(state)),
997 "blackjack" => View::Blackjack(BlackjackView::from_state(state)), 1053 "blackjack" => View::Blackjack(BlackjackView::from_state(state)),
998 "crash" => View::Crash(CrashView::from_state(state)), 1054 "crash" => View::Crash(CrashView::from_state(state)),
999 _ => return None, 1055 _ => return None,
1056 });
1057 }
1058
1059 Some(match self.screen {
1060 Screen::History => View::History(HistoryView {
1061 records: self.play_history.clone(),
1062 }),
1063 Screen::Lobby => View::Lobby(LobbyView {
1064 connected: self.connected,
1065 user: self.logged_in.clone(),
1066 balance: self.balance,
1067 games: self
1068 .game
1069 .get_available_games()
1070 .into_iter()
1071 .map(|(game, name)| (game.to_string(), name.to_string()))
1072 .collect(),
1073 }),
1000 }) 1074 })
1001 } 1075 }
1002 1076
1003 fn board_effects(&self) -> Vec<Effect> { 1077 fn board_effects(&self) -> Vec<Effect> {
1078 if !self.is_in_game() {
1079 return Vec::new();
1080 }
1004 self.view().map(Effect::Render).into_iter().collect() 1081 self.view().map(Effect::Render).into_iter().collect()
1005 } 1082 }
1006 1083
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<String> {
16 View::Roulette(roulette) => RouletteGame::render_view(roulette), 16 View::Roulette(roulette) => RouletteGame::render_view(roulette),
17 View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack), 17 View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack),
18 View::Crash(crash) => CrashGame::render_view(crash), 18 View::Crash(crash) => CrashGame::render_view(crash),
19 // The terminal adapter keeps its command prompt for these screens.
20 View::Lobby(_) | View::History(_) => String::new(),
19 }; 21 };
20 rendered.lines().map(str::to_string).collect() 22 rendered.lines().map(str::to_string).collect()
21} 23}
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 {
241 } 241 }
242} 242}
243 243
244/// The lobby: who is logged in, the balance, and what can be played.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct LobbyView {
247 pub connected: bool,
248 pub user: Option<String>,
249 pub balance: Option<i64>,
250 pub games: Vec<(String, String)>,
251}
252
253/// The play log, already parsed.
254#[derive(Debug, Clone, PartialEq)]
255pub struct HistoryView {
256 pub records: Vec<crate::history::PlayRecord>,
257}
258
244/// Everything a frontend can draw. 259/// Everything a frontend can draw.
245#[derive(Debug, Clone, PartialEq)] 260#[derive(Debug, Clone, PartialEq)]
246pub enum View { 261pub enum View {
262 Lobby(LobbyView),
263 History(HistoryView),
247 Slots(SlotsView), 264 Slots(SlotsView),
248 Roulette(RouletteView), 265 Roulette(RouletteView),
249 Blackjack(BlackjackView), 266 Blackjack(BlackjackView),
@@ -253,6 +270,10 @@ pub enum View {
253/// Semantic input. Frontends turn keys and clicks into these. 270/// Semantic input. Frontends turn keys and clicks into these.
254#[derive(Debug, Clone, PartialEq, Eq)] 271#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum Command { 272pub enum Command {
273 Play { game: String, bet: Option<u64> },
274 History { limit: u32 },
275 ShowLobby,
276 Logout,
256 SetBet(u64), 277 SetBet(u64),
257 AdjustBet(i64), 278 AdjustBet(i64),
258 Leave, 279 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() {
1407 assert!((view.multiplier - 2.0).abs() < 1e-6); 1407 assert!((view.multiplier - 2.0).abs() < 1e-6);
1408 assert_eq!(view.potential, 20); 1408 assert_eq!(view.potential, 20);
1409} 1409}
1410
1411#[test]
1412fn the_lobby_view_lists_the_games_and_the_account() {
1413 let mut h = Harness::new();
1414 h.core.set_balance(1000);
1415
1416 let Some(View::Lobby(lobby)) = h.core.view() else {
1417 panic!("expected the lobby");
1418 };
1419 assert!(lobby.connected);
1420 assert_eq!(lobby.user.as_deref(), Some("alice"));
1421 assert_eq!(lobby.balance, Some(1000));
1422
1423 let games: Vec<&str> = lobby.games.iter().map(|(game, _)| game.as_str()).collect();
1424 assert_eq!(games, vec!["blackjack", "crash", "roulette", "slots"]);
1425}
1426
1427#[test]
1428fn a_play_command_opens_the_game_view() {
1429 let mut h = Harness::new();
1430
1431 let effects = h.core.command(Command::Play {
1432 game: "slots".to_string(),
1433 bet: Some(25),
1434 });
1435 h.apply(effects);
1436
1437 let Some(View::Slots(view)) = h.core.view() else {
1438 panic!("expected a slots view");
1439 };
1440 assert_eq!(view.bet, 25);
1441 assert!(h.sent.iter().any(|line| line == "INFO"));
1442 assert_eq!(h.board().len(), 10);
1443}
1444
1445#[test]
1446fn history_is_a_screen_and_keeps_its_records() {
1447 let mut h = Harness::new();
1448
1449 let effects = h.core.command(Command::History { limit: 5 });
1450 h.apply(effects);
1451 assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY 5"));
1452
1453 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}}]"#;
1454 h.reply(&format!("HISTORY {json}"));
1455
1456 let Some(View::History(view)) = h.core.view() else {
1457 panic!("expected the history screen");
1458 };
1459 assert_eq!(view.records.len(), 1);
1460 assert_eq!(view.records[0].game, "crash");
1461 assert_eq!(view.records[0].summary(), "cashed 1.13x");
1462
1463 // The terminal adapter still prints the table.
1464 assert!(h
1465 .screen
1466 .lines
1467 .iter()
1468 .any(|line| line.starts_with("Recent plays (1)")));
1469}
1470
1471#[test]
1472fn logging_out_and_showing_the_lobby_switch_the_screen() {
1473 let mut h = Harness::new();
1474 h.core.command(Command::History { limit: 5 });
1475 assert!(matches!(h.core.view(), Some(View::History(_))));
1476
1477 h.core.command(Command::ShowLobby);
1478 let Some(View::Lobby(lobby)) = h.core.view() else {
1479 panic!("expected the lobby");
1480 };
1481 assert_eq!(lobby.user.as_deref(), Some("alice"));
1482
1483 let effects = h.core.command(Command::Logout);
1484 h.apply(effects);
1485 let Some(View::Lobby(lobby)) = h.core.view() else {
1486 panic!("expected the lobby");
1487 };
1488 assert_eq!(lobby.user, None);
1489}