summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 23:31:54 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 23:31:54 +0100
commit7d18a268547734daf19a740b6533e9c1697baf1a (patch)
tree789f519a19b053b8ad0ff41d2828fe22c06fb21a
parentv1.2.0: show the symbol paytable on the slots board (diff)
downloadzagan-bet-7d18a268547734daf19a740b6533e9c1697baf1a.tar.gz
zagan-bet-7d18a268547734daf19a740b6533e9c1697baf1a.zip
v1.2.1: track wrapped board lines and split the paytable
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SLOTS.md17
-rw-r--r--docs/TESTING.md2
-rw-r--r--src/bin/client/repl.rs8
-rw-r--r--src/client/games/slots.rs10
-rw-r--r--src/client/ui.rs17
-rw-r--r--src/slots.rs18
-rw-r--r--tests/client_core.rs46
-rw-r--r--tests/client_ui.rs19
-rw-r--r--tests/slots.rs14
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 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "1.2.0" 3version = "1.2.1"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[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.
62 62
63## Client board 63## Client board
64 64
65The board is nine lines: 65The board is ten lines:
66 66
67``` 67```
68=== SLOTS === 68=== SLOTS ===
@@ -70,15 +70,16 @@ Bet: 10 PLN Balance: 1000 PLN
70[ C ] [ L ] [ O ] [ G ] [ D ] 70[ C ] [ L ] [ O ] [ G ] [ D ]
71[ S ] { C } { C } [ O ] [ G ] 71[ S ] { C } { C } [ O ] [ G ]
72[ D ] [ S ] { C } [ L ] [ O ] 72[ D ] [ S ] { C } [ L ] [ O ]
73Pays 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 73Pays 3/4/5 x bet: C 2/5/15 L 3/7/20 O 3/8/25
74G 4/12/40 D 6/18/70 S 9/30/150
74Won 20 PLN 75Won 20 PLN
75Winning lines: 2 76Winning lines: 2
76[+/-] Bet [SPACE] Spin [Q] Quit 77[+/-] Bet [SPACE] Spin [Q] Quit
77``` 78```
78 79
79The line above the status is the compact paytable: for each symbol, the 80The two lines above the status are the compact paytable: for each symbol, the
80multipliers of the total bet for 3, 4 and 5 of a kind. Winning cells use 81multipliers of the total bet for 3, 4 and 5 of a kind. It is split so it does
81braces and are rendered yellow/bold in the terminal. The line list below the 82not wrap on a narrow terminal. Winning cells use braces and are rendered
82status names the paylines that won. Both come from the server's `win_lines` 83yellow/bold in the terminal. The line list below the status names the paylines
83and `win_cells`, which are already carried through the client state for the 84that won. Both come from the server's `win_lines` and `win_cells`, which are
84animation pass. 85already 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.
395. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and 395. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and
40 the fetched balance. 40 the fetched balance.
416. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and 416. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and
42 new balance inside the same 9-line board. Winning symbols appear as 42 new balance inside the same 10-line board. Winning symbols appear as
43 yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls 43 yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls
44 line is never overwritten. Slots rules and RTP: `docs/SLOTS.md`. 44 line is never overwritten. Slots rules and RTP: `docs/SLOTS.md`.
457. `+` / `-` after a spin changes only the bet; no stale grid or result lines 457. `+` / `-` 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};
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}; 21};
21use zagan_bet::protocol::io_failure_message; 22use zagan_bet::protocol::io_failure_message;
22 23
@@ -29,7 +30,7 @@ pub struct ClientState {
29 pub default_port: u16, 30 pub default_port: u16,
30 core: ClientCore, 31 core: ClientCore,
31 cleanup_done: Arc<Mutex<bool>>, 32 cleanup_done: Arc<Mutex<bool>>,
32 /// Number of terminal lines currently occupied by the game board. 33 /// Number of visual terminal lines currently occupied by the game board.
33 game_lines: usize, 34 game_lines: usize,
34 /// Text last drawn after the `> ` prompt, used to keep the command echo 35 /// Text last drawn after the `> ` prompt, used to keep the command echo
35 /// in the transcript before printing command output. 36 /// in the transcript before printing command output.
@@ -255,7 +256,10 @@ impl ClientState {
255 .collect(); 256 .collect();
256 print!("{}", rendered.join("\r\n")); 257 print!("{}", rendered.join("\r\n"));
257 stdout().flush().unwrap(); 258 stdout().flush().unwrap();
258 self.game_lines = lines.len().max(1); 259 let width = crossterm::terminal::size()
260 .map(|(columns, _)| columns as usize)
261 .unwrap_or(80);
262 self.game_lines = visual_line_count(lines, width).max(1);
259 } 263 }
260 264
261 fn clear_region(&mut self) { 265 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 @@
1use super::traits::{Game, GamePhase, GameState}; 1use super::traits::{Game, GamePhase, GameState};
2use crate::slots::{paytable_line, REELS, ROWS}; 2use crate::slots::{paytable_lines, REELS, ROWS};
3 3
4/// Height of the game board. It must never depend on the spin result. 4/// Height of the game board. It must never depend on the spin result.
5pub const SLOT_BOARD_LINES: usize = 9; 5pub const SLOT_BOARD_LINES: usize = 10;
6 6
7pub struct SlotsGame; 7pub struct SlotsGame;
8 8
@@ -102,15 +102,17 @@ impl Game for SlotsGame {
102 .unwrap_or("?"); 102 .unwrap_or("?");
103 let winning = Self::highlighted_cells(state); 103 let winning = Self::highlighted_cells(state);
104 let grid = self.render_grid(&reels, &winning); 104 let grid = self.render_grid(&reels, &winning);
105 let paytable = paytable_lines();
105 106
106 format!( 107 format!(
107 "=== 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", 108 "=== 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",
108 state.bet_amount, 109 state.bet_amount,
109 balance, 110 balance,
110 grid[0], 111 grid[0],
111 grid[1], 112 grid[1],
112 grid[2], 113 grid[2],
113 paytable_line(), 114 paytable[0],
115 paytable[1],
114 self.render_status(state), 116 self.render_status(state),
115 self.render_win_lines(state) 117 self.render_win_lines(state)
116 ) 118 )
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 {
257 .min(u16::MAX as usize) as u16 257 .min(u16::MAX as usize) as u16
258} 258}
259 259
260/// Number of terminal rows a block of logical lines occupies after wrapping.
261/// Empty lines still occupy one row.
262pub fn visual_line_count(lines: &[String], width: usize) -> usize {
263 let width = width.max(1);
264 lines
265 .iter()
266 .map(|line| {
267 let columns = display_width(line);
268 if columns == 0 {
269 1
270 } else {
271 columns.div_ceil(width)
272 }
273 })
274 .sum()
275}
276
260/// Format a ping round-trip time with one decimal place. 277/// Format a ping round-trip time with one decimal place.
261pub fn format_pong(elapsed: std::time::Duration) -> String { 278pub fn format_pong(elapsed: std::time::Duration) -> String {
262 format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0) 279 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] = [
23 ('S', 9, 30, 150), 23 ('S', 9, 30, 150),
24]; 24];
25 25
26/// One-line paytable rendered under the reels. 26/// Paytable rendered under the reels, split so it fits a narrow terminal.
27pub fn paytable_line() -> String { 27pub fn paytable_lines() -> Vec<String> {
28 let entries: Vec<String> = PAYTABLE 28 let entries: Vec<String> = PAYTABLE
29 .iter() 29 .iter()
30 .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}")) 30 .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}"))
31 .collect(); 31 .collect();
32 format!("Pays 3/4/5 x bet: {}", entries.join(" ")) 32
33 entries
34 .chunks(3)
35 .enumerate()
36 .map(|(index, chunk)| {
37 let prefix = if index == 0 {
38 "Pays 3/4/5 x bet: "
39 } else {
40 ""
41 };
42 format!("{}{}", prefix, chunk.join(" "))
43 })
44 .collect()
33} 45}
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 {
128} 128}
129 129
130#[test] 130#[test]
131fn play_shows_a_nine_line_board_and_fetches_the_balance() { 131fn play_shows_a_ten_line_board_and_fetches_the_balance() {
132 let mut h = Harness::new(); 132 let mut h = Harness::new();
133 h.line("play slots 50"); 133 h.line("play slots 50");
134 134
135 assert_eq!(h.board().len(), 9); 135 assert_eq!(h.board().len(), 10);
136 assert!(h.board()[1].contains("Bet: 50 PLN")); 136 assert!(h.board()[1].contains("Bet: 50 PLN"));
137 assert!(!h.board()[1].contains("Lines:")); 137 assert!(!h.board()[1].contains("Lines:"));
138 assert!(h.board()[5].contains("Pays 3/4/5 x bet:")); 138 assert!(h.board()[5].contains("Pays 3/4/5 x bet:"));
139 assert!(h.board()[5].contains("S 9/30/150")); 139 assert!(h.board()[6].contains("G 4/12/40 D 6/18/70 S 9/30/150"));
140 assert!(h.sent.contains(&"INFO".to_string())); 140 assert!(h.sent.contains(&"INFO".to_string()));
141 141
142 h.reply("INFO alice 1 1000"); 142 h.reply("INFO alice 1 1000");
143 assert_eq!(h.board().len(), 9); 143 assert_eq!(h.board().len(), 10);
144 assert!(h.board()[1].contains("Balance: 1000 PLN")); 144 assert!(h.board()[1].contains("Balance: 1000 PLN"));
145} 145}
146 146
@@ -151,22 +151,22 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() {
151 let lines_before_spin = h.screen.lines.len(); 151 let lines_before_spin = h.screen.lines.len();
152 152
153 h.key(Key::Char(' ')); 153 h.key(Key::Char(' '));
154 assert_eq!(h.board().len(), 9); 154 assert_eq!(h.board().len(), 10);
155 assert!(h.board()[6].contains("Spinning...")); 155 assert!(h.board()[7].contains("Spinning..."));
156 assert!(h.board()[8].contains("[Q] Quit")); 156 assert!(h.board()[9].contains("[Q] Quit"));
157 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); 157 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50"));
158 assert_eq!(h.screen.lines.len(), lines_before_spin); 158 assert_eq!(h.screen.lines.len(), lines_before_spin);
159 159
160 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); 160 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6");
161 assert_eq!(h.board().len(), 9); 161 assert_eq!(h.board().len(), 10);
162 assert!(h 162 assert!(h
163 .board() 163 .board()
164 .get(2) 164 .get(2)
165 .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); 165 .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]")));
166 assert!(h.board()[6].contains("Won 100 PLN")); 166 assert!(h.board()[7].contains("Won 100 PLN"));
167 assert!(h.board()[7].contains("Winning lines: 1")); 167 assert!(h.board()[8].contains("Winning lines: 1"));
168 assert!(h.board()[1].contains("Balance: 1050 PLN")); 168 assert!(h.board()[1].contains("Balance: 1050 PLN"));
169 assert!(h.board()[8].contains("[Q] Quit")); 169 assert!(h.board()[9].contains("[Q] Quit"));
170 assert_eq!(h.screen.lines.len(), lines_before_spin); 170 assert_eq!(h.screen.lines.len(), lines_before_spin);
171} 171}
172 172
@@ -180,7 +180,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
180 180
181 h.key(Key::Char('+')); 181 h.key(Key::Char('+'));
182 182
183 assert_eq!(h.board().len(), 9); 183 assert_eq!(h.board().len(), 10);
184 assert!(h.board()[1].contains("Bet: 60 PLN")); 184 assert!(h.board()[1].contains("Bet: 60 PLN"));
185 assert!(h 185 assert!(h
186 .board() 186 .board()
@@ -205,9 +205,9 @@ fn a_losing_spin_shows_no_win_and_no_winning_lines() {
205 205
206 h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -")); 206 h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -"));
207 207
208 assert_eq!(h.board().len(), 9); 208 assert_eq!(h.board().len(), 10);
209 assert!(h.board()[6].contains("No win.")); 209 assert!(h.board()[7].contains("No win."));
210 assert_eq!(h.board()[7], ""); 210 assert_eq!(h.board()[8], "");
211 assert!(!h.board()[2].contains('{')); 211 assert!(!h.board()[2].contains('{'));
212} 212}
213 213
@@ -219,9 +219,9 @@ fn a_mega_win_shows_all_winning_lines() {
219 219
220 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")); 220 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"));
221 221
222 assert_eq!(h.board().len(), 9); 222 assert_eq!(h.board().len(), 10);
223 assert!(h.board()[6].contains("MEGA WIN! 750 PLN")); 223 assert!(h.board()[7].contains("MEGA WIN! 750 PLN"));
224 assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5")); 224 assert!(h.board()[8].contains("Winning lines: 1,2,3,4,5"));
225 assert_eq!(h.board()[2].matches("{ C }").count(), 5); 225 assert_eq!(h.board()[2].matches("{ C }").count(), 5);
226} 226}
227 227
@@ -245,8 +245,8 @@ fn a_server_error_replaces_the_status_line() {
245 245
246 h.reply("ERROR Insufficient balance"); 246 h.reply("ERROR Insufficient balance");
247 247
248 assert_eq!(h.board().len(), 9); 248 assert_eq!(h.board().len(), 10);
249 assert!(h.board()[6].contains("ERROR: Insufficient balance")); 249 assert!(h.board()[7].contains("ERROR: Insufficient balance"));
250 assert!(h.board()[2].contains("[ ? ]")); 250 assert!(h.board()[2].contains("[ ? ]"));
251 assert!(!h.board().iter().any(|line| line.contains("Spinning"))); 251 assert!(!h.board().iter().any(|line| line.contains("Spinning")));
252} 252}
@@ -259,8 +259,8 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() {
259 259
260 h.reply(""); 260 h.reply("");
261 261
262 assert_eq!(h.board().len(), 9); 262 assert_eq!(h.board().len(), 10);
263 assert!(h.board()[6].contains("No response from server")); 263 assert!(h.board()[7].contains("No response from server"));
264} 264}
265 265
266#[test] 266#[test]
@@ -344,7 +344,7 @@ fn play_without_a_bet_uses_the_default() {
344 let mut h = Harness::new(); 344 let mut h = Harness::new();
345 h.line("play slots"); 345 h.line("play slots");
346 346
347 assert_eq!(h.board().len(), 9); 347 assert_eq!(h.board().len(), 10);
348 assert!(h.board()[1].contains("Bet: 10 PLN")); 348 assert!(h.board()[1].contains("Bet: 10 PLN"));
349} 349}
350 350
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 @@
1use zagan_bet::client::ui::{ 1use zagan_bet::client::ui::{
2 command_names, completion_lines, display_width, first_contact_lines, format_pong, 2 command_names, completion_lines, display_width, first_contact_lines, format_pong,
3 help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, 3 help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history,
4 visual_line_count,
4}; 5};
5use std::time::Duration; 6use std::time::Duration;
6 7
@@ -98,6 +99,24 @@ fn redraw_moves_up_by_line_count_minus_one() {
98} 99}
99 100
100#[test] 101#[test]
102fn visual_line_count_accounts_for_terminal_wrapping() {
103 let lines = vec![
104 "12345".to_string(),
105 "123456".to_string(),
106 String::new(),
107 ];
108
109 assert_eq!(visual_line_count(&lines, 5), 4); // 1 + 2 + 1
110 assert_eq!(visual_line_count(&lines, 6), 3); // 1 + 1 + 1
111 assert_eq!(visual_line_count(&lines, 80), 3);
112 assert_eq!(visual_line_count(&[], 80), 0);
113
114 let wide = "x".repeat(75);
115 assert_eq!(visual_line_count(&[wide.clone()], 75), 1);
116 assert_eq!(visual_line_count(&[wide], 72), 2);
117}
118
119#[test]
101fn input_cursor_is_after_the_removed_character() { 120fn input_cursor_is_after_the_removed_character() {
102 assert_eq!(input_cursor_column(10, 8), 18); 121 assert_eq!(input_cursor_column(10, 8), 18);
103 assert_eq!(input_cursor_column(10, 0), 10); 122 assert_eq!(input_cursor_column(10, 0), 10);
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 @@
1//! Regression tests for the server-side 5x3 slots game. 1//! Regression tests for the server-side 5x3 slots game.
2 2
3use zagan_bet::slots::{paytable_line, PAYTABLE}; 3use zagan_bet::slots::{paytable_lines, PAYTABLE};
4use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; 4use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS};
5use zagan_bet::server::games::traits::{Game, GamePhase}; 5use zagan_bet::server::games::traits::{Game, GamePhase};
6 6
@@ -108,16 +108,20 @@ fn theoretical_rtp_is_just_under_one() {
108} 108}
109 109
110#[test] 110#[test]
111fn the_paytable_line_lists_every_symbol_and_fits_80_columns() { 111fn the_paytable_lines_list_every_symbol_and_fit_80_columns() {
112 let line = paytable_line(); 112 let lines = paytable_lines();
113 let joined = lines.join(" ");
113 114
115 assert_eq!(lines.len(), 2, "paytable should be split into two lines");
114 for (symbol, three, four, five) in PAYTABLE { 116 for (symbol, three, four, five) in PAYTABLE {
115 assert!( 117 assert!(
116 line.contains(&format!("{symbol} {three}/{four}/{five}")), 118 joined.contains(&format!("{symbol} {three}/{four}/{five}")),
117 "paytable is missing {symbol}" 119 "paytable is missing {symbol}"
118 ); 120 );
119 } 121 }
120 assert!(line.len() <= 80, "paytable is too wide: {line:?}"); 122 for line in lines {
123 assert!(line.len() <= 80, "paytable is too wide: {line:?}");
124 }
121} 125}
122 126
123#[test] 127#[test]