diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 23:06:22 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 23:06:22 +0100 |
| commit | 810e212b4b255914cfa73e85b52ec23db281c0e3 (patch) | |
| tree | 01e8035ce2945112af5ef41ef8959890bb0ef722 | |
| parent | v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migr... (diff) | |
| download | zagan-bet-810e212b4b255914cfa73e85b52ec23db281c0e3.tar.gz zagan-bet-810e212b4b255914cfa73e85b52ec23db281c0e3.zip | |
v3.1.0: warn on shrink, but let a live round finish
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/TESTING.md | 4 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 59 | ||||
| -rw-r--r-- | src/client/core.rs | 22 | ||||
| -rw-r--r-- | src/client/ui.rs | 10 | ||||
| -rw-r--r-- | tests/client_core.rs | 75 | ||||
| -rw-r--r-- | tests/client_ui.rs | 12 |
8 files changed, 182 insertions, 4 deletions
| @@ -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 |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "3.0.0" | 3 | version = "3.1.0" |
| 4 | edition = "2024" | 4 | edition = "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. |
| 97 | 2. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6) | 97 | 2. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6) |
| 98 | must fit without wrapping. | 98 | must fit without wrapping. |
| 99 | 3. While running, shrink the terminal below 80x24: a `WARNING:` line appears in | ||
| 100 | the transcript, or under the board when a game is open. | ||
| 101 | 4. 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}; | |||
| 17 | use zagan_bet::client::database; | 17 | use zagan_bet::client::database; |
| 18 | use zagan_bet::client::ui::{ | 18 | use 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 | }; |
| 22 | use zagan_bet::protocol::io_failure_message; | 22 | use zagan_bet::protocol::io_failure_message; |
| 23 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; | 23 | use 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 | ||
| 41 | impl ClientState { | 45 | impl 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. | ||
| 265 | pub 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] | ||
| 1065 | fn 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; | |||
| 2 | use zagan_bet::client::ui::{ | 2 | use 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] | ||
| 310 | fn 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 | } | ||
