diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:19:52 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:19:52 +0100 |
| commit | 2915d826f744612e0d975282f5bddf13270b9f3f (patch) | |
| tree | a2864bfaef91070ac3b4e13b8b731e539a757cc8 /src | |
| parent | v1.0.0: replace slots with 5x3 reels and five paylines (diff) | |
| download | zagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.tar.gz zagan-bet-2915d826f744612e0d975282f5bddf13270b9f3f.zip | |
v1.1.0: highlight winning symbols on the slots board
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/client/repl.rs | 31 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 11 | ||||
| -rw-r--r-- | src/client/games/response.rs | 18 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 29 | ||||
| -rw-r--r-- | src/server/core.rs | 7 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 49 |
6 files changed, 125 insertions, 20 deletions
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 { | |||
| 240 | .map(|line| { | 240 | .map(|line| { |
| 241 | if line.starts_with("===") { | 241 | if line.starts_with("===") { |
| 242 | line.as_str().bold().cyan().to_string() | 242 | line.as_str().bold().cyan().to_string() |
| 243 | } else if line.contains("JACKPOT") || line.contains("Two of a kind") { | 243 | } else if line.contains('{') { |
| 244 | style_winning_cells(line) | ||
| 245 | } else if line.contains("WIN!") || line.starts_with("Won ") { | ||
| 244 | line.as_str().bold().green().to_string() | 246 | line.as_str().bold().green().to_string() |
| 247 | } else if line.starts_with("Winning lines:") { | ||
| 248 | line.as_str().yellow().to_string() | ||
| 245 | } else if line.starts_with("ERROR:") { | 249 | } else if line.starts_with("ERROR:") { |
| 246 | line.as_str().red().to_string() | 250 | line.as_str().red().to_string() |
| 247 | } else { | 251 | } else { |
| @@ -436,6 +440,31 @@ impl ClientState { | |||
| 436 | } | 440 | } |
| 437 | } | 441 | } |
| 438 | 442 | ||
| 443 | fn style_winning_cells(line: &str) -> String { | ||
| 444 | let chars: Vec<char> = line.chars().collect(); | ||
| 445 | let mut out = String::new(); | ||
| 446 | let mut index = 0; | ||
| 447 | |||
| 448 | while index < chars.len() { | ||
| 449 | if chars[index] == '{' { | ||
| 450 | let start = index; | ||
| 451 | while index < chars.len() && chars[index] != '}' { | ||
| 452 | index += 1; | ||
| 453 | } | ||
| 454 | if index < chars.len() { | ||
| 455 | index += 1; | ||
| 456 | } | ||
| 457 | let cell: String = chars[start..index].iter().collect(); | ||
| 458 | out.push_str(&cell.as_str().bold().yellow().to_string()); | ||
| 459 | } else { | ||
| 460 | out.push(chars[index]); | ||
| 461 | index += 1; | ||
| 462 | } | ||
| 463 | } | ||
| 464 | |||
| 465 | out | ||
| 466 | } | ||
| 467 | |||
| 439 | fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> { | 468 | fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> { |
| 440 | if key.modifiers.contains(KeyModifiers::CONTROL) { | 469 | if key.modifiers.contains(KeyModifiers::CONTROL) { |
| 441 | match key.code { | 470 | 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 { | |||
| 76 | session.data.remove("reels"); | 76 | session.data.remove("reels"); |
| 77 | session.data.remove("win_type"); | 77 | session.data.remove("win_type"); |
| 78 | session.data.remove("win_lines"); | 78 | session.data.remove("win_lines"); |
| 79 | session.data.remove("win_cells"); | ||
| 79 | session.data.remove("winnings"); | 80 | session.data.remove("winnings"); |
| 80 | Ok(()) | 81 | Ok(()) |
| 81 | } | 82 | } |
| @@ -101,6 +102,15 @@ impl GameManager { | |||
| 101 | .collect::<Vec<_>>() | 102 | .collect::<Vec<_>>() |
| 102 | .join(","), | 103 | .join(","), |
| 103 | ); | 104 | ); |
| 105 | session.data.insert( | ||
| 106 | "win_cells".to_string(), | ||
| 107 | result | ||
| 108 | .win_cells | ||
| 109 | .iter() | ||
| 110 | .map(usize::to_string) | ||
| 111 | .collect::<Vec<_>>() | ||
| 112 | .join(","), | ||
| 113 | ); | ||
| 104 | session | 114 | session |
| 105 | .data | 115 | .data |
| 106 | .insert("winnings".to_string(), result.winnings.to_string()); | 116 | .insert("winnings".to_string(), result.winnings.to_string()); |
| @@ -118,6 +128,7 @@ impl GameManager { | |||
| 118 | session.data.remove("reels"); | 128 | session.data.remove("reels"); |
| 119 | session.data.remove("win_type"); | 129 | session.data.remove("win_type"); |
| 120 | session.data.remove("win_lines"); | 130 | session.data.remove("win_lines"); |
| 131 | session.data.remove("win_cells"); | ||
| 121 | session.data.remove("winnings"); | 132 | session.data.remove("winnings"); |
| 122 | session.data.insert("error".to_string(), message.into()); | 133 | session.data.insert("error".to_string(), message.into()); |
| 123 | } | 134 | } |
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 { | |||
| 7 | pub reels: String, | 7 | pub reels: String, |
| 8 | pub win_type: String, | 8 | pub win_type: String, |
| 9 | pub win_lines: Vec<usize>, | 9 | pub win_lines: Vec<usize>, |
| 10 | pub win_cells: Vec<usize>, | ||
| 10 | } | 11 | } |
| 11 | 12 | ||
| 12 | /// Parse one server reply to `PLAY_START`. | 13 | /// Parse one server reply to `PLAY_START`. |
| @@ -52,11 +53,11 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> { | |||
| 52 | 53 | ||
| 53 | let win_lines = parts | 54 | let win_lines = parts |
| 54 | .get(7) | 55 | .get(7) |
| 55 | .map(|raw| { | 56 | .map(|raw| parse_indices(raw)) |
| 56 | raw.split(',') | 57 | .unwrap_or_default(); |
| 57 | .filter_map(|value| value.trim().parse::<usize>().ok()) | 58 | let win_cells = parts |
| 58 | .collect() | 59 | .get(8) |
| 59 | }) | 60 | .map(|raw| parse_indices(raw)) |
| 60 | .unwrap_or_default(); | 61 | .unwrap_or_default(); |
| 61 | 62 | ||
| 62 | Ok(SpinResult { | 63 | Ok(SpinResult { |
| @@ -67,5 +68,12 @@ pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> { | |||
| 67 | reels: parts[5].to_string(), | 68 | reels: parts[5].to_string(), |
| 68 | win_type: parts[6].to_string(), | 69 | win_type: parts[6].to_string(), |
| 69 | win_lines, | 70 | win_lines, |
| 71 | win_cells, | ||
| 70 | }) | 72 | }) |
| 71 | } | 73 | } |
| 74 | |||
| 75 | fn parse_indices(raw: &str) -> Vec<usize> { | ||
| 76 | raw.split(',') | ||
| 77 | .filter_map(|value| value.trim().parse::<usize>().ok()) | ||
| 78 | .collect() | ||
| 79 | } | ||
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 { | |||
| 13 | Self | 13 | Self |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | fn cell(chars: &[char], row: usize, col: usize) -> char { | 16 | fn cell(chars: &[char], row: usize, col: usize, winning: &[usize]) -> String { |
| 17 | if chars.len() == REELS * ROWS { | 17 | let symbol = if chars.len() == REELS * ROWS { |
| 18 | chars.get(col * ROWS + row).copied().unwrap_or('?') | 18 | chars.get(col * ROWS + row).copied().unwrap_or('?') |
| 19 | } else if chars.len() == 3 && row == 0 { | 19 | } else if chars.len() == 3 && row == 0 { |
| 20 | // Legacy 3-symbol server: degrade to the first row. | 20 | // Legacy 3-symbol server: degrade to the first row. |
| 21 | chars.get(col).copied().unwrap_or('?') | 21 | chars.get(col).copied().unwrap_or('?') |
| 22 | } else { | 22 | } else { |
| 23 | '?' | 23 | '?' |
| 24 | }; | ||
| 25 | |||
| 26 | if winning.contains(&(col * ROWS + row)) { | ||
| 27 | format!("{{ {} }}", symbol) | ||
| 28 | } else { | ||
| 29 | format!("[ {} ]", symbol) | ||
| 24 | } | 30 | } |
| 25 | } | 31 | } |
| 26 | 32 | ||
| 27 | fn render_grid(&self, reels: &str) -> [String; ROWS] { | 33 | fn highlighted_cells(state: &GameState) -> Vec<usize> { |
| 34 | state | ||
| 35 | .data | ||
| 36 | .get("win_cells") | ||
| 37 | .map(|raw| { | ||
| 38 | raw.split(',') | ||
| 39 | .filter_map(|value| value.trim().parse::<usize>().ok()) | ||
| 40 | .collect() | ||
| 41 | }) | ||
| 42 | .unwrap_or_default() | ||
| 43 | } | ||
| 44 | |||
| 45 | fn render_grid(&self, reels: &str, winning: &[usize]) -> [String; ROWS] { | ||
| 28 | let chars: Vec<char> = reels.chars().collect(); | 46 | let chars: Vec<char> = reels.chars().collect(); |
| 29 | std::array::from_fn(|row| { | 47 | std::array::from_fn(|row| { |
| 30 | (0..REELS) | 48 | (0..REELS) |
| 31 | .map(|col| format!("[ {} ]", Self::cell(&chars, row, col))) | 49 | .map(|col| Self::cell(&chars, row, col, winning)) |
| 32 | .collect::<Vec<_>>() | 50 | .collect::<Vec<_>>() |
| 33 | .join(" ") | 51 | .join(" ") |
| 34 | }) | 52 | }) |
| @@ -84,7 +102,8 @@ impl Game for SlotsGame { | |||
| 84 | .get("balance") | 102 | .get("balance") |
| 85 | .map(String::as_str) | 103 | .map(String::as_str) |
| 86 | .unwrap_or("?"); | 104 | .unwrap_or("?"); |
| 87 | let grid = self.render_grid(&reels); | 105 | let winning = Self::highlighted_cells(state); |
| 106 | let grid = self.render_grid(&reels, &winning); | ||
| 88 | 107 | ||
| 89 | format!( | 108 | format!( |
| 90 | "=== 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", | 109 | "=== 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 { | |||
| 254 | .get("win_lines") | 254 | .get("win_lines") |
| 255 | .cloned() | 255 | .cloned() |
| 256 | .unwrap_or_else(|| "-".to_string()); | 256 | .unwrap_or_else(|| "-".to_string()); |
| 257 | let win_cells = result | ||
| 258 | .result_data | ||
| 259 | .get("win_cells") | ||
| 260 | .cloned() | ||
| 261 | .unwrap_or_else(|| "-".to_string()); | ||
| 257 | 262 | ||
| 258 | match database::apply_spin( | 263 | match database::apply_spin( |
| 259 | &mut self.conn, | 264 | &mut self.conn, |
| @@ -268,7 +273,7 @@ impl ServerCore { | |||
| 268 | Ok(new_balance) => { | 273 | Ok(new_balance) => { |
| 269 | let _ = self.sessions.remove_session(&session_id); | 274 | let _ = self.sessions.remove_session(&session_id); |
| 270 | format!( | 275 | format!( |
| 271 | "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines}" | 276 | "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines} {win_cells}" |
| 272 | ) | 277 | ) |
| 273 | } | 278 | } |
| 274 | Err(error) => { | 279 | 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 { | |||
| 22 | five: u64, | 22 | five: u64, |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 26 | pub struct LineWin { | ||
| 27 | pub line: usize, | ||
| 28 | /// Column-major cell indices of the matched symbols. | ||
| 29 | pub cells: Vec<usize>, | ||
| 30 | } | ||
| 31 | |||
| 25 | pub struct SlotsGame { | 32 | pub struct SlotsGame { |
| 26 | payouts: HashMap<char, Payout>, | 33 | payouts: HashMap<char, Payout>, |
| 27 | /// Deterministic grid for tests and demos, in column-major order. | 34 | /// Deterministic grid for tests and demos, in column-major order. |
| @@ -65,10 +72,11 @@ impl SlotsGame { | |||
| 65 | .collect() | 72 | .collect() |
| 66 | } | 73 | } |
| 67 | 74 | ||
| 68 | /// Evaluate one spin. Returns the total win and the 1-based winning lines. | 75 | /// Evaluate one spin. Returns the total win and the winning lines with |
| 69 | pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec<usize>) { | 76 | /// the column-major cells that should be highlighted. |
| 77 | pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec<LineWin>) { | ||
| 70 | let mut total = 0u64; | 78 | let mut total = 0u64; |
| 71 | let mut winning_lines = Vec::new(); | 79 | let mut wins = Vec::new(); |
| 72 | 80 | ||
| 73 | for (index, pattern) in PAYLINES.iter().enumerate() { | 81 | for (index, pattern) in PAYLINES.iter().enumerate() { |
| 74 | let Some(&first) = grid.get(pattern[0]) else { | 82 | let Some(&first) = grid.get(pattern[0]) else { |
| @@ -99,10 +107,19 @@ impl SlotsGame { | |||
| 99 | }; | 107 | }; |
| 100 | 108 | ||
| 101 | total = total.saturating_add(bet.saturating_mul(multiplier)); | 109 | total = total.saturating_add(bet.saturating_mul(multiplier)); |
| 102 | winning_lines.push(index + 1); | 110 | let cells = pattern |
| 111 | .iter() | ||
| 112 | .enumerate() | ||
| 113 | .take(run) | ||
| 114 | .map(|(col, row)| col * ROWS + row) | ||
| 115 | .collect(); | ||
| 116 | wins.push(LineWin { | ||
| 117 | line: index + 1, | ||
| 118 | cells, | ||
| 119 | }); | ||
| 103 | } | 120 | } |
| 104 | 121 | ||
| 105 | (total, winning_lines) | 122 | (total, wins) |
| 106 | } | 123 | } |
| 107 | 124 | ||
| 108 | /// Exact theoretical return to player for the current paytable. | 125 | /// Exact theoretical return to player for the current paytable. |
| @@ -213,12 +230,27 @@ impl Game for SlotsGame { | |||
| 213 | ))); | 230 | ))); |
| 214 | } | 231 | } |
| 215 | 232 | ||
| 216 | let (winnings, winning_lines) = self.calculate_payout(&grid, state.bet_amount); | 233 | let (winnings, wins) = self.calculate_payout(&grid, state.bet_amount); |
| 217 | let win_type = classify_win(winnings, state.bet_amount); | 234 | let win_type = classify_win(winnings, state.bet_amount); |
| 218 | let win_lines = if winning_lines.is_empty() { | 235 | let win_lines = if wins.is_empty() { |
| 236 | "-".to_string() | ||
| 237 | } else { | ||
| 238 | wins.iter() | ||
| 239 | .map(|win| win.line.to_string()) | ||
| 240 | .collect::<Vec<_>>() | ||
| 241 | .join(",") | ||
| 242 | }; | ||
| 243 | |||
| 244 | let mut cells: Vec<usize> = wins | ||
| 245 | .iter() | ||
| 246 | .flat_map(|win| win.cells.iter().copied()) | ||
| 247 | .collect(); | ||
| 248 | cells.sort_unstable(); | ||
| 249 | cells.dedup(); | ||
| 250 | let win_cells = if cells.is_empty() { | ||
| 219 | "-".to_string() | 251 | "-".to_string() |
| 220 | } else { | 252 | } else { |
| 221 | winning_lines | 253 | cells |
| 222 | .iter() | 254 | .iter() |
| 223 | .map(usize::to_string) | 255 | .map(usize::to_string) |
| 224 | .collect::<Vec<_>>() | 256 | .collect::<Vec<_>>() |
| @@ -229,6 +261,7 @@ impl Game for SlotsGame { | |||
| 229 | result_data.insert("reels".to_string(), reel_string.clone()); | 261 | result_data.insert("reels".to_string(), reel_string.clone()); |
| 230 | result_data.insert("win_type".to_string(), win_type.to_string()); | 262 | result_data.insert("win_type".to_string(), win_type.to_string()); |
| 231 | result_data.insert("win_lines".to_string(), win_lines); | 263 | result_data.insert("win_lines".to_string(), win_lines); |
| 264 | result_data.insert("win_cells".to_string(), win_cells); | ||
| 232 | 265 | ||
| 233 | Ok(GameResult { | 266 | Ok(GameResult { |
| 234 | session_id: state.session_id.clone(), | 267 | session_id: state.session_id.clone(), |
