From 810e212b4b255914cfa73e85b52ec23db281c0e3 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 23:06:22 +0100 Subject: v3.1.0: warn on shrink, but let a live round finish --- AGENTS.md | 2 ++ Cargo.toml | 2 +- docs/TESTING.md | 4 +++ src/bin/client/repl.rs | 59 +++++++++++++++++++++++++++++++++++++-- src/client/core.rs | 22 +++++++++++++++ src/client/ui.rs | 10 +++++++ tests/client_core.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/client_ui.rs | 12 +++++++- 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 @@ - The client refuses to start below 80x24 (`terminal_too_small`). Every game board must fit in that size; a new board wider or taller than the minimum must raise the minimum in the same change. +- A resize below the minimum warns, but never during a live round + (`ClientCore::is_live_game`): a reserved bet must be finishable. ## Schema - 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 @@ [package] name = "zagan-bet" -version = "3.0.0" +version = "3.1.0" edition = "2024" [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. switching to the alternate screen or raw mode. 2. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6) must fit without wrapping. +3. While running, shrink the terminal below 80x24: a `WARNING:` line appears in + the transcript, or under the board when a game is open. +4. Shrink during a live blackjack hand or a running crash round: nothing + interrupts the round. The warning appears once the round settles. ## Manual blackjack checklist 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}; use zagan_bet::client::database; use zagan_bet::client::ui::{ display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, - visual_line_count, + terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; use zagan_bet::protocol::io_failure_message; use zagan_bet::roulette::{WHEEL, color as roulette_color}; @@ -36,6 +36,10 @@ pub struct ClientState { /// Text last drawn after the `> ` prompt, used to keep the command echo /// in the transcript before printing command output. prompt_text: String, + /// Last known terminal size, updated from resize events. + terminal_size: (u16, u16), + /// Size we already warned about, so a resize does not spam the transcript. + size_warned: Option<(u16, u16)>, } impl ClientState { @@ -60,6 +64,8 @@ impl ClientState { cleanup_done: Arc::new(Mutex::new(false)), game_lines: 0, prompt_text: String::new(), + terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), + size_warned: None, } } @@ -114,7 +120,15 @@ impl ClientState { } } } - Event::Resize(_, _) => self.apply_prompt(), + Event::Resize(columns, rows) => { + self.terminal_size = (columns, rows); + if self.core.is_in_game() { + let effects = self.core.refresh_board(); + self.apply_effects(effects); + } else { + self.apply_prompt(); + } + } _ => {} } } @@ -130,6 +144,10 @@ impl ClientState { } } + if !quit { + quit = self.warn_about_size(); + } + if quit { break; } @@ -292,7 +310,42 @@ impl ClientState { self.prompt_text.clear(); } + /// Warn once per size when the terminal is too small and no round is live. + /// Inside a game the warning is appended to the board instead, and during a + /// live round it is suppressed until the round settles. + fn warn_about_size(&mut self) -> bool { + let size = self.terminal_size; + let Some(message) = terminal_size_warning(size.0, size.1) else { + self.size_warned = None; + return false; + }; + if self.core.is_in_game() || self.size_warned == Some(size) { + return false; + } + self.size_warned = Some(size); + self.apply_effects(vec![Effect::Print(vec![message])]); + false + } + fn draw_region(&mut self, lines: &[String]) { + let warning = if self.core.is_live_game() { + None + } else { + terminal_size_warning(self.terminal_size.0, self.terminal_size.1) + }; + let owned; + let lines: &[String] = match warning { + Some(warning) => { + owned = { + let mut lines = lines.to_vec(); + lines.push(warning); + lines + }; + &owned + } + None => lines, + }; + if self.game_lines == 0 { self.commit_prompt_line(); } else { @@ -308,6 +361,8 @@ impl ClientState { .map(|line| { if line.starts_with("===") { line.as_str().bold().cyan().to_string() + } else if line.starts_with("WARNING:") { + line.as_str().bold().red().to_string() } else if roulette { if line.starts_with("Won ") { 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 { self.game.is_in_game() } + /// True while a round is running and leaving would forfeit a reserved bet. + /// The terminal may shrink during one of these without interrupting it. + pub fn is_live_game(&self) -> bool { + match self + .game + .get_current_state() + .map(|state| state.game_type.as_str()) + { + Some("blackjack") => self + .game + .blackjack_state() + .is_some_and(|hand| hand.phase == "player"), + Some("crash") => self.crash_started.is_some(), + _ => false, + } + } + + /// Redraw the current board, for terminal resizes. + pub fn refresh_board(&self) -> Vec { + self.board_effects() + } + pub fn prompt_effect(&self) -> Option { if self.is_in_game() { 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 { )) } +/// One-line warning for a terminal that shrank while the client is running. +pub fn terminal_size_warning(columns: u16, rows: u16) -> Option { + if columns >= MIN_COLUMNS && rows >= MIN_ROWS { + return None; + } + Some(format!( + "WARNING: terminal is {columns}x{rows}; zagan.bet needs at least {MIN_COLUMNS}x{MIN_ROWS}. Resize to fit." + )) +} + /// Render remembered hosts. /// /// 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() { assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); } + +#[test] +fn a_round_is_live_only_while_money_is_reserved() { + let mut h = Harness::new(); + + // Placed chips are not a live round: nothing is debited until the spin. + h.enter_roulette(); + h.key(Key::Enter); + assert!(h.board()[9].contains("1 10")); + assert!(!h.core.is_live_game()); + h.key(Key::Char('q')); + + // Blackjack is live while the player owes an action. + h.enter_blackjack(); + assert!(!h.core.is_live_game()); + h.key(Key::Char(' ')); + assert!(!h.core.is_live_game(), "not live until the server deals"); + let player = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand"], + "playing", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&player).unwrap() + )); + assert!(h.core.is_live_game()); + + h.key(Key::Char('s')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("BLACKJACK_ACTION bj1 stand") + ); + let done = bj_state( + "bj1", + "done", + &["10S", "6H"], + &["9D", "8C"], + 16, + 17, + 10, + 10, + 990, + &[], + "lose", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&done).unwrap() + )); + assert!(!h.core.is_live_game()); + h.key(Key::Char('q')); + + // Crash is live while the multiplier runs. + h.enter_crash(); + h.key(Key::Char(' ')); + assert!(!h.core.is_live_game(), "not live until the round starts"); + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + assert!(h.core.is_live_game()); + + let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); + h.push(&crash_line("CRASH_BUSTED", &busted)); + assert!(!h.core.is_live_game()); +} 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; use zagan_bet::client::ui::{ command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, - terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS, + terminal_size_warning, terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; #[test] @@ -305,3 +305,13 @@ fn the_roulette_felt_fits_the_minimum_terminal() { "the board must leave room for the prompt and a few transcript lines" ); } + +#[test] +fn a_shrunk_terminal_gets_a_one_line_warning() { + assert!(terminal_size_warning(80, 24).is_none()); + let warning = terminal_size_warning(60, 20).unwrap(); + assert!(warning.starts_with("WARNING:")); + assert!(warning.contains("60x20")); + assert!(warning.contains("80x24")); + assert!(!warning.contains('\n'), "the board warning must be one line"); +} -- cgit v1.2.3