From 9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:45:43 +0100 Subject: v1.3.0: simplify payouts to bet x symbol x shape --- Cargo.toml | 2 +- docs/SLOTS.md | 92 +++++++++++++++++++++++++++++++++-------------- docs/TESTING.md | 7 ++-- src/client/games/slots.rs | 33 +++++++++++------ src/server/games/slots.rs | 32 ++++++++++------- src/slots.rs | 53 ++++++++++++++------------- tests/client_core.rs | 23 ++++++------ tests/server_core.rs | 4 +-- tests/slots.rs | 40 ++++++++++++++------- 9 files changed, 183 insertions(+), 103 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 700ccec..4a9daea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "1.2.1" +version = "1.3.0" edition = "2024" [dependencies] diff --git a/docs/SLOTS.md b/docs/SLOTS.md index b8c670b..1dbaba5 100644 --- a/docs/SLOTS.md +++ b/docs/SLOTS.md @@ -20,19 +20,33 @@ 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**. +``` +payout = total bet x symbol multiplier x shape multiplier +``` + +Symbol multipliers: + +| Symbol | Multiplier | +| --- | ---: | +| C | 1 | +| L | 2 | +| O | 3 | +| G | 4 | +| D | 6 | +| S | 10 | + +Shape multipliers (how many matching symbols on the line): + +| Shape | Multiplier | +| --- | ---: | +| 3 of a kind | 1 | +| 4 of a kind | 3 | +| 5 of a kind | 14 | + +Every winning payline is added together. + +- Exact RTP: **3835/3888 = 0.986368...** (1.36% house edge). +- Maximum win: 5 lines x S(10) x 5-shape(14) = **700x total bet**. - Chance of any win on a spin: about **13%**. - The RTP is checked by `SlotsGame::theoretical_rtp()` and `tests/slots.rs`. @@ -67,19 +81,45 @@ The board is ten lines: ``` === SLOTS === 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 -Won 20 PLN -Winning lines: 2 +{ G } [ L ] [ S ] [ O ] [ G ] +[ D ] { G } [ L ] [ S ] [ C ] +[ O ] [ O ] { G } [ L ] [ O ] +Pays bet x symbol x shape +Symbol C1 L2 O3 G4 D6 S10 Shape 3:1 4:3 5:14 +Won 40 PLN +Winning lines: 4 (V) [+/-] Bet [SPACE] Spin [Q] Quit ``` -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. +The two lines above the status show the payout formula and the two multiplier +tables. 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. Multiple winning lines are summed; the +board lists all of them and highlights the union of their matched cells. + +## Reading a win + +A payline is one of five fixed paths across the five reels: + +| Line | Name | Rows on reels 1-5 | +| ---: | --- | --- | +| 1 | top | top, top, top, top, top | +| 2 | middle | middle, middle, middle, middle, middle | +| 3 | bottom | bottom, bottom, bottom, bottom, bottom | +| 4 | V | top, middle, bottom, middle, top | +| 5 | inverted V | bottom, middle, top, middle, bottom | + +A line pays only when the first **3, 4 or 5** symbols on that line match, +reading from the leftmost reel. + +In the example above, line 4 (V) contains `G G G S G`: + +- reels 1-3 are `G`, so three `G` symbols pay; +- reel 4 is `S`, which breaks the run, so the `G` on reel 5 does not count; +- `G` has symbol multiplier 4 and 3-of-a-kind has shape multiplier 1, so the + win is `10 x 4 x 1 = 40 PLN`. + +A diagonal of `L` symbols that is not one of the five paylines does not win, +and a run must start on reel 1. That is why only the three `G` cells are +marked in the example. diff --git a/docs/TESTING.md b/docs/TESTING.md index 84d98e7..acdcdd4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -36,12 +36,13 @@ 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 a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and +5. `play slots 50` shows a 5x3 grid, the `Pays bet x symbol x shape` lines, and the fetched balance. 6. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and 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`. + yellow/bold `{ X }` cells; the `Winning lines:` list names the payline + (e.g. `4 (V)`). 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/client/games/slots.rs b/src/client/games/slots.rs index a417e7d..ad9ef5c 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,5 +1,5 @@ use super::traits::{Game, GamePhase, GameState}; -use crate::slots::{paytable_lines, REELS, ROWS}; +use crate::slots::{paytable_lines, LINE_NAMES, REELS, ROWS}; /// Height of the game board. It must never depend on the spin result. pub const SLOT_BOARD_LINES: usize = 10; @@ -81,7 +81,21 @@ impl SlotsGame { } match state.data.get("win_lines").map(String::as_str) { Some(lines) if !lines.is_empty() && lines != "-" => { - format!("Winning lines: {lines}") + let named: Vec = lines + .split(',') + .filter_map(|raw| raw.trim().parse::().ok()) + .map(|line| { + let name = line + .checked_sub(1) + .and_then(|index| LINE_NAMES.get(index)) + .copied(); + match name { + Some(name) => format!("{line} ({name})"), + None => line.to_string(), + } + }) + .collect(); + format!("Winning lines: {}", named.join(", ")) } _ => String::new(), } @@ -126,15 +140,14 @@ impl Game for SlotsGame { "SLOTS GAME\n\ =========\n\ 5 reels x 3 rows, 5 paylines.\n\ - Match 3, 4 or 5 symbols from the left on a payline.\n\ + A win needs 3, 4 or 5 matching symbols from reel 1 on a payline.\n\ \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\ + Payout = total bet x symbol x shape\n\ + Symbols: C=1 L=2 O=3 G=4 D=6 S=10\n\ + Shapes: 3 of a kind=1, 4 of a kind=3, 5 of a kind=14\n\ + \n\ + Lines: 1 top, 2 middle, 3 bottom, 4 V, 5 inverted V\n\ + Multiple winning lines are added together.\n\ \n\ Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ \n\ diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index e9f58b1..298cd4c 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs @@ -1,5 +1,5 @@ use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; -use crate::slots::PAYTABLE; +use crate::slots::{SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; use rand::Rng; use std::collections::HashMap; @@ -27,10 +27,18 @@ pub struct SlotsGame { impl SlotsGame { pub fn new() -> Self { - let payouts = PAYTABLE + // payout = bet * symbol multiplier * shape multiplier + let payouts = SYMBOL_MULTIPLIERS .iter() - .map(|&(symbol, three, four, five)| { - (symbol, Payout { three, four, five }) + .map(|&(symbol, symbol_multiplier)| { + ( + symbol, + Payout { + three: symbol_multiplier * SHAPE_MULTIPLIERS[0].1, + four: symbol_multiplier * SHAPE_MULTIPLIERS[1].1, + five: symbol_multiplier * SHAPE_MULTIPLIERS[2].1, + }, + ) }) .collect(); @@ -116,17 +124,15 @@ impl SlotsGame { 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 - }) + let symbol_sum: f64 = SYMBOL_MULTIPLIERS + .iter() + .map(|(_, multiplier)| *multiplier as f64) .sum(); + let shape_ev = p3 * SHAPE_MULTIPLIERS[0].1 as f64 + + p4 * SHAPE_MULTIPLIERS[1].1 as f64 + + p5 * SHAPE_MULTIPLIERS[2].1 as f64; - per_line * PAYLINES.len() as f64 + symbol_sum * shape_ev * PAYLINES.len() as f64 } } diff --git a/src/slots.rs b/src/slots.rs index a3b4ef4..9e41f05 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -13,33 +13,38 @@ pub const PAYLINES: [[usize; REELS]; 5] = [ [2, 1, 0, 1, 2], // line 5: inverted V ]; -/// (symbol, 3-of-a-kind, 4-of-a-kind, 5-of-a-kind) multipliers of total bet. -pub const PAYTABLE: [(char, u64, u64, u64); 6] = [ - ('C', 2, 5, 15), - ('L', 3, 7, 20), - ('O', 3, 8, 25), - ('G', 4, 12, 40), - ('D', 6, 18, 70), - ('S', 9, 30, 150), +pub const LINE_NAMES: [&str; 5] = ["top", "middle", "bottom", "V", "inverted V"]; + +/// Symbol value multipliers. +pub const SYMBOL_MULTIPLIERS: [(char, u64); 6] = [ + ('C', 1), + ('L', 2), + ('O', 3), + ('G', 4), + ('D', 6), + ('S', 10), ]; -/// Paytable rendered under the reels, split so it fits a narrow terminal. +/// (match length, shape multiplier): 3, 4 or 5 of a kind. +pub const SHAPE_MULTIPLIERS: [(usize, u64); 3] = [(3, 1), (4, 3), (5, 14)]; + +/// Paytable rendered under the reels. +/// +/// `payout = bet * symbol_multiplier * shape_multiplier`. pub fn paytable_lines() -> Vec { - let entries: Vec = PAYTABLE + let symbols = SYMBOL_MULTIPLIERS + .iter() + .map(|(symbol, multiplier)| format!("{symbol}{multiplier}")) + .collect::>() + .join(" "); + let shapes = SHAPE_MULTIPLIERS .iter() - .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}")) - .collect(); + .map(|(length, multiplier)| format!("{length}:{multiplier}")) + .collect::>() + .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() + vec![ + "Pays bet x symbol x shape".to_string(), + format!("Symbol {symbols} Shape {shapes}"), + ] } diff --git a/tests/client_core.rs b/tests/client_core.rs index e75a611..fd88566 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -135,8 +135,9 @@ fn play_shows_a_ten_line_board_and_fetches_the_balance() { 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()[6].contains("G 4/12/40 D 6/18/70 S 9/30/150")); + assert!(h.board()[5].contains("Pays bet x symbol x shape")); + assert!(h.board()[6].contains("Symbol C1 L2 O3 G4 D6 S10")); + assert!(h.board()[6].contains("Shape 3:1 4:3 5:14")); assert!(h.sent.contains(&"INFO".to_string())); h.reply("INFO alice 1 1000"); @@ -157,15 +158,15 @@ 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 0,3,6"); + h.reply("GAME_RESULT session-1 50 50 1000 CCCCLLCLODGSDGS win 1 0,3,6"); 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()[7].contains("Won 100 PLN")); - assert!(h.board()[8].contains("Winning lines: 1")); - assert!(h.board()[1].contains("Balance: 1050 PLN")); + assert!(h.board()[7].contains("Won 50 PLN")); + assert!(h.board()[8].contains("Winning lines: 1 (top)")); + assert!(h.board()[1].contains("Balance: 1000 PLN")); assert!(h.board()[9].contains("[Q] Quit")); assert_eq!(h.screen.lines.len(), lines_before_spin); } @@ -175,7 +176,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 0,3,6"); + h.reply("GAME_RESULT session-1 50 50 1000 CCCCLLCLODGSDGS win 1 0,3,6"); let lines_before_bet = h.screen.lines.len(); h.key(Key::Char('+')); @@ -217,11 +218,11 @@ 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 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14")); + h.reply(&format!("GAME_RESULT session-1 700 10 1690 {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(), 10); - assert!(h.board()[7].contains("MEGA WIN! 750 PLN")); - assert!(h.board()[8].contains("Winning lines: 1,2,3,4,5")); + assert!(h.board()[7].contains("MEGA WIN! 700 PLN")); + assert!(h.board()[8].contains("Winning lines: 1 (top), 2 (middle), 3 (bottom), 4 (V), 5 (inverted V)")); assert_eq!(h.board()[2].matches("{ C }").count(), 5); } @@ -521,7 +522,7 @@ 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 0,3,6"); + let response = format!("GAME_RESULT session-1 50 50 1000 {WIN3} win 1 0,3,6"); let result = parse_spin_response(&response).expect("valid 5x3 result"); assert_eq!(result.reels.chars().count(), 15); diff --git a/tests/server_core.rs b/tests/server_core.rs index 5c3c316..3f5dc9a 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -188,10 +188,10 @@ 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.contains(" 10 10 1000 "), "response: {response}"); assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); - assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } diff --git a/tests/slots.rs b/tests/slots.rs index bdf8f10..8bdd9af 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_lines, PAYTABLE}; +use zagan_bet::slots::{paytable_lines, LINE_NAMES, SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; @@ -44,7 +44,7 @@ fn the_wire_grid_is_five_reels_by_three_rows() { fn three_of_a_kind_on_the_top_line_pays() { let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); - assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); + assert_eq!(winnings, 10, "CCC: 10 * C(1) * shape3(1)"); assert_eq!(lines(&wins), vec![1]); assert_eq!(wins[0].cells, vec![0, 3, 6]); } @@ -53,7 +53,7 @@ fn three_of_a_kind_on_the_top_line_pays() { fn four_of_a_kind_pays_more_than_three() { let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); - assert_eq!(winnings, 50, "CCCC pays 5x total bet"); + assert_eq!(winnings, 30, "CCCC: 10 * C(1) * shape4(3)"); assert_eq!(lines(&wins), vec![1]); assert_eq!(wins[0].cells, vec![0, 3, 6, 9]); } @@ -62,7 +62,7 @@ fn four_of_a_kind_pays_more_than_three() { fn five_of_a_kind_pays_all_lines() { let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); - assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); + assert_eq!(winnings, 7000, "five SSSSS lines: 5 * 10 * S(10) * shape5(14)"); 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]); @@ -73,7 +73,7 @@ fn five_of_a_kind_pays_all_lines() { fn the_v_payline_is_evaluated() { let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10); - assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); + assert_eq!(winnings, 140, "V line: 10 * C(1) * shape5(14)"); assert_eq!(lines(&wins), vec![4]); assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]); } @@ -90,7 +90,7 @@ fn no_matching_line_pays_nothing() { fn winning_lines_are_added_together() { let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); - assert_eq!(winnings, (150 + 20 + 25) * 10); + assert_eq!(winnings, (10 + 2 + 3) * 14 * 10); assert_eq!(lines(&wins), vec![1, 2, 3]); let mut cells: Vec = wins.iter().flat_map(|win| win.cells.clone()).collect(); @@ -102,21 +102,35 @@ fn winning_lines_are_added_together() { fn theoretical_rtp_is_just_under_one() { let rtp = SlotsGame::new().theoretical_rtp(); - assert!((rtp - 425.0 / 432.0).abs() < 1e-12); + assert!((rtp - 3835.0 / 3888.0).abs() < 1e-12); assert!(rtp < 1.0, "house edge must be positive"); assert!(rtp > 0.98, "house edge should be tiny"); } #[test] -fn the_paytable_lines_list_every_symbol_and_fit_80_columns() { +fn payline_names_match_the_paylines() { + assert_eq!(LINE_NAMES.len(), PAYLINES.len()); + assert_eq!(LINE_NAMES[3], "V"); + assert_eq!(LINE_NAMES[4], "inverted V"); +} + +#[test] +fn the_paytable_lines_show_the_product_formula_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_eq!(lines.len(), 2, "paytable should be two lines"); + assert!(joined.contains("Pays bet x symbol x shape")); + for (symbol, multiplier) in SYMBOL_MULTIPLIERS { + assert!( + joined.contains(&format!("{symbol}{multiplier}")), + "paytable is missing symbol {symbol}" + ); + } + for (length, multiplier) in SHAPE_MULTIPLIERS { assert!( - joined.contains(&format!("{symbol} {three}/{four}/{five}")), - "paytable is missing {symbol}" + joined.contains(&format!("{length}:{multiplier}")), + "paytable is missing shape {length}" ); } for line in lines { @@ -140,7 +154,7 @@ fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() { assert_eq!(state.phase, GamePhase::Finished); let result = game.calculate_result(&state).unwrap(); - assert_eq!(result.winnings, 20); + assert_eq!(result.winnings, 10); 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"); -- cgit v1.2.3