From fc59ef77cda14d97d854dfcaa848307cfa512b69 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:13:24 +0100 Subject: v1.0.0: replace slots with 5x3 reels and five paylines --- Cargo.toml | 2 +- docs/SLOTS.md | 74 +++++++++++++++ docs/TESTING.md | 10 +- src/client/games/manager.rs | 11 +++ src/client/games/response.rs | 13 ++- src/client/games/slots.rs | 87 +++++++++++++----- src/server/core.rs | 17 +++- src/server/games/slots.rs | 212 +++++++++++++++++++++++++++---------------- tests/client_core.rs | 106 ++++++++++++++++++---- tests/server_core.rs | 33 ++++--- tests/slots.rs | 159 ++++++++++++++++++-------------- 11 files changed, 511 insertions(+), 213 deletions(-) create mode 100644 docs/SLOTS.md diff --git a/Cargo.toml b/Cargo.toml index 0ab5ada..3e9d2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.4.0" +version = "1.0.0" edition = "2024" [dependencies] diff --git a/docs/SLOTS.md b/docs/SLOTS.md new file mode 100644 index 0000000..ccc3b60 --- /dev/null +++ b/docs/SLOTS.md @@ -0,0 +1,74 @@ +# Slots: 5 reels x 3 rows + +## Grid + +- 5 reels (columns) x 3 rows = 15 visible symbols. +- Six symbols, uniform: `C` cherry, `L` lemon, `O` orange, `G` grapes, + `D` diamond, `S` seven. +- The bet is the **total** bet, not a per-line bet. +- Five paylines: top, middle, bottom, V, inverted V. +- A line pays for 3, 4 or 5 matching symbols starting on reel 1 and reading + left to right. + +``` +line 1: top row across all five reels +line 2: middle row across all five reels +line 3: bottom row across all five reels +line 4: top, middle, bottom, middle, top +line 5: bottom, middle, top, middle, bottom +``` + +## Paytable + +Multipliers are applied to the total bet. + +| Symbol | 3 of a kind | 4 of a kind | 5 of a kind | +| --- | ---: | ---: | ---: | +| C | 2x | 5x | 15x | +| L | 3x | 7x | 20x | +| O | 3x | 8x | 25x | +| G | 4x | 12x | 40x | +| D | 6x | 18x | 70x | +| S | 9x | 30x | 150x | + +- Exact RTP: **425/432 = 0.983796...** (1.62% house edge). +- Maximum win: 5 lines x 150x = **750x total bet**. +- Chance of any win on a spin: about **13%**. +- The RTP is checked by `SlotsGame::theoretical_rtp()` and `tests/slots.rs`. + +## Wire format + +`GAME_RESULT` keeps its existing fields and appends the winning lines: + +``` +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. +- Older servers may still send a 3-character `reels` string; the client + degrades it to the first row. + +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`. + +## Client board + +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 ] + +Won 20 PLN +Winning lines: 1, 3 +[+/-] 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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 55f5422..8fac851 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -36,10 +36,12 @@ Use an 80-column terminal because several output lines are width-sensitive. prompt, Ctrl+C exits and restores the terminal. 4. `help` fits 80 columns. Tab completion wraps within 76 columns and the cursor stays visible. -5. `play slots 50` shows `Bet: 50 PLN` and the fetched balance. -6. Space shows `Spinning...`, then the reels, win text, and new balance inside - the same 7-line board. The controls line is never overwritten. -7. `+` / `-` after a spin changes only the bet; no stale result lines appear. +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`. +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. 9. `info`, `ping`, `send hello`, and `hosts` print readable output; the host fingerprint fits 80 columns. diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 71cd28c..8436142 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -75,6 +75,7 @@ impl GameManager { session.data.remove("error"); session.data.remove("reels"); session.data.remove("win_type"); + session.data.remove("win_lines"); session.data.remove("winnings"); Ok(()) } @@ -91,6 +92,15 @@ impl GameManager { session .data .insert("win_type".to_string(), result.win_type); + session.data.insert( + "win_lines".to_string(), + result + .win_lines + .iter() + .map(usize::to_string) + .collect::>() + .join(","), + ); session .data .insert("winnings".to_string(), result.winnings.to_string()); @@ -107,6 +117,7 @@ impl GameManager { session.phase = GamePhase::WaitingToStart; session.data.remove("reels"); session.data.remove("win_type"); + session.data.remove("win_lines"); 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 6948805..de22a75 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs @@ -6,6 +6,7 @@ pub struct SpinResult { pub balance: i64, pub reels: String, pub win_type: String, + pub win_lines: Vec, } /// Parse one server reply to `PLAY_START`. @@ -45,10 +46,19 @@ pub fn parse_spin_response(response: &str) -> Result { .parse::() .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; - if parts[5].chars().count() != 3 { + if parts[5].chars().count() != 3 && parts[5].chars().count() != 15 { return Err(format!("Invalid reels from server: {}", parts[5])); } + let win_lines = parts + .get(7) + .map(|raw| { + raw.split(',') + .filter_map(|value| value.trim().parse::().ok()) + .collect() + }) + .unwrap_or_default(); + Ok(SpinResult { session_id: parts[1].to_string(), winnings, @@ -56,5 +66,6 @@ pub fn parse_spin_response(response: &str) -> Result { balance, reels: parts[5].to_string(), win_type: parts[6].to_string(), + win_lines, }) } diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 5eed34d..39c43b2 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,8 +1,10 @@ use super::traits::{Game, GamePhase, GameState}; -/// Height of the game board. It must never depend on the spin result, or an -/// in-place redraw would move to the wrong line. -pub const SLOT_BOARD_LINES: usize = 7; +/// Height of the game board. It must never depend on the spin result. +pub const SLOT_BOARD_LINES: usize = 9; +pub const LINES: usize = 5; +pub const REELS: usize = 5; +pub const ROWS: usize = 3; pub struct SlotsGame; @@ -11,17 +13,27 @@ impl SlotsGame { Self } - fn render_reels(&self, reels: &str) -> String { - let mut chars = reels.chars(); - format!( - "[ {} ] [ {} ] [ {} ]", - chars.next().unwrap_or('?'), - chars.next().unwrap_or('?'), - chars.next().unwrap_or('?') - ) + fn cell(chars: &[char], row: usize, col: usize) -> char { + 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 { + '?' + } + } + + fn render_grid(&self, reels: &str) -> [String; ROWS] { + let chars: Vec = reels.chars().collect(); + std::array::from_fn(|row| { + (0..REELS) + .map(|col| format!("[ {} ]", Self::cell(&chars, row, col))) + .collect::>() + .join(" ") + }) } - /// The fifth board line: current status, result, or error. fn render_status(&self, state: &GameState) -> String { if let Some(error) = state.data.get("error") { return format!("ERROR: {error}"); @@ -35,14 +47,29 @@ impl SlotsGame { .get("winnings") .map(String::as_str) .unwrap_or("0"); - return match state.data.get("win_type").map(String::as_str) { - Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), - Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), - _ => "No win.".to_string(), - }; + if winnings != "0" { + return match state.data.get("win_type").map(String::as_str) { + Some("mega") => format!("MEGA WIN! {winnings} PLN"), + Some("big") => format!("BIG WIN! {winnings} PLN"), + _ => format!("Won {winnings} PLN"), + }; + } + return "No win.".to_string(); } "Press [SPACE] to spin!".to_string() } + + fn render_win_lines(&self, state: &GameState) -> String { + if state.phase != GamePhase::Finished { + return String::new(); + } + match state.data.get("win_lines").map(String::as_str) { + Some(lines) if !lines.is_empty() && lines != "-" => { + format!("Winning lines: {lines}") + } + _ => String::new(), + } + } } impl Game for SlotsGame { @@ -57,13 +84,18 @@ impl Game for SlotsGame { .get("balance") .map(String::as_str) .unwrap_or("?"); + let grid = self.render_grid(&reels); format!( - "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", + "=== 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", state.bet_amount, + LINES, balance, - self.render_reels(&reels), - self.render_status(state) + grid[0], + grid[1], + grid[2], + self.render_status(state), + self.render_win_lines(state) ) } @@ -74,17 +106,22 @@ impl Game for SlotsGame { fn get_help(&self) -> String { "SLOTS GAME\n\ =========\n\ - Match symbols to win!\n\ + 5 reels x 3 rows, 5 paylines.\n\ + Match 3, 4 or 5 symbols from the left on a payline.\n\ \n\ - Payouts:\n\ - Three of a kind: 2x-28x bet\n\ - Two of a kind: 1x-3x bet\n\ + Payouts (x total bet, 3/4/5 of a kind):\n\ + C: 2 / 5 / 15\n\ + L: 3 / 7 / 20\n\ + O: 3 / 8 / 25\n\ + G: 4 / 12 / 40\n\ + D: 6 / 18 / 70\n\ + S: 9 / 30 / 150\n\ \n\ Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ \n\ Controls:\n\ SPACE - Spin the reels\n\ - +/- - Adjust bet amount\n\ + +/- - Adjust total bet\n\ Q - Exit the game" .to_string() } diff --git a/src/server/core.rs b/src/server/core.rs index 84ad4d2..33cafbf 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -239,12 +239,21 @@ impl ServerCore { } }; - let reels = new_state.data.get("reels").cloned().unwrap_or_default(); - let win_type = new_state - .data + let reels = result + .result_data + .get("reels") + .cloned() + .unwrap_or_default(); + let win_type = result + .result_data .get("win_type") .cloned() .unwrap_or_default(); + let win_lines = result + .result_data + .get("win_lines") + .cloned() + .unwrap_or_else(|| "-".to_string()); match database::apply_spin( &mut self.conn, @@ -259,7 +268,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}" + "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type} {win_lines}" ) } Err(error) => { diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index 02595b1..2ef1b46 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs @@ -2,77 +2,127 @@ use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; use rand::Rng; use std::collections::HashMap; +pub const REELS: usize = 5; +pub const ROWS: usize = 3; +pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; + +/// Five paylines, each entry is the row used on reel 0..4. +pub const PAYLINES: [[usize; REELS]; 5] = [ + [0, 0, 0, 0, 0], // line 1: top + [1, 1, 1, 1, 1], // line 2: middle + [2, 2, 2, 2, 2], // line 3: bottom + [0, 1, 2, 1, 0], // line 4: V + [2, 1, 0, 1, 2], // line 5: inverted V +]; + +#[derive(Clone, Copy)] +struct Payout { + three: u64, + four: u64, + five: u64, +} + pub struct SlotsGame { - symbols: Vec, - payouts: HashMap, - /// Deterministic reels for tests and demos. - fixed_reels: Option>, + payouts: HashMap, + /// Deterministic grid for tests and demos, in column-major order. + fixed_grid: Option>, } impl SlotsGame { pub fn new() -> Self { - let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S']; let mut payouts = HashMap::new(); - // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge) - payouts.insert("CCC".to_string(), 2); - payouts.insert("LLL".to_string(), 3); - payouts.insert("OOO".to_string(), 5); - payouts.insert("GGG".to_string(), 8); - payouts.insert("DDD".to_string(), 14); - payouts.insert("SSS".to_string(), 28); - - payouts.insert("CC".to_string(), 1); - payouts.insert("LL".to_string(), 1); - payouts.insert("OO".to_string(), 1); - payouts.insert("GG".to_string(), 2); - payouts.insert("DD".to_string(), 2); - payouts.insert("SS".to_string(), 3); + // RTP = 425/432 = 0.9838 (1.62% house edge) with five paylines. + payouts.insert('C', Payout { three: 2, four: 5, five: 15 }); + payouts.insert('L', Payout { three: 3, four: 7, five: 20 }); + payouts.insert('O', Payout { three: 3, four: 8, five: 25 }); + payouts.insert('G', Payout { three: 4, four: 12, five: 40 }); + payouts.insert('D', Payout { three: 6, four: 18, five: 70 }); + payouts.insert('S', Payout { three: 9, four: 30, five: 150 }); Self { - symbols, payouts, - fixed_reels: None, + fixed_grid: None, } } - /// Build a slots game that always spins the given three symbols. - pub fn with_reels(reels: &str) -> Self { + /// Build a slots game that always spins the given 15-symbol grid. + /// The string is column-major: reel 0 top/middle/bottom, then reel 1... + pub fn with_grid(grid: &str) -> Self { let mut game = Self::new(); - game.fixed_reels = Some(reels.chars().take(3).collect()); + game.fixed_grid = Some(grid.chars().take(REELS * ROWS).collect()); game } - fn spin_reels(&self) -> Vec { - if let Some(reels) = &self.fixed_reels { - return reels.clone(); + fn spin_grid(&self) -> Vec { + if let Some(grid) = &self.fixed_grid { + return grid.clone(); } let mut rng = rand::thread_rng(); - (0..3) - .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) + (0..REELS * ROWS) + .map(|_| SYMBOLS[rng.gen_range(0..SYMBOLS.len())]) .collect() } - pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { - let reel_string: String = reels.iter().collect(); - - if let Some(&multiplier) = self.payouts.get(&reel_string) { - return bet.saturating_mul(multiplier); - } - - // Check all three pairs including non-adjacent (e.g. [C,D,C]) - let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; - let mut best: u64 = 0; - for &(a, b) in &pairs { - if reels[a] == reels[b] { - let two_of_kind = format!("{}{}", reels[a], reels[a]); - if let Some(&multiplier) = self.payouts.get(&two_of_kind) { - best = best.max(bet.saturating_mul(multiplier)); + /// Evaluate one spin. Returns the total win and the 1-based winning lines. + pub fn calculate_payout(&self, grid: &[char], bet: u64) -> (u64, Vec) { + let mut total = 0u64; + let mut winning_lines = Vec::new(); + + for (index, pattern) in PAYLINES.iter().enumerate() { + let Some(&first) = grid.get(pattern[0]) else { + continue; + }; + + let mut run = 1; + for (col, row) in pattern.iter().enumerate().skip(1) { + let position = col * ROWS + row; + if grid.get(position) == Some(&first) { + run += 1; + } else { + break; } } + + if run < 3 { + continue; + } + + let Some(payout) = self.payouts.get(&first) else { + continue; + }; + let multiplier = match run { + 3 => payout.three, + 4 => payout.four, + _ => payout.five, + }; + + total = total.saturating_add(bet.saturating_mul(multiplier)); + winning_lines.push(index + 1); } - best + + (total, winning_lines) + } + + /// Exact theoretical return to player for the current paytable. + pub fn theoretical_rtp(&self) -> f64 { + // For one reel line and one symbol: exactly 3, 4 or 5 of a kind. + let p3 = 5.0 / 1296.0; + let p4 = 5.0 / 7776.0; + let p5 = 1.0 / 7776.0; + + let per_line: f64 = self + .payouts + .values() + .map(|payout| { + p3 * payout.three as f64 + + p4 * payout.four as f64 + + p5 * payout.five as f64 + }) + .sum(); + + per_line * PAYLINES.len() as f64 } } @@ -86,7 +136,7 @@ impl Game for SlotsGame { } fn get_description(&self) -> &'static str { - "Spin the reels and match symbols to win!" + "5 reels x 3 rows, 5 paylines. Match 3, 4 or 5 symbols from the left." } fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { @@ -115,32 +165,21 @@ impl Game for SlotsGame { match action { "spin" => { - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { + if state.phase != GamePhase::WaitingToStart + && state.phase != GamePhase::Playing + { return Err(GameError::InvalidAction( "Cannot spin in current phase".to_string(), )); } - let reels = self.spin_reels(); - let reel_string: String = reels.iter().collect(); + let grid = self.spin_grid(); + let reels: String = grid.iter().collect(); let mut new_data = state.data.clone(); - new_data.insert("reels".to_string(), reel_string.clone()); + new_data.insert("reels".to_string(), reels); new_data.insert("status".to_string(), "spun".to_string()); - let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; - let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; - new_data.insert( - "win_type".to_string(), - if is_jackpot { - "jackpot".to_string() - } else if is_two { - "two".to_string() - } else { - "none".to_string() - }, - ); - Ok(GameState { session_id: state.session_id.clone(), game_type: state.game_type.clone(), @@ -165,24 +204,31 @@ impl Game for SlotsGame { .data .get("reels") .ok_or(GameError::InternalError("No reels found".to_string()))?; + let grid: Vec = reel_string.chars().collect(); + if grid.len() != REELS * ROWS { + return Err(GameError::InternalError(format!( + "Expected {} symbols, got {}", + REELS * ROWS, + grid.len() + ))); + } - let reels: Vec = reel_string.chars().collect(); - let winnings = self.calculate_payout(&reels, state.bet_amount); + let (winnings, winning_lines) = self.calculate_payout(&grid, state.bet_amount); + let win_type = classify_win(winnings, state.bet_amount); + let win_lines = if winning_lines.is_empty() { + "-".to_string() + } else { + winning_lines + .iter() + .map(usize::to_string) + .collect::>() + .join(",") + }; let mut result_data = HashMap::new(); result_data.insert("reels".to_string(), reel_string.clone()); - result_data.insert( - "payout_multiplier".to_string(), - if winnings > 0 && state.bet_amount > 0 { - (winnings / state.bet_amount).to_string() - } else { - "0".to_string() - }, - ); - result_data.insert( - "win_type".to_string(), - state.data.get("win_type").cloned().unwrap_or_default(), - ); + result_data.insert("win_type".to_string(), win_type.to_string()); + result_data.insert("win_lines".to_string(), win_lines); Ok(GameResult { session_id: state.session_id.clone(), @@ -194,3 +240,15 @@ impl Game for SlotsGame { }) } } + +fn classify_win(winnings: u64, bet: u64) -> &'static str { + if winnings == 0 { + "none" + } else if bet > 0 && winnings >= bet.saturating_mul(50) { + "mega" + } else if bet > 0 && winnings >= bet.saturating_mul(10) { + "big" + } else { + "win" + } +} diff --git a/tests/client_core.rs b/tests/client_core.rs index c298ab7..80ec587 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -5,8 +5,13 @@ //! player would see, not about ANSI sequences. use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; +use zagan_bet::client::games::response::parse_spin_response; use zagan_bet::client::ui::command_names; +const WIN3: &str = "CCCCLLCLODGSDGS"; +const LOSS: &str = "CLOOGDSLCDCOGLS"; +const ALL_C: &str = "CCCCCCCCCCCCCCC"; + #[derive(Default)] struct Screen { lines: Vec, @@ -123,16 +128,17 @@ impl Harness { } #[test] -fn play_shows_a_seven_line_board_and_fetches_the_balance() { +fn play_shows_a_nine_line_board_and_fetches_the_balance() { let mut h = Harness::new(); h.line("play slots 50"); - assert_eq!(h.board().len(), 7); + assert_eq!(h.board().len(), 9); assert!(h.board()[1].contains("Bet: 50 PLN")); + assert!(h.board()[1].contains("Lines: 5")); assert!(h.sent.contains(&"INFO".to_string())); h.reply("INFO alice 1 1000"); - assert_eq!(h.board().len(), 7); + assert_eq!(h.board().len(), 9); assert!(h.board()[1].contains("Balance: 1000 PLN")); } @@ -143,18 +149,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(), 7); - assert!(h.board()[4].contains("Spinning...")); - assert!(h.board()[6].contains("[Q] Quit")); + assert_eq!(h.board().len(), 9); + assert!(h.board()[6].contains("Spinning...")); + assert!(h.board()[8].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 60 50 1010 OSS two"); - assert_eq!(h.board().len(), 7); - assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); - assert!(h.board()[4].contains("Two of a kind! Winnings: 60 PLN")); - assert!(h.board()[1].contains("Balance: 1010 PLN")); - assert!(h.board()[6].contains("[Q] Quit")); + h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1"); + assert_eq!(h.board().len(), 9); + 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()[1].contains("Balance: 1050 PLN")); + assert!(h.board()[8].contains("[Q] Quit")); assert_eq!(h.screen.lines.len(), lines_before_spin); } @@ -163,14 +173,17 @@ 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 60 50 1010 OSS two"); + h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1"); let lines_before_bet = h.screen.lines.len(); h.key(Key::Char('+')); - assert_eq!(h.board().len(), 7); + assert_eq!(h.board().len(), 9); assert!(h.board()[1].contains("Bet: 60 PLN")); - assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); + assert!(h + .board() + .get(2) + .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]"))); assert_eq!(h.screen.lines.len(), lines_before_bet); assert_eq!( h.screen @@ -182,6 +195,32 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { ); } +#[test] +fn a_losing_spin_shows_no_win_and_no_winning_lines() { + let mut h = Harness::new(); + h.enter_slots("10"); + h.key(Key::Char(' ')); + + 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], ""); +} + +#[test] +fn a_mega_win_shows_all_winning_lines() { + let mut h = Harness::new(); + 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")); + + 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")); +} + #[test] fn quitting_clears_the_board_and_returns_the_prompt() { let mut h = Harness::new(); @@ -202,8 +241,8 @@ fn a_server_error_replaces_the_status_line() { h.reply("ERROR Insufficient balance"); - assert_eq!(h.board().len(), 7); - assert!(h.board()[4].contains("ERROR: Insufficient balance")); + assert_eq!(h.board().len(), 9); + assert!(h.board()[6].contains("ERROR: Insufficient balance")); assert!(h.board()[2].contains("[ ? ]")); assert!(!h.board().iter().any(|line| line.contains("Spinning"))); } @@ -216,8 +255,8 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() { h.reply(""); - assert_eq!(h.board().len(), 7); - assert!(h.board()[4].contains("No response from server")); + assert_eq!(h.board().len(), 9); + assert!(h.board()[6].contains("No response from server")); } #[test] @@ -301,7 +340,7 @@ fn play_without_a_bet_uses_the_default() { let mut h = Harness::new(); h.line("play slots"); - assert_eq!(h.board().len(), 7); + assert_eq!(h.board().len(), 9); assert!(h.board()[1].contains("Bet: 10 PLN")); } @@ -475,3 +514,30 @@ fn disconnect_clears_connection_state() { .iter() .any(|line| line.contains("Not connected"))); } + +#[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 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]); +} + +#[test] +fn accepts_a_legacy_three_symbol_result() { + let result = parse_spin_response("GAME_RESULT session-1 60 10 1050 OSS two") + .expect("legacy result should still parse"); + + assert_eq!(result.reels, "OSS"); + assert_eq!(result.win_type, "two"); + assert!(result.win_lines.is_empty()); +} + +#[test] +fn rejects_a_grid_with_the_wrong_size() { + let response = "GAME_RESULT session-1 100 50 1050 CCCCCCCCCCCCCC win 1"; + + assert!(parse_spin_response(response).is_err()); +} diff --git a/tests/server_core.rs b/tests/server_core.rs index fe2b3d3..f3736e9 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -6,7 +6,7 @@ use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::slots::SlotsGame; -fn setup(name: &str, reels: &str) -> (ServerCore, String) { +fn setup(name: &str, grid: &str) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() @@ -17,12 +17,18 @@ fn setup(name: &str, reels: &str) -> (ServerCore, String) { database::init_db(&conn); let registry = GameRegistry::new(); - registry.register_game(SlotsGame::with_reels(reels)); + registry.register_game(SlotsGame::with_grid(grid)); // Low bcrypt cost keeps the tests fast. (ServerCore::with_registry(conn, registry, 4), path) } +/// Column-major 5x3 grid with no winning line. +const LOSS: &str = "CLOOGDSLCDCOGLS"; + +/// Top line is C C C D D; it pays 2x total bet. +const WIN3: &str = "CCCCLLCLODGSDGS"; + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -39,7 +45,7 @@ fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { #[test] fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { - let (mut core, path) = setup("register", "CLO"); + let (mut core, path) = setup("register", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); @@ -59,7 +65,7 @@ fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { #[test] fn the_same_credential_gets_a_different_salt() { - let (mut core, path) = setup("salt", "CLO"); + let (mut core, path) = setup("salt", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); @@ -73,7 +79,7 @@ fn the_same_credential_gets_a_different_salt() { #[test] fn legacy_sha256_rows_are_upgraded_on_login() { - let (mut core, path) = setup("legacy", "CLO"); + let (mut core, path) = setup("legacy", LOSS); let conn = Connection::open(&path).unwrap(); database::add_user(&conn, "legacy", "deadbeef").unwrap(); @@ -96,7 +102,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() { #[test] fn duplicate_registration_returns_an_error() { - let (mut core, _path) = setup("duplicate", "CLO"); + let (mut core, _path) = setup("duplicate", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); @@ -108,7 +114,7 @@ fn duplicate_registration_returns_an_error() { #[test] fn a_malicious_username_cannot_change_another_balance() { - let (mut core, _path) = setup("injection", "CLO"); + let (mut core, _path) = setup("injection", LOSS); let mut admin = ConnectionState::default(); register(&mut core, &mut admin, "victim"); @@ -132,7 +138,7 @@ fn a_malicious_username_cannot_change_another_balance() { #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { - let (mut core, path) = setup("rollback", "CLO"); + let (mut core, path) = setup("rollback", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); @@ -155,7 +161,7 @@ fn a_failed_audit_insert_rolls_back_the_balance_change() { #[test] fn spins_write_audit_rows_and_leave_no_sessions() { - let (mut core, _path) = setup("audit", "CLO"); + let (mut core, _path) = setup("audit", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); @@ -175,7 +181,7 @@ fn spins_write_audit_rows_and_leave_no_sessions() { #[test] fn a_winning_spin_updates_the_balance_and_the_audit_row() { - let (mut core, _path) = setup("win", "CCC"); + let (mut core, _path) = setup("win", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); @@ -183,6 +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_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); @@ -191,7 +198,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() { #[test] fn insufficient_balance_does_not_write_an_audit_row() { - let (mut core, path) = setup("insufficient", "CLO"); + let (mut core, path) = setup("insufficient", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); @@ -211,7 +218,7 @@ fn insufficient_balance_does_not_write_an_audit_row() { #[test] fn a_negative_balance_is_rejected_without_wrapping() { - let (mut core, path) = setup("negative", "CLO"); + let (mut core, path) = setup("negative", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); @@ -233,7 +240,7 @@ fn a_negative_balance_is_rejected_without_wrapping() { #[test] fn unknown_games_are_rejected_before_a_session_is_created() { - let (mut core, _path) = setup("unknown", "CLO"); + let (mut core, _path) = setup("unknown", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); diff --git a/tests/slots.rs b/tests/slots.rs index 3035800..5fb751a 100644 --- a/tests/slots.rs +++ b/tests/slots.rs @@ -1,99 +1,122 @@ -//! Regression tests for the server-side slots game logic. +//! Regression tests for the server-side 5x3 slots game. -use zagan_bet::server::games::slots::SlotsGame; +use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; -fn make_game() -> SlotsGame { - SlotsGame::new() +/// Convert row-major test rows into the column-major wire format. +fn grid(rows: [&str; 3]) -> String { + let cells: Vec> = rows.iter().map(|row| row.chars().collect()).collect(); + let mut result = String::new(); + for col in 0..REELS { + for row in 0..ROWS { + result.push(cells[row][col]); + } + } + result +} + +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) +} + +#[test] +fn the_wire_grid_is_five_reels_by_three_rows() { + assert_eq!(REELS, 5); + assert_eq!(ROWS, 3); + assert_eq!(PAYLINES.len(), 5); + + let game = SlotsGame::new(); + let state = game.prepare_state("alice", 10); + let state = game.process_action(&state, "spin", &[]).unwrap(); + let reels = state.data.get("reels").unwrap(); + assert_eq!(reels.chars().count(), REELS * ROWS); } -/// Three of a kind pays the three-of-a-kind multiplier. #[test] -fn three_of_a_kind_pays() { - let game = make_game(); - // CCC = 2x - assert_eq!(game.calculate_payout(&['C', 'C', 'C'], 100), 200); - // SSS = 28x - assert_eq!(game.calculate_payout(&['S', 'S', 'S'], 10), 280); +fn three_of_a_kind_on_the_top_line_pays() { + let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); + + assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); + assert_eq!(lines, vec![1]); } -/// Two adjacent matching symbols pays the two-of-a-kind multiplier. #[test] -fn two_adjacent_pays() { - let game = make_game(); - // CC? = 1x - assert_eq!(game.calculate_payout(&['C', 'C', 'D'], 100), 100); - // ?LL = 1x - assert_eq!(game.calculate_payout(&['D', 'L', 'L'], 50), 50); +fn four_of_a_kind_pays_more_than_three() { + let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); + + assert_eq!(winnings, 50, "CCCC pays 5x total bet"); + assert_eq!(lines, vec![1]); } -/// Non-adjacent matching symbols (e.g. [C,D,C]) must also pay. -/// This was a bug: the old code only checked pairs (0,1) and (1,2). #[test] -fn two_non_adjacent_pays() { - let game = make_game(); - // C,D,C — two cherries at positions 0 and 2 - assert_eq!(game.calculate_payout(&['C', 'D', 'C'], 100), 100); - // S,X,S — two sevens at positions 0 and 2 - assert_eq!(game.calculate_payout(&['S', 'L', 'S'], 10), 30); - // L,O,L - assert_eq!(game.calculate_payout(&['L', 'O', 'L'], 50), 50); +fn five_of_a_kind_pays_all_lines() { + let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); + + assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); + assert_eq!(lines, vec![1, 2, 3, 4, 5]); } -/// No matching symbols returns 0. #[test] -fn no_match_returns_zero() { - let game = make_game(); - assert_eq!(game.calculate_payout(&['C', 'L', 'O'], 100), 0); - assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); +fn the_v_payline_is_evaluated() { + let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10); + + assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); + assert_eq!(lines, vec![4]); } #[test] -fn an_enormous_bet_saturates_instead_of_overflowing() { - let game = make_game(); - assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX); - assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX); +fn no_matching_line_pays_nothing() { + let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); + + assert_eq!(winnings, 0); + assert!(lines.is_empty()); } -/// process_action produces a finished state with correct reels and win_type. #[test] -fn process_action_spin_sets_finished() { - let game = make_game(); - let state = game.prepare_state("alice", 100); - assert_eq!(state.phase, GamePhase::WaitingToStart); - - // Manually set reels by going through process_action. - // Since spin is random, we test the state machine rather than the outcome. - let result = game.process_action(&state, "spin", &[]); - assert!(result.is_ok()); - let new_state = result.unwrap(); - assert_eq!(new_state.phase, GamePhase::Finished); - assert!(new_state.data.contains_key("reels")); - assert!(new_state.data.contains_key("win_type")); +fn winning_lines_are_added_together() { + let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); + + assert_eq!(winnings, (150 + 20 + 25) * 10); + assert_eq!(lines, vec![1, 2, 3]); +} + +#[test] +fn theoretical_rtp_is_just_under_one() { + let rtp = SlotsGame::new().theoretical_rtp(); + + assert!((rtp - 425.0 / 432.0).abs() < 1e-12); + assert!(rtp < 1.0, "house edge must be positive"); + assert!(rtp > 0.98, "house edge should be tiny"); +} + +#[test] +fn an_enormous_bet_saturates_instead_of_overflowing() { + let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX); + + assert_eq!(winnings, u64::MAX); } -/// calculate_result on a finished state returns valid GameResult. #[test] -fn calculate_result_on_finished_state() { - let game = make_game(); - let mut state = game.prepare_state("alice", 100); - state.phase = GamePhase::Finished; - state - .data - .insert("reels".to_string(), "CCD".to_string()); - state - .data - .insert("win_type".to_string(), "two".to_string()); +fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() { + let game = SlotsGame::with_grid(&grid(["CCCDD", "CLLGG", "CLOSS"])); + let state = game.prepare_state("alice", 10); + + let state = game.process_action(&state, "spin", &[]).unwrap(); + assert_eq!(state.phase, GamePhase::Finished); let result = game.calculate_result(&state).unwrap(); - assert_eq!(result.bet_amount, 100); - assert_eq!(result.winnings, 100); // CC = 1x + 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"); } -/// calculate_result on a non-finished state returns an error. #[test] -fn calculate_result_on_unfinished_state() { - let game = make_game(); - let state = game.prepare_state("alice", 100); +fn calculate_result_rejects_an_unfinished_state() { + let game = SlotsGame::new(); + let state = game.prepare_state("alice", 10); + assert!(game.calculate_result(&state).is_err()); } -- cgit v1.2.3