summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 23:06:22 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 23:06:22 +0100
commit810e212b4b255914cfa73e85b52ec23db281c0e3 (patch)
tree01e8035ce2945112af5ef41ef8959890bb0ef722
parentv3.0.0: cursor-driven roulette felt with splits and corners; drop schema migr... (diff)
downloadzagan-bet-810e212b4b255914cfa73e85b52ec23db281c0e3.tar.gz
zagan-bet-810e212b4b255914cfa73e85b52ec23db281c0e3.zip
v3.1.0: warn on shrink, but let a live round finish
-rw-r--r--AGENTS.md2
-rw-r--r--Cargo.toml2
-rw-r--r--docs/TESTING.md4
-rw-r--r--src/bin/client/repl.rs59
-rw-r--r--src/client/core.rs22
-rw-r--r--src/client/ui.rs10
-rw-r--r--tests/client_core.rs75
-rw-r--r--tests/client_ui.rs12
8 files changed, 182 insertions, 4 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 8de7a2d..ede8d67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,6 +59,8 @@
59- The client refuses to start below 80x24 (`terminal_too_small`). Every game 59- The client refuses to start below 80x24 (`terminal_too_small`). Every game
60 board must fit in that size; a new board wider or taller than the minimum 60 board must fit in that size; a new board wider or taller than the minimum
61 must raise the minimum in the same change. 61 must raise the minimum in the same change.
62- A resize below the minimum warns, but never during a live round
63 (`ClientCore::is_live_game`): a reserved bet must be finishable.
62 64
63## Schema 65## Schema
64- The app is in alpha: do not write schema migrations. `init_db` creates the 66- The app is in alpha: do not write schema migrations. `init_db` creates the
diff --git a/Cargo.toml b/Cargo.toml
index 69df089..f212238 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "3.0.0" 3version = "3.1.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 7ec5b60..0c931d8 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -96,6 +96,10 @@ Use an 80-column terminal because several output lines are width-sensitive.
96 switching to the alternate screen or raw mode. 96 switching to the alternate screen or raw mode.
972. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6) 972. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6)
98 must fit without wrapping. 98 must fit without wrapping.
993. While running, shrink the terminal below 80x24: a `WARNING:` line appears in
100 the transcript, or under the board when a game is open.
1014. Shrink during a live blackjack hand or a running crash round: nothing
102 interrupts the round. The warning appears once the round settles.
99 103
100## Manual blackjack checklist 104## Manual blackjack checklist
101 105
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 2cf121d..bf02c8d 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -17,7 +17,7 @@ use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
17use zagan_bet::client::database; 17use zagan_bet::client::database;
18use zagan_bet::client::ui::{ 18use zagan_bet::client::ui::{
19 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, 19 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up,
20 visual_line_count, 20 terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS,
21}; 21};
22use zagan_bet::protocol::io_failure_message; 22use zagan_bet::protocol::io_failure_message;
23use zagan_bet::roulette::{WHEEL, color as roulette_color}; 23use zagan_bet::roulette::{WHEEL, color as roulette_color};
@@ -36,6 +36,10 @@ pub struct ClientState {
36 /// Text last drawn after the `> ` prompt, used to keep the command echo 36 /// Text last drawn after the `> ` prompt, used to keep the command echo
37 /// in the transcript before printing command output. 37 /// in the transcript before printing command output.
38 prompt_text: String, 38 prompt_text: String,
39 /// Last known terminal size, updated from resize events.
40 terminal_size: (u16, u16),
41 /// Size we already warned about, so a resize does not spam the transcript.
42 size_warned: Option<(u16, u16)>,
39} 43}
40 44
41impl ClientState { 45impl ClientState {
@@ -60,6 +64,8 @@ impl ClientState {
60 cleanup_done: Arc::new(Mutex::new(false)), 64 cleanup_done: Arc::new(Mutex::new(false)),
61 game_lines: 0, 65 game_lines: 0,
62 prompt_text: String::new(), 66 prompt_text: String::new(),
67 terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)),
68 size_warned: None,
63 } 69 }
64 } 70 }
65 71
@@ -114,7 +120,15 @@ impl ClientState {
114 } 120 }
115 } 121 }
116 } 122 }
117 Event::Resize(_, _) => self.apply_prompt(), 123 Event::Resize(columns, rows) => {
124 self.terminal_size = (columns, rows);
125 if self.core.is_in_game() {
126 let effects = self.core.refresh_board();
127 self.apply_effects(effects);
128 } else {
129 self.apply_prompt();
130 }
131 }
118 _ => {} 132 _ => {}
119 } 133 }
120 } 134 }
@@ -130,6 +144,10 @@ impl ClientState {
130 } 144 }
131 } 145 }
132 146
147 if !quit {
148 quit = self.warn_about_size();
149 }
150
133 if quit { 151 if quit {
134 break; 152 break;
135 } 153 }
@@ -292,7 +310,42 @@ impl ClientState {
292 self.prompt_text.clear(); 310 self.prompt_text.clear();
293 } 311 }
294 312
313 /// Warn once per size when the terminal is too small and no round is live.
314 /// Inside a game the warning is appended to the board instead, and during a
315 /// live round it is suppressed until the round settles.
316 fn warn_about_size(&mut self) -> bool {
317 let size = self.terminal_size;
318 let Some(message) = terminal_size_warning(size.0, size.1) else {
319 self.size_warned = None;
320 return false;
321 };
322 if self.core.is_in_game() || self.size_warned == Some(size) {
323 return false;
324 }
325 self.size_warned = Some(size);
326 self.apply_effects(vec![Effect::Print(vec![message])]);
327 false
328 }
329
295 fn draw_region(&mut self, lines: &[String]) { 330 fn draw_region(&mut self, lines: &[String]) {
331 let warning = if self.core.is_live_game() {
332 None
333 } else {
334 terminal_size_warning(self.terminal_size.0, self.terminal_size.1)
335 };
336 let owned;
337 let lines: &[String] = match warning {
338 Some(warning) => {
339 owned = {
340 let mut lines = lines.to_vec();
341 lines.push(warning);
342 lines
343 };
344 &owned
345 }
346 None => lines,
347 };
348
296 if self.game_lines == 0 { 349 if self.game_lines == 0 {
297 self.commit_prompt_line(); 350 self.commit_prompt_line();
298 } else { 351 } else {
@@ -308,6 +361,8 @@ impl ClientState {
308 .map(|line| { 361 .map(|line| {
309 if line.starts_with("===") { 362 if line.starts_with("===") {
310 line.as_str().bold().cyan().to_string() 363 line.as_str().bold().cyan().to_string()
364 } else if line.starts_with("WARNING:") {
365 line.as_str().bold().red().to_string()
311 } else if roulette { 366 } else if roulette {
312 if line.starts_with("Won ") { 367 if line.starts_with("Won ") {
313 line.as_str().bold().green().to_string() 368 line.as_str().bold().green().to_string()
diff --git a/src/client/core.rs b/src/client/core.rs
index 8411d47..95fa050 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -138,6 +138,28 @@ impl ClientCore {
138 self.game.is_in_game() 138 self.game.is_in_game()
139 } 139 }
140 140
141 /// True while a round is running and leaving would forfeit a reserved bet.
142 /// The terminal may shrink during one of these without interrupting it.
143 pub fn is_live_game(&self) -> bool {
144 match self
145 .game
146 .get_current_state()
147 .map(|state| state.game_type.as_str())
148 {
149 Some("blackjack") => self
150 .game
151 .blackjack_state()
152 .is_some_and(|hand| hand.phase == "player"),
153 Some("crash") => self.crash_started.is_some(),
154 _ => false,
155 }
156 }
157
158 /// Redraw the current board, for terminal resizes.
159 pub fn refresh_board(&self) -> Vec<Effect> {
160 self.board_effects()
161 }
162
141 pub fn prompt_effect(&self) -> Option<Effect> { 163 pub fn prompt_effect(&self) -> Option<Effect> {
142 if self.is_in_game() { 164 if self.is_in_game() {
143 return None; 165 return None;
diff --git a/src/client/ui.rs b/src/client/ui.rs
index 6161166..ca001c2 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -261,6 +261,16 @@ pub fn terminal_too_small(columns: u16, rows: u16) -> Option<String> {
261 )) 261 ))
262} 262}
263 263
264/// One-line warning for a terminal that shrank while the client is running.
265pub fn terminal_size_warning(columns: u16, rows: u16) -> Option<String> {
266 if columns >= MIN_COLUMNS && rows >= MIN_ROWS {
267 return None;
268 }
269 Some(format!(
270 "WARNING: terminal is {columns}x{rows}; zagan.bet needs at least {MIN_COLUMNS}x{MIN_ROWS}. Resize to fit."
271 ))
272}
273
264/// Render remembered hosts. 274/// Render remembered hosts.
265/// 275///
266/// Two addresses that present the same certificate key are the same server, so 276/// Two addresses that present the same certificate key are the same server, so
diff --git a/tests/client_core.rs b/tests/client_core.rs
index dea6808..a7c5716 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -1060,3 +1060,78 @@ fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() {
1060 assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); 1060 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
1061 assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); 1061 assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost")));
1062} 1062}
1063
1064#[test]
1065fn a_round_is_live_only_while_money_is_reserved() {
1066 let mut h = Harness::new();
1067
1068 // Placed chips are not a live round: nothing is debited until the spin.
1069 h.enter_roulette();
1070 h.key(Key::Enter);
1071 assert!(h.board()[9].contains("1 10"));
1072 assert!(!h.core.is_live_game());
1073 h.key(Key::Char('q'));
1074
1075 // Blackjack is live while the player owes an action.
1076 h.enter_blackjack();
1077 assert!(!h.core.is_live_game());
1078 h.key(Key::Char(' '));
1079 assert!(!h.core.is_live_game(), "not live until the server deals");
1080 let player = bj_state(
1081 "bj1",
1082 "player",
1083 &["10S", "6H"],
1084 &["9D", "?"],
1085 16,
1086 9,
1087 10,
1088 10,
1089 990,
1090 &["hit", "stand"],
1091 "playing",
1092 0,
1093 );
1094 h.reply(&format!(
1095 "BLACKJACK_STATE {}",
1096 serde_json::to_string(&player).unwrap()
1097 ));
1098 assert!(h.core.is_live_game());
1099
1100 h.key(Key::Char('s'));
1101 assert_eq!(
1102 h.sent.last().map(String::as_str),
1103 Some("BLACKJACK_ACTION bj1 stand")
1104 );
1105 let done = bj_state(
1106 "bj1",
1107 "done",
1108 &["10S", "6H"],
1109 &["9D", "8C"],
1110 16,
1111 17,
1112 10,
1113 10,
1114 990,
1115 &[],
1116 "lose",
1117 0,
1118 );
1119 h.reply(&format!(
1120 "BLACKJACK_STATE {}",
1121 serde_json::to_string(&done).unwrap()
1122 ));
1123 assert!(!h.core.is_live_game());
1124 h.key(Key::Char('q'));
1125
1126 // Crash is live while the multiplier runs.
1127 h.enter_crash();
1128 h.key(Key::Char(' '));
1129 assert!(!h.core.is_live_game(), "not live until the round starts");
1130 let running = crash_state("running", 1.0, None, 0, 990);
1131 h.reply(&crash_line("CRASH_START", &running));
1132 assert!(h.core.is_live_game());
1133
1134 let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990);
1135 h.push(&crash_line("CRASH_BUSTED", &busted));
1136 assert!(!h.core.is_live_game());
1137}
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index 7969139..6e00903 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -2,7 +2,7 @@ use std::time::Duration;
2use zagan_bet::client::ui::{ 2use zagan_bet::client::ui::{
3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines, 3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines,
4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, 4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history,
5 terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS, 5 terminal_size_warning, terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS,
6}; 6};
7 7
8#[test] 8#[test]
@@ -305,3 +305,13 @@ fn the_roulette_felt_fits_the_minimum_terminal() {
305 "the board must leave room for the prompt and a few transcript lines" 305 "the board must leave room for the prompt and a few transcript lines"
306 ); 306 );
307} 307}
308
309#[test]
310fn a_shrunk_terminal_gets_a_one_line_warning() {
311 assert!(terminal_size_warning(80, 24).is_none());
312 let warning = terminal_size_warning(60, 20).unwrap();
313 assert!(warning.starts_with("WARNING:"));
314 assert!(warning.contains("60x20"));
315 assert!(warning.contains("80x24"));
316 assert!(!warning.contains('\n'), "the board warning must be one line");
317}