From 2915d826f744612e0d975282f5bddf13270b9f3f Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:19:52 +0100 Subject: v1.1.0: highlight winning symbols on the slots board --- Cargo.toml | 2 +- docs/SLOTS.md | 26 +++++++++++++++-------- docs/TESTING.md | 5 +++-- src/bin/client/repl.rs | 31 +++++++++++++++++++++++++++- src/client/games/manager.rs | 11 ++++++++++ src/client/games/response.rs | 18 +++++++++++----- src/client/games/slots.rs | 29 +++++++++++++++++++++----- src/server/core.rs | 7 ++++++- src/server/games/slots.rs | 49 ++++++++++++++++++++++++++++++++++++-------- tests/client_core.rs | 16 +++++++++------ tests/server_core.rs | 2 +- tests/slots.rs | 43 +++++++++++++++++++++++++------------- 12 files changed, 186 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3e9d2dd..7de7c4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "1.0.0" +version = "1.1.0" edition = "2024" [dependencies] diff --git a/docs/SLOTS.md b/docs/SLOTS.md index ccc3b60..a64bdbf 100644 --- a/docs/SLOTS.md +++ b/docs/SLOTS.md @@ -38,21 +38,27 @@ Multipliers are applied to the total bet. ## Wire format -`GAME_RESULT` keeps its existing fields and appends the winning lines: +`GAME_RESULT` keeps its existing fields and appends the winning lines and cells: ``` -GAME_RESULT +GAME_RESULT ``` - `reels`: 15 characters, **column-major**: reel 0 top/middle/bottom, then reel 1, and so on. - `win_type`: `none`, `win`, `big` (>= 10x total bet) or `mega` (>= 50x). - `win_lines`: 1-based comma-separated line numbers, or `-` for no win. +- `win_cells`: 0-based column-major cell indices to highlight, or `-` for no + win. Example: `0,3,6` is the first three cells of the top row. - Older servers may still send a 3-character `reels` string; the client degrades it to the first row. +- Older 1.0 servers send only `win_lines`; the 1.1 client simply shows no + highlights against them. -The extra `win_lines` field is additive, but the 5x3 `reels` payload is a -breaking change for pre-5x3 clients. That is why the release is `1.0.0`. +The extra `win_lines`/`win_cells` fields are additive. The 5x3 `reels` payload +is a breaking change for pre-5x3 clients, hence `1.0.0`; highlighting is the +backward-compatible `1.1.0` release. The client never re-derives paylines: the +server sends exactly which cells won. ## Client board @@ -62,13 +68,15 @@ The board is nine lines: === SLOTS === Bet: 10 PLN Lines: 5 Balance: 1000 PLN [ C ] [ L ] [ O ] [ G ] [ D ] -[ S ] [ C ] [ L ] [ O ] [ G ] -[ D ] [ S ] [ C ] [ L ] [ O ] +[ S ] { C } { C } [ O ] [ G ] +[ D ] [ S ] { C } [ L ] [ O ] Won 20 PLN -Winning lines: 1, 3 +Winning lines: 2 [+/-] Bet [SPACE] Spin [Q] Quit ``` -`win_lines` is already carried through the client state so the next animation -pass can highlight the winning rows without recomputing the payout. +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 8fac851..8e35c5a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -38,8 +38,9 @@ Use an 80-column terminal because several output lines are width-sensitive. cursor stays visible. 5. `play slots 50` shows a 5x3 grid, `Lines: 5`, 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. The controls line is never - overwritten. Slots rules and RTP: `docs/SLOTS.md`. + new balance inside the same 9-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 appear. 8. `q` clears the board. Ctrl+C in a game clears the board and exits. diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 033fb1a..58cb541 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -240,8 +240,12 @@ impl ClientState { .map(|line| { if line.starts_with("===") { line.as_str().bold().cyan().to_string() - } else if line.contains("JACKPOT") || line.contains("Two of a kind") { + } else if line.contains('{') { + style_winning_cells(line) + } else if line.contains("WIN!") || line.starts_with("Won ") { line.as_str().bold().green().to_string() + } else if line.starts_with("Winning lines:") { + line.as_str().yellow().to_string() } else if line.starts_with("ERROR:") { line.as_str().red().to_string() } else { @@ -436,6 +440,31 @@ impl ClientState { } } +fn style_winning_cells(line: &str) -> String { + let chars: Vec = line.chars().collect(); + let mut out = String::new(); + let mut index = 0; + + while index < chars.len() { + if chars[index] == '{' { + let start = index; + while index < chars.len() && chars[index] != '}' { + index += 1; + } + if index < chars.len() { + index += 1; + } + let cell: String = chars[start..index].iter().collect(); + out.push_str(&cell.as_str().bold().yellow().to_string()); + } else { + out.push(chars[index]); + index += 1; + } + } + + out +} + fn map_key(key: crossterm::event::KeyEvent) -> Option { if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 8436142..168c49d 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -76,6 +76,7 @@ impl GameManager { session.data.remove("reels"); session.data.remove("win_type"); session.data.remove("win_lines"); + session.data.remove("win_cells"); session.data.remove("winnings"); Ok(()) } @@ -101,6 +102,15 @@ impl GameManager { .collect::>() .join(","), ); + session.data.insert( + "win_cells".to_string(), + result + .win_cells + .iter() + .map(usize::to_string) + .collect::>() + .join(","), + ); session .data .insert("winnings".to_string(), result.winnings.to_string()); @@ -118,6 +128,7 @@ impl GameManager { session.data.remove("reels"); session.data.remove("win_type"); session.data.remove("win_lines"); + session.data.remove("win_cells"); session.data.remove("winnings"); session.data.insert("error".to_string(), message.into()); } diff --git a/src/client/games/response.rs b/src/client/games/response.rs index de22a75..d7f9d45 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs @@ -7,6 +7,7 @@ pub struct SpinResult { pub reels: String, pub win_type: String, pub win_lines: Vec, + pub win_cells: Vec, } /// Parse one server reply to `PLAY_START`. @@ -52,11 +53,11 @@ pub fn parse_spin_response(response: &str) -> Result { let win_lines = parts .get(7) - .map(|raw| { - raw.split(',') - .filter_map(|value| value.trim().parse::().ok()) - .collect() - }) + .map(|raw| parse_indices(raw)) + .unwrap_or_default(); + let win_cells = parts + .get(8) + .map(|raw| parse_indices(raw)) .unwrap_or_default(); Ok(SpinResult { @@ -67,5 +68,12 @@ pub fn parse_spin_response(response: &str) -> Result { reels: parts[5].to_string(), win_type: parts[6].to_string(), win_lines, + win_cells, }) } + +fn parse_indices(raw: &str) -> Vec { + raw.split(',') + .filter_map(|value| value.trim().parse::().ok()) + .collect() +} diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 39c43b2..c367fcb 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -13,22 +13,40 @@ impl SlotsGame { Self } - fn cell(chars: &[char], row: usize, col: usize) -> char { - if chars.len() == REELS * ROWS { + fn cell(chars: &[char], row: usize, col: usize, winning: &[usize]) -> String { + let symbol = if chars.len() == REELS * ROWS { chars.get(col * ROWS + row).copied().unwrap_or('?') } else if chars.len() == 3 && row == 0 { // Legacy 3-symbol server: degrade to the first row. chars.get(col).copied().unwrap_or('?') } else { '?' + }; + + if winning.contains(&(col * ROWS + row)) { + format!("{{ {} }}", symbol) + } else { + format!("[ {} ]", symbol) } } - fn render_grid(&self, reels: &str) -> [String; ROWS] { + fn highlighted_cells(state: &GameState) -> Vec { + state + .data + .get("win_cells") + .map(|raw| { + raw.split(',') + .filter_map(|value| value.trim().parse::().ok()) + .collect() + }) + .unwrap_or_default() + } + + fn render_grid(&self, reels: &str, winning: &[usize]) -> [String; ROWS] { let chars: Vec = reels.chars().collect(); std::array::from_fn(|row| { (0..REELS) - .map(|col| format!("[ {} ]", Self::cell(&chars, row, col))) + .map(|col| Self::cell(&chars, row, col, winning)) .collect::>() .join(" ") }) @@ -84,7 +102,8 @@ impl Game for SlotsGame { .get("balance") .map(String::as_str) .unwrap_or("?"); - let grid = self.render_grid(&reels); + let winning = Self::highlighted_cells(state); + let grid = self.render_grid(&reels, &winning); format!( "=== SLOTS ===\r\nBet: {} PLN Lines: {} Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", diff --git a/src/server/core.rs b/src/server/core.rs index 33cafbf..350fe18 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -254,6 +254,11 @@ impl ServerCore { .get("win_lines") .cloned() .unwrap_or_else(|| "-".to_string()); + let win_cells = result + .result_data + .get("win_cells") + .cloned() + .unwrap_or_else(|| "-".to_string()); match database::apply_spin( &mut self.conn, @@ -268,7 +273,7 @@ impl ServerCore { Ok(new_balance) => { let _ = self.sessions.remove_session(&session_id); format!( - "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines}" + "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}" ) } Err(error) => { diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index 2ef1b46..1d71c9d 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs @@ -22,6 +22,13 @@ struct Payout { five: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LineWin { + pub line: usize, + /// Column-major cell indices of the matched symbols. + pub cells: Vec, +} + pub struct SlotsGame { payouts: HashMap, /// Deterministic grid for tests and demos, in column-major order. @@ -65,10 +72,11 @@ impl SlotsGame { .collect() } - /// Evaluate one spin. Returns the total win and the 1-based winning lines. - pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec) { + /// Evaluate one spin. Returns the total win and the winning lines with + /// the column-major cells that should be highlighted. + pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec) { let mut total = 0u64; - let mut winning_lines = Vec::new(); + let mut wins = Vec::new(); for (index, pattern) in PAYLINES.iter().enumerate() { let Some(&first) = grid.get(pattern[0]) else { @@ -99,10 +107,19 @@ impl SlotsGame { }; total = total.saturating_add(bet.saturating_mul(multiplier)); - winning_lines.push(index + 1); + let cells = pattern + .iter() + .enumerate() + .take(run) + .map(|(col, row)| col * ROWS + row) + .collect(); + wins.push(LineWin { + line: index + 1, + cells, + }); } - (total, winning_lines) + (total, wins) } /// Exact theoretical return to player for the current paytable. @@ -213,12 +230,27 @@ impl Game for SlotsGame { ))); } - let (winnings, winning_lines) = self.calculate_payout(&grid, state.bet_amount); + let (winnings, wins) = self.calculate_payout(&grid, state.bet_amount); let win_type = classify_win(winnings, state.bet_amount); - let win_lines = if winning_lines.is_empty() { + let win_lines = if wins.is_empty() { + "-".to_string() + } else { + wins.iter() + .map(|win| win.line.to_string()) + .collect::>() + .join(",") + }; + + let mut cells: Vec = wins + .iter() + .flat_map(|win| win.cells.iter().copied()) + .collect(); + cells.sort_unstable(); + cells.dedup(); + let win_cells = if cells.is_empty() { "-".to_string() } else { - winning_lines + cells .iter() .map(usize::to_string) .collect::>() @@ -229,6 +261,7 @@ impl Game for SlotsGame { result_data.insert("reels".to_string(), reel_string.clone()); result_data.insert("win_type".to_string(), win_type.to_string()); result_data.insert("win_lines".to_string(), win_lines); + result_data.insert("win_cells".to_string(), win_cells); Ok(GameResult { session_id: state.session_id.clone(), diff --git a/tests/client_core.rs b/tests/client_core.rs index 80ec587..91ddcab 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -155,12 +155,12 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() { 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"); + h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); assert_eq!(h.board().len(), 9); assert!(h .board() .get(2) - .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]"))); + .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()[1].contains("Balance: 1050 PLN")); @@ -173,7 +173,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { let mut h = Harness::new(); h.enter_slots("50"); h.key(Key::Char(' ')); - h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1"); + h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); let lines_before_bet = h.screen.lines.len(); h.key(Key::Char('+')); @@ -183,7 +183,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { assert!(h .board() .get(2) - .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]"))); + .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); assert_eq!(h.screen.lines.len(), lines_before_bet); assert_eq!( h.screen @@ -206,6 +206,7 @@ fn a_losing_spin_shows_no_win_and_no_winning_lines() { assert_eq!(h.board().len(), 9); assert!(h.board()[6].contains("No win.")); assert_eq!(h.board()[7], ""); + assert!(!h.board()[2].contains('{')); } #[test] @@ -214,11 +215,12 @@ fn a_mega_win_shows_all_winning_lines() { h.enter_slots("10"); h.key(Key::Char(' ')); - h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5")); + 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()[2].matches("{ C }").count(), 5); } #[test] @@ -517,12 +519,13 @@ fn disconnect_clears_connection_state() { #[test] fn parses_a_five_by_three_result_with_winning_lines() { - let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1"); + let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1 0,3,6"); let result = parse_spin_response(&response).expect("valid 5x3 result"); assert_eq!(result.reels.chars().count(), 15); assert_eq!(result.win_type, "win"); assert_eq!(result.win_lines, vec![1]); + assert_eq!(result.win_cells, vec![0, 3, 6]); } #[test] @@ -533,6 +536,7 @@ fn accepts_a_legacy_three_symbol_result() { assert_eq!(result.reels, "OSS"); assert_eq!(result.win_type, "two"); assert!(result.win_lines.is_empty()); + assert!(result.win_cells.is_empty()); } #[test] diff --git a/tests/server_core.rs b/tests/server_core.rs index f3736e9..5c3c316 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -189,7 +189,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() { let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); assert!(response.contains(" 20 10 1010 "), "response: {response}"); - assert!(response.ends_with(" win 1"), "response: {response}"); + assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); assert_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); diff --git a/tests/slots.rs b/tests/slots.rs index 5fb751a..857e607 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::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; +use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; /// Convert row-major test rows into the column-major wire format. @@ -15,13 +15,17 @@ fn grid(rows: [&str; 3]) -> String { result } -fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec) { +fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec) { let reels = grid(rows); let game = SlotsGame::with_grid(&reels); let cells: Vec = reels.chars().collect(); game.calculate_payout(&cells, bet) } +fn lines(wins: &[LineWin]) -> Vec { + wins.iter().map(|win| win.line).collect() +} + #[test] fn the_wire_grid_is_five_reels_by_three_rows() { assert_eq!(REELS, 5); @@ -37,50 +41,60 @@ fn the_wire_grid_is_five_reels_by_three_rows() { #[test] fn three_of_a_kind_on_the_top_line_pays() { - let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); + let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); - assert_eq!(lines, vec![1]); + assert_eq!(lines(&wins), vec![1]); + assert_eq!(wins[0].cells, vec![0, 3, 6]); } #[test] fn four_of_a_kind_pays_more_than_three() { - let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); + let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); assert_eq!(winnings, 50, "CCCC pays 5x total bet"); - assert_eq!(lines, vec![1]); + assert_eq!(lines(&wins), vec![1]); + assert_eq!(wins[0].cells, vec![0, 3, 6, 9]); } #[test] fn five_of_a_kind_pays_all_lines() { - let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); + let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); - assert_eq!(lines, vec![1, 2, 3, 4, 5]); + assert_eq!(lines(&wins), vec![1, 2, 3, 4, 5]); + assert_eq!(wins[0].cells, vec![0, 3, 6, 9, 12]); + assert_eq!(wins[3].cells, vec![0, 4, 8, 10, 12]); + assert_eq!(wins[4].cells, vec![2, 4, 6, 10, 14]); } #[test] fn the_v_payline_is_evaluated() { - let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10); + let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10); assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); - assert_eq!(lines, vec![4]); + assert_eq!(lines(&wins), vec![4]); + assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]); } #[test] fn no_matching_line_pays_nothing() { - let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); + let (winnings, wins) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); assert_eq!(winnings, 0); - assert!(lines.is_empty()); + assert!(wins.is_empty()); } #[test] fn winning_lines_are_added_together() { - let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); + let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); assert_eq!(winnings, (150 + 20 + 25) * 10); - assert_eq!(lines, vec![1, 2, 3]); + assert_eq!(lines(&wins), vec![1, 2, 3]); + + let mut cells: Vec = wins.iter().flat_map(|win| win.cells.clone()).collect(); + cells.sort_unstable(); + assert_eq!(cells, (0..15).collect::>()); } #[test] @@ -111,6 +125,7 @@ fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() { assert_eq!(result.winnings, 20); assert_eq!(result.result_data.get("win_type").unwrap(), "win"); assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); + assert_eq!(result.result_data.get("win_cells").unwrap(), "0,3,6"); } #[test] -- cgit v1.2.3