From 7d18a268547734daf19a740b6533e9c1697baf1a Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:31:54 +0100 Subject: v1.2.1: track wrapped board lines and split the paytable --- Cargo.toml | 2 +- docs/SLOTS.md | 17 +++++++++-------- docs/TESTING.md | 2 +- src/bin/client/repl.rs | 8 ++++++-- src/client/games/slots.rs | 10 ++++++---- src/client/ui.rs | 17 +++++++++++++++++ src/slots.rs | 18 +++++++++++++++--- tests/client_core.rs | 46 +++++++++++++++++++++++----------------------- tests/client_ui.rs | 19 +++++++++++++++++++ tests/slots.rs | 14 +++++++++----- 10 files changed, 106 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 85c7e16..700ccec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "1.2.0" +version = "1.2.1" edition = "2024" [dependencies] diff --git a/docs/SLOTS.md b/docs/SLOTS.md index a2e778d..b8c670b 100644 --- a/docs/SLOTS.md +++ b/docs/SLOTS.md @@ -62,7 +62,7 @@ server sends exactly which cells won. ## Client board -The board is nine lines: +The board is ten lines: ``` === SLOTS === @@ -70,15 +70,16 @@ Bet: 10 PLN Balance: 1000 PLN [ C ] [ L ] [ O ] [ G ] [ D ] [ S ] { C } { C } [ O ] [ G ] [ D ] [ S ] { C } [ L ] [ O ] -Pays 3/4/5 x bet: C 2/5/15 L 3/7/20 O 3/8/25 G 4/12/40 D 6/18/70 S 9/30/150 +Pays 3/4/5 x bet: C 2/5/15 L 3/7/20 O 3/8/25 +G 4/12/40 D 6/18/70 S 9/30/150 Won 20 PLN Winning lines: 2 [+/-] Bet [SPACE] Spin [Q] Quit ``` -The line above the status is the compact paytable: for each symbol, the -multipliers of the total bet for 3, 4 and 5 of a kind. Winning cells use -braces and are rendered yellow/bold in the terminal. The line list below the -status names the paylines that won. Both come from the server's `win_lines` -and `win_cells`, which are already carried through the client state for the -animation pass. +The two lines above the status are the compact paytable: for each symbol, the +multipliers of the total bet for 3, 4 and 5 of a kind. It is split so it does +not wrap on a narrow terminal. Winning cells use braces and are rendered +yellow/bold in the terminal. The line list below the status names the paylines +that won. Both come from the server's `win_lines` and `win_cells`, which are +already carried through the client state for the animation pass. diff --git a/docs/TESTING.md b/docs/TESTING.md index 7f54798..84d98e7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -39,7 +39,7 @@ Use an 80-column terminal because several output lines are width-sensitive. 5. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and the fetched balance. 6. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and - new balance inside the same 9-line board. Winning symbols appear as + new balance inside the same 10-line board. Winning symbols appear as yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls line is never overwritten. Slots rules and RTP: `docs/SLOTS.md`. 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 58cb541..1ed3073 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -17,6 +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, }; use zagan_bet::protocol::io_failure_message; @@ -29,7 +30,7 @@ pub struct ClientState { pub default_port: u16, core: ClientCore, cleanup_done: Arc>, - /// Number of terminal lines currently occupied by the game board. + /// Number of visual terminal lines currently occupied by the game board. game_lines: usize, /// Text last drawn after the `> ` prompt, used to keep the command echo /// in the transcript before printing command output. @@ -255,7 +256,10 @@ impl ClientState { .collect(); print!("{}", rendered.join("\r\n")); stdout().flush().unwrap(); - self.game_lines = lines.len().max(1); + let width = crossterm::terminal::size() + .map(|(columns, _)| columns as usize) + .unwrap_or(80); + self.game_lines = visual_line_count(lines, width).max(1); } fn clear_region(&mut self) { diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 7e59919..a417e7d 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,8 +1,8 @@ use super::traits::{Game, GamePhase, GameState}; -use crate::slots::{paytable_line, REELS, ROWS}; +use crate::slots::{paytable_lines, REELS, ROWS}; /// Height of the game board. It must never depend on the spin result. -pub const SLOT_BOARD_LINES: usize = 9; +pub const SLOT_BOARD_LINES: usize = 10; pub struct SlotsGame; @@ -102,15 +102,17 @@ impl Game for SlotsGame { .unwrap_or("?"); let winning = Self::highlighted_cells(state); let grid = self.render_grid(&reels, &winning); + let paytable = paytable_lines(); format!( - "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", + "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", state.bet_amount, balance, grid[0], grid[1], grid[2], - paytable_line(), + paytable[0], + paytable[1], self.render_status(state), self.render_win_lines(state) ) diff --git a/src/client/ui.rs b/src/client/ui.rs index 493f660..11cedc7 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -257,6 +257,23 @@ pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { .min(u16::MAX as usize) as u16 } +/// Number of terminal rows a block of logical lines occupies after wrapping. +/// Empty lines still occupy one row. +pub fn visual_line_count(lines: &[String], width: usize) -> usize { + let width = width.max(1); + lines + .iter() + .map(|line| { + let columns = display_width(line); + if columns == 0 { + 1 + } else { + columns.div_ceil(width) + } + }) + .sum() +} + /// Format a ping round-trip time with one decimal place. pub fn format_pong(elapsed: std::time::Duration) -> String { format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0) diff --git a/src/slots.rs b/src/slots.rs index 561c449..a3b4ef4 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -23,11 +23,23 @@ pub const PAYTABLE: [(char, u64, u64, u64); 6] = [ ('S', 9, 30, 150), ]; -/// One-line paytable rendered under the reels. -pub fn paytable_line() -> String { +/// Paytable rendered under the reels, split so it fits a narrow terminal. +pub fn paytable_lines() -> Vec { let entries: Vec = PAYTABLE .iter() .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}")) .collect(); - format!("Pays 3/4/5 x bet: {}", entries.join(" ")) + + entries + .chunks(3) + .enumerate() + .map(|(index, chunk)| { + let prefix = if index == 0 { + "Pays 3/4/5 x bet: " + } else { + "" + }; + format!("{}{}", prefix, chunk.join(" ")) + }) + .collect() } diff --git a/tests/client_core.rs b/tests/client_core.rs index ea99d52..e75a611 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -128,19 +128,19 @@ impl Harness { } #[test] -fn play_shows_a_nine_line_board_and_fetches_the_balance() { +fn play_shows_a_ten_line_board_and_fetches_the_balance() { let mut h = Harness::new(); h.line("play slots 50"); - assert_eq!(h.board().len(), 9); + assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 50 PLN")); assert!(!h.board()[1].contains("Lines:")); assert!(h.board()[5].contains("Pays 3/4/5 x bet:")); - assert!(h.board()[5].contains("S 9/30/150")); + assert!(h.board()[6].contains("G 4/12/40 D 6/18/70 S 9/30/150")); assert!(h.sent.contains(&"INFO".to_string())); h.reply("INFO alice 1 1000"); - assert_eq!(h.board().len(), 9); + assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Balance: 1000 PLN")); } @@ -151,22 +151,22 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() { let lines_before_spin = h.screen.lines.len(); h.key(Key::Char(' ')); - assert_eq!(h.board().len(), 9); - assert!(h.board()[6].contains("Spinning...")); - assert!(h.board()[8].contains("[Q] Quit")); + assert_eq!(h.board().len(), 10); + assert!(h.board()[7].contains("Spinning...")); + assert!(h.board()[9].contains("[Q] Quit")); assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); assert_eq!(h.screen.lines.len(), lines_before_spin); h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); - assert_eq!(h.board().len(), 9); + assert_eq!(h.board().len(), 10); assert!(h .board() .get(2) .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); - assert!(h.board()[6].contains("Won 100 PLN")); - assert!(h.board()[7].contains("Winning lines: 1")); + assert!(h.board()[7].contains("Won 100 PLN")); + assert!(h.board()[8].contains("Winning lines: 1")); assert!(h.board()[1].contains("Balance: 1050 PLN")); - assert!(h.board()[8].contains("[Q] Quit")); + assert!(h.board()[9].contains("[Q] Quit")); assert_eq!(h.screen.lines.len(), lines_before_spin); } @@ -180,7 +180,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { h.key(Key::Char('+')); - assert_eq!(h.board().len(), 9); + assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 60 PLN")); assert!(h .board() @@ -205,9 +205,9 @@ fn a_losing_spin_shows_no_win_and_no_winning_lines() { h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -")); - assert_eq!(h.board().len(), 9); - assert!(h.board()[6].contains("No win.")); - assert_eq!(h.board()[7], ""); + assert_eq!(h.board().len(), 10); + assert!(h.board()[7].contains("No win.")); + assert_eq!(h.board()[8], ""); assert!(!h.board()[2].contains('{')); } @@ -219,9 +219,9 @@ fn a_mega_win_shows_all_winning_lines() { h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14")); - assert_eq!(h.board().len(), 9); - assert!(h.board()[6].contains("MEGA WIN! 750 PLN")); - assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5")); + assert_eq!(h.board().len(), 10); + assert!(h.board()[7].contains("MEGA WIN! 750 PLN")); + assert!(h.board()[8].contains("Winning lines: 1,2,3,4,5")); assert_eq!(h.board()[2].matches("{ C }").count(), 5); } @@ -245,8 +245,8 @@ fn a_server_error_replaces_the_status_line() { h.reply("ERROR Insufficient balance"); - assert_eq!(h.board().len(), 9); - assert!(h.board()[6].contains("ERROR: Insufficient balance")); + assert_eq!(h.board().len(), 10); + assert!(h.board()[7].contains("ERROR: Insufficient balance")); assert!(h.board()[2].contains("[ ? ]")); assert!(!h.board().iter().any(|line| line.contains("Spinning"))); } @@ -259,8 +259,8 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() { h.reply(""); - assert_eq!(h.board().len(), 9); - assert!(h.board()[6].contains("No response from server")); + assert_eq!(h.board().len(), 10); + assert!(h.board()[7].contains("No response from server")); } #[test] @@ -344,7 +344,7 @@ fn play_without_a_bet_uses_the_default() { let mut h = Harness::new(); h.line("play slots"); - assert_eq!(h.board().len(), 9); + assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 10 PLN")); } diff --git a/tests/client_ui.rs b/tests/client_ui.rs index a22fbe7..ea432e0 100644 --- a/tests/client_ui.rs +++ b/tests/client_ui.rs @@ -1,6 +1,7 @@ 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, + visual_line_count, }; use std::time::Duration; @@ -97,6 +98,24 @@ fn redraw_moves_up_by_line_count_minus_one() { assert_eq!(lines_to_move_up(70_000), u16::MAX); } +#[test] +fn visual_line_count_accounts_for_terminal_wrapping() { + let lines = vec![ + "12345".to_string(), + "123456".to_string(), + String::new(), + ]; + + assert_eq!(visual_line_count(&lines, 5), 4); // 1 + 2 + 1 + assert_eq!(visual_line_count(&lines, 6), 3); // 1 + 1 + 1 + assert_eq!(visual_line_count(&lines, 80), 3); + assert_eq!(visual_line_count(&[], 80), 0); + + let wide = "x".repeat(75); + assert_eq!(visual_line_count(&[wide.clone()], 75), 1); + assert_eq!(visual_line_count(&[wide], 72), 2); +} + #[test] fn input_cursor_is_after_the_removed_character() { assert_eq!(input_cursor_column(10, 8), 18); diff --git a/tests/slots.rs b/tests/slots.rs index b7292b8..bdf8f10 100644 --- a/tests/slots.rs +++ b/tests/slots.rs @@ -1,6 +1,6 @@ //! Regression tests for the server-side 5x3 slots game. -use zagan_bet::slots::{paytable_line, PAYTABLE}; +use zagan_bet::slots::{paytable_lines, PAYTABLE}; use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; @@ -108,16 +108,20 @@ fn theoretical_rtp_is_just_under_one() { } #[test] -fn the_paytable_line_lists_every_symbol_and_fits_80_columns() { - let line = paytable_line(); +fn the_paytable_lines_list_every_symbol_and_fit_80_columns() { + let lines = paytable_lines(); + let joined = lines.join(" "); + assert_eq!(lines.len(), 2, "paytable should be split into two lines"); for (symbol, three, four, five) in PAYTABLE { assert!( - line.contains(&format!("{symbol} {three}/{four}/{five}")), + joined.contains(&format!("{symbol} {three}/{four}/{five}")), "paytable is missing {symbol}" ); } - assert!(line.len() <= 80, "paytable is too wide: {line:?}"); + for line in lines { + assert!(line.len() <= 80, "paytable is too wide: {line:?}"); + } } #[test] -- cgit v1.2.3