diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:45:43 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:45:43 +0100 |
| commit | 9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e (patch) | |
| tree | e1179b0523356dfb5d43f3d6d4614203f6fa8dec | |
| parent | v1.2.1: track wrapped board lines and split the paytable (diff) | |
| download | zagan-bet-9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e.tar.gz zagan-bet-9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e.zip | |
v1.3.0: simplify payouts to bet x symbol x shape
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SLOTS.md | 92 | ||||
| -rw-r--r-- | docs/TESTING.md | 7 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 33 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 32 | ||||
| -rw-r--r-- | src/slots.rs | 53 | ||||
| -rw-r--r-- | tests/client_core.rs | 23 | ||||
| -rw-r--r-- | tests/server_core.rs | 4 | ||||
| -rw-r--r-- | tests/slots.rs | 40 |
9 files changed, 183 insertions, 103 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "1.2.1" | 3 | version = "1.3.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [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 | |||
| 20 | 20 | ||
| 21 | ## Paytable | 21 | ## Paytable |
| 22 | 22 | ||
| 23 | Multipliers are applied to the total bet. | 23 | ``` |
| 24 | 24 | payout = total bet x symbol multiplier x shape multiplier | |
| 25 | | Symbol | 3 of a kind | 4 of a kind | 5 of a kind | | 25 | ``` |
| 26 | | --- | ---: | ---: | ---: | | 26 | |
| 27 | | C | 2x | 5x | 15x | | 27 | Symbol multipliers: |
| 28 | | L | 3x | 7x | 20x | | 28 | |
| 29 | | O | 3x | 8x | 25x | | 29 | | Symbol | Multiplier | |
| 30 | | G | 4x | 12x | 40x | | 30 | | --- | ---: | |
| 31 | | D | 6x | 18x | 70x | | 31 | | C | 1 | |
| 32 | | S | 9x | 30x | 150x | | 32 | | L | 2 | |
| 33 | 33 | | O | 3 | | |
| 34 | - Exact RTP: **425/432 = 0.983796...** (1.62% house edge). | 34 | | G | 4 | |
| 35 | - Maximum win: 5 lines x 150x = **750x total bet**. | 35 | | D | 6 | |
| 36 | | S | 10 | | ||
| 37 | |||
| 38 | Shape multipliers (how many matching symbols on the line): | ||
| 39 | |||
| 40 | | Shape | Multiplier | | ||
| 41 | | --- | ---: | | ||
| 42 | | 3 of a kind | 1 | | ||
| 43 | | 4 of a kind | 3 | | ||
| 44 | | 5 of a kind | 14 | | ||
| 45 | |||
| 46 | Every winning payline is added together. | ||
| 47 | |||
| 48 | - Exact RTP: **3835/3888 = 0.986368...** (1.36% house edge). | ||
| 49 | - Maximum win: 5 lines x S(10) x 5-shape(14) = **700x total bet**. | ||
| 36 | - Chance of any win on a spin: about **13%**. | 50 | - Chance of any win on a spin: about **13%**. |
| 37 | - The RTP is checked by `SlotsGame::theoretical_rtp()` and `tests/slots.rs`. | 51 | - The RTP is checked by `SlotsGame::theoretical_rtp()` and `tests/slots.rs`. |
| 38 | 52 | ||
| @@ -67,19 +81,45 @@ The board is ten lines: | |||
| 67 | ``` | 81 | ``` |
| 68 | === SLOTS === | 82 | === SLOTS === |
| 69 | Bet: 10 PLN Balance: 1000 PLN | 83 | Bet: 10 PLN Balance: 1000 PLN |
| 70 | [ C ] [ L ] [ O ] [ G ] [ D ] | 84 | { G } [ L ] [ S ] [ O ] [ G ] |
| 71 | [ S ] { C } { C } [ O ] [ G ] | 85 | [ D ] { G } [ L ] [ S ] [ C ] |
| 72 | [ D ] [ S ] { C } [ L ] [ O ] | 86 | [ O ] [ O ] { G } [ L ] [ O ] |
| 73 | Pays 3/4/5 x bet: C 2/5/15 L 3/7/20 O 3/8/25 | 87 | Pays bet x symbol x shape |
| 74 | G 4/12/40 D 6/18/70 S 9/30/150 | 88 | Symbol C1 L2 O3 G4 D6 S10 Shape 3:1 4:3 5:14 |
| 75 | Won 20 PLN | 89 | Won 40 PLN |
| 76 | Winning lines: 2 | 90 | Winning lines: 4 (V) |
| 77 | [+/-] Bet [SPACE] Spin [Q] Quit | 91 | [+/-] Bet [SPACE] Spin [Q] Quit |
| 78 | ``` | 92 | ``` |
| 79 | 93 | ||
| 80 | The two lines above the status are the compact paytable: for each symbol, the | 94 | The two lines above the status show the payout formula and the two multiplier |
| 81 | multipliers of the total bet for 3, 4 and 5 of a kind. It is split so it does | 95 | tables. Winning cells use braces and are rendered yellow/bold in the terminal. |
| 82 | not wrap on a narrow terminal. Winning cells use braces and are rendered | 96 | The line list below the status names the paylines that won. Both come from the |
| 83 | yellow/bold in the terminal. The line list below the status names the paylines | 97 | server's `win_lines` and `win_cells`, which are already carried through the |
| 84 | that won. Both come from the server's `win_lines` and `win_cells`, which are | 98 | client state for the animation pass. Multiple winning lines are summed; the |
| 85 | already carried through the client state for the animation pass. | 99 | board lists all of them and highlights the union of their matched cells. |
| 100 | |||
| 101 | ## Reading a win | ||
| 102 | |||
| 103 | A payline is one of five fixed paths across the five reels: | ||
| 104 | |||
| 105 | | Line | Name | Rows on reels 1-5 | | ||
| 106 | | ---: | --- | --- | | ||
| 107 | | 1 | top | top, top, top, top, top | | ||
| 108 | | 2 | middle | middle, middle, middle, middle, middle | | ||
| 109 | | 3 | bottom | bottom, bottom, bottom, bottom, bottom | | ||
| 110 | | 4 | V | top, middle, bottom, middle, top | | ||
| 111 | | 5 | inverted V | bottom, middle, top, middle, bottom | | ||
| 112 | |||
| 113 | A line pays only when the first **3, 4 or 5** symbols on that line match, | ||
| 114 | reading from the leftmost reel. | ||
| 115 | |||
| 116 | In the example above, line 4 (V) contains `G G G S G`: | ||
| 117 | |||
| 118 | - reels 1-3 are `G`, so three `G` symbols pay; | ||
| 119 | - reel 4 is `S`, which breaks the run, so the `G` on reel 5 does not count; | ||
| 120 | - `G` has symbol multiplier 4 and 3-of-a-kind has shape multiplier 1, so the | ||
| 121 | win is `10 x 4 x 1 = 40 PLN`. | ||
| 122 | |||
| 123 | A diagonal of `L` symbols that is not one of the five paylines does not win, | ||
| 124 | and a run must start on reel 1. That is why only the three `G` cells are | ||
| 125 | 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. | |||
| 36 | prompt, Ctrl+C exits and restores the terminal. | 36 | prompt, Ctrl+C exits and restores the terminal. |
| 37 | 4. `help` fits 80 columns. Tab completion wraps within 76 columns and the | 37 | 4. `help` fits 80 columns. Tab completion wraps within 76 columns and the |
| 38 | cursor stays visible. | 38 | cursor stays visible. |
| 39 | 5. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and | 39 | 5. `play slots 50` shows a 5x3 grid, the `Pays bet x symbol x shape` lines, and |
| 40 | the fetched balance. | 40 | the fetched balance. |
| 41 | 6. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and | 41 | 6. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and |
| 42 | new balance inside the same 10-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 names the payline |
| 44 | line is never overwritten. Slots rules and RTP: `docs/SLOTS.md`. | 44 | (e.g. `4 (V)`). The controls line is never overwritten. Slots rules and RTP: |
| 45 | `docs/SLOTS.md`. | ||
| 45 | 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines | 46 | 7. `+` / `-` after a spin changes only the bet; no stale grid or result lines |
| 46 | appear. | 47 | appear. |
| 47 | 8. `q` clears the board. Ctrl+C in a game clears the board and exits. | 48 | 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 @@ | |||
| 1 | use super::traits::{Game, GamePhase, GameState}; | 1 | use super::traits::{Game, GamePhase, GameState}; |
| 2 | use crate::slots::{paytable_lines, REELS, ROWS}; | 2 | use crate::slots::{paytable_lines, LINE_NAMES, 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. |
| 5 | pub const SLOT_BOARD_LINES: usize = 10; | 5 | pub const SLOT_BOARD_LINES: usize = 10; |
| @@ -81,7 +81,21 @@ impl SlotsGame { | |||
| 81 | } | 81 | } |
| 82 | match state.data.get("win_lines").map(String::as_str) { | 82 | match state.data.get("win_lines").map(String::as_str) { |
| 83 | Some(lines) if !lines.is_empty() && lines != "-" => { | 83 | Some(lines) if !lines.is_empty() && lines != "-" => { |
| 84 | format!("Winning lines: {lines}") | 84 | let named: Vec<String> = lines |
| 85 | .split(',') | ||
| 86 | .filter_map(|raw| raw.trim().parse::<usize>().ok()) | ||
| 87 | .map(|line| { | ||
| 88 | let name = line | ||
| 89 | .checked_sub(1) | ||
| 90 | .and_then(|index| LINE_NAMES.get(index)) | ||
| 91 | .copied(); | ||
| 92 | match name { | ||
| 93 | Some(name) => format!("{line} ({name})"), | ||
| 94 | None => line.to_string(), | ||
| 95 | } | ||
| 96 | }) | ||
| 97 | .collect(); | ||
| 98 | format!("Winning lines: {}", named.join(", ")) | ||
| 85 | } | 99 | } |
| 86 | _ => String::new(), | 100 | _ => String::new(), |
| 87 | } | 101 | } |
| @@ -126,15 +140,14 @@ impl Game for SlotsGame { | |||
| 126 | "SLOTS GAME\n\ | 140 | "SLOTS GAME\n\ |
| 127 | =========\n\ | 141 | =========\n\ |
| 128 | 5 reels x 3 rows, 5 paylines.\n\ | 142 | 5 reels x 3 rows, 5 paylines.\n\ |
| 129 | Match 3, 4 or 5 symbols from the left on a payline.\n\ | 143 | A win needs 3, 4 or 5 matching symbols from reel 1 on a payline.\n\ |
| 130 | \n\ | 144 | \n\ |
| 131 | Payouts (x total bet, 3/4/5 of a kind):\n\ | 145 | Payout = total bet x symbol x shape\n\ |
| 132 | C: 2 / 5 / 15\n\ | 146 | Symbols: C=1 L=2 O=3 G=4 D=6 S=10\n\ |
| 133 | L: 3 / 7 / 20\n\ | 147 | Shapes: 3 of a kind=1, 4 of a kind=3, 5 of a kind=14\n\ |
| 134 | O: 3 / 8 / 25\n\ | 148 | \n\ |
| 135 | G: 4 / 12 / 40\n\ | 149 | Lines: 1 top, 2 middle, 3 bottom, 4 V, 5 inverted V\n\ |
| 136 | D: 6 / 18 / 70\n\ | 150 | Multiple winning lines are added together.\n\ |
| 137 | S: 9 / 30 / 150\n\ | ||
| 138 | \n\ | 151 | \n\ |
| 139 | Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ | 152 | Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ |
| 140 | \n\ | 153 | \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 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; |
| 2 | use crate::slots::PAYTABLE; | 2 | use crate::slots::{SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; |
| 3 | use rand::Rng; | 3 | use rand::Rng; |
| 4 | use std::collections::HashMap; | 4 | use std::collections::HashMap; |
| 5 | 5 | ||
| @@ -27,10 +27,18 @@ pub struct SlotsGame { | |||
| 27 | 27 | ||
| 28 | impl SlotsGame { | 28 | impl SlotsGame { |
| 29 | pub fn new() -> Self { | 29 | pub fn new() -> Self { |
| 30 | let payouts = PAYTABLE | 30 | // payout = bet * symbol multiplier * shape multiplier |
| 31 | let payouts = SYMBOL_MULTIPLIERS | ||
| 31 | .iter() | 32 | .iter() |
| 32 | .map(|&(symbol, three, four, five)| { | 33 | .map(|&(symbol, symbol_multiplier)| { |
| 33 | (symbol, Payout { three, four, five }) | 34 | ( |
| 35 | symbol, | ||
| 36 | Payout { | ||
| 37 | three: symbol_multiplier * SHAPE_MULTIPLIERS[0].1, | ||
| 38 | four: symbol_multiplier * SHAPE_MULTIPLIERS[1].1, | ||
| 39 | five: symbol_multiplier * SHAPE_MULTIPLIERS[2].1, | ||
| 40 | }, | ||
| 41 | ) | ||
| 34 | }) | 42 | }) |
| 35 | .collect(); | 43 | .collect(); |
| 36 | 44 | ||
| @@ -116,17 +124,15 @@ impl SlotsGame { | |||
| 116 | let p4 = 5.0 / 7776.0; | 124 | let p4 = 5.0 / 7776.0; |
| 117 | let p5 = 1.0 / 7776.0; | 125 | let p5 = 1.0 / 7776.0; |
| 118 | 126 | ||
| 119 | let per_line: f64 = self | 127 | let symbol_sum: f64 = SYMBOL_MULTIPLIERS |
| 120 | .payouts | 128 | .iter() |
| 121 | .values() | 129 | .map(|(_, multiplier)| *multiplier as f64) |
| 122 | .map(|payout| { | ||
| 123 | p3 * payout.three as f64 | ||
| 124 | + p4 * payout.four as f64 | ||
| 125 | + p5 * payout.five as f64 | ||
| 126 | }) | ||
| 127 | .sum(); | 130 | .sum(); |
| 131 | let shape_ev = p3 * SHAPE_MULTIPLIERS[0].1 as f64 | ||
| 132 | + p4 * SHAPE_MULTIPLIERS[1].1 as f64 | ||
| 133 | + p5 * SHAPE_MULTIPLIERS[2].1 as f64; | ||
| 128 | 134 | ||
| 129 | per_line * PAYLINES.len() as f64 | 135 | symbol_sum * shape_ev * PAYLINES.len() as f64 |
| 130 | } | 136 | } |
| 131 | } | 137 | } |
| 132 | 138 | ||
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] = [ | |||
| 13 | [2, 1, 0, 1, 2], // line 5: inverted V | 13 | [2, 1, 0, 1, 2], // line 5: inverted V |
| 14 | ]; | 14 | ]; |
| 15 | 15 | ||
| 16 | /// (symbol, 3-of-a-kind, 4-of-a-kind, 5-of-a-kind) multipliers of total bet. | 16 | pub const LINE_NAMES: [&str; 5] = ["top", "middle", "bottom", "V", "inverted V"]; |
| 17 | pub const PAYTABLE: [(char, u64, u64, u64); 6] = [ | 17 | |
| 18 | ('C', 2, 5, 15), | 18 | /// Symbol value multipliers. |
| 19 | ('L', 3, 7, 20), | 19 | pub const SYMBOL_MULTIPLIERS: [(char, u64); 6] = [ |
| 20 | ('O', 3, 8, 25), | 20 | ('C', 1), |
| 21 | ('G', 4, 12, 40), | 21 | ('L', 2), |
| 22 | ('D', 6, 18, 70), | 22 | ('O', 3), |
| 23 | ('S', 9, 30, 150), | 23 | ('G', 4), |
| 24 | ('D', 6), | ||
| 25 | ('S', 10), | ||
| 24 | ]; | 26 | ]; |
| 25 | 27 | ||
| 26 | /// Paytable rendered under the reels, split so it fits a narrow terminal. | 28 | /// (match length, shape multiplier): 3, 4 or 5 of a kind. |
| 29 | pub const SHAPE_MULTIPLIERS: [(usize, u64); 3] = [(3, 1), (4, 3), (5, 14)]; | ||
| 30 | |||
| 31 | /// Paytable rendered under the reels. | ||
| 32 | /// | ||
| 33 | /// `payout = bet * symbol_multiplier * shape_multiplier`. | ||
| 27 | pub fn paytable_lines() -> Vec<String> { | 34 | pub fn paytable_lines() -> Vec<String> { |
| 28 | let entries: Vec<String> = PAYTABLE | 35 | let symbols = SYMBOL_MULTIPLIERS |
| 36 | .iter() | ||
| 37 | .map(|(symbol, multiplier)| format!("{symbol}{multiplier}")) | ||
| 38 | .collect::<Vec<_>>() | ||
| 39 | .join(" "); | ||
| 40 | let shapes = SHAPE_MULTIPLIERS | ||
| 29 | .iter() | 41 | .iter() |
| 30 | .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}")) | 42 | .map(|(length, multiplier)| format!("{length}:{multiplier}")) |
| 31 | .collect(); | 43 | .collect::<Vec<_>>() |
| 44 | .join(" "); | ||
| 32 | 45 | ||
| 33 | entries | 46 | vec![ |
| 34 | .chunks(3) | 47 | "Pays bet x symbol x shape".to_string(), |
| 35 | .enumerate() | 48 | format!("Symbol {symbols} Shape {shapes}"), |
| 36 | .map(|(index, chunk)| { | 49 | ] |
| 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() | ||
| 45 | } | 50 | } |
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() { | |||
| 135 | assert_eq!(h.board().len(), 10); | 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 bet x symbol x shape")); |
| 139 | assert!(h.board()[6].contains("G 4/12/40 D 6/18/70 S 9/30/150")); | 139 | assert!(h.board()[6].contains("Symbol C1 L2 O3 G4 D6 S10")); |
| 140 | assert!(h.board()[6].contains("Shape 3:1 4:3 5:14")); | ||
| 140 | assert!(h.sent.contains(&"INFO".to_string())); | 141 | assert!(h.sent.contains(&"INFO".to_string())); |
| 141 | 142 | ||
| 142 | h.reply("INFO alice 1 1000"); | 143 | h.reply("INFO alice 1 1000"); |
| @@ -157,15 +158,15 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() { | |||
| 157 | assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); | 158 | assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); |
| 158 | assert_eq!(h.screen.lines.len(), lines_before_spin); | 159 | assert_eq!(h.screen.lines.len(), lines_before_spin); |
| 159 | 160 | ||
| 160 | h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); | 161 | h.reply("GAME_RESULT session-1 50 50 1000 CCCCLLCLODGSDGS win 1 0,3,6"); |
| 161 | assert_eq!(h.board().len(), 10); | 162 | assert_eq!(h.board().len(), 10); |
| 162 | assert!(h | 163 | assert!(h |
| 163 | .board() | 164 | .board() |
| 164 | .get(2) | 165 | .get(2) |
| 165 | .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); | 166 | .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); |
| 166 | assert!(h.board()[7].contains("Won 100 PLN")); | 167 | assert!(h.board()[7].contains("Won 50 PLN")); |
| 167 | assert!(h.board()[8].contains("Winning lines: 1")); | 168 | assert!(h.board()[8].contains("Winning lines: 1 (top)")); |
| 168 | assert!(h.board()[1].contains("Balance: 1050 PLN")); | 169 | assert!(h.board()[1].contains("Balance: 1000 PLN")); |
| 169 | assert!(h.board()[9].contains("[Q] Quit")); | 170 | assert!(h.board()[9].contains("[Q] Quit")); |
| 170 | assert_eq!(h.screen.lines.len(), lines_before_spin); | 171 | assert_eq!(h.screen.lines.len(), lines_before_spin); |
| 171 | } | 172 | } |
| @@ -175,7 +176,7 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { | |||
| 175 | let mut h = Harness::new(); | 176 | let mut h = Harness::new(); |
| 176 | h.enter_slots("50"); | 177 | h.enter_slots("50"); |
| 177 | h.key(Key::Char(' ')); | 178 | h.key(Key::Char(' ')); |
| 178 | h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); | 179 | h.reply("GAME_RESULT session-1 50 50 1000 CCCCLLCLODGSDGS win 1 0,3,6"); |
| 179 | let lines_before_bet = h.screen.lines.len(); | 180 | let lines_before_bet = h.screen.lines.len(); |
| 180 | 181 | ||
| 181 | h.key(Key::Char('+')); | 182 | h.key(Key::Char('+')); |
| @@ -217,11 +218,11 @@ fn a_mega_win_shows_all_winning_lines() { | |||
| 217 | h.enter_slots("10"); | 218 | h.enter_slots("10"); |
| 218 | h.key(Key::Char(' ')); | 219 | h.key(Key::Char(' ')); |
| 219 | 220 | ||
| 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 | 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")); |
| 221 | 222 | ||
| 222 | assert_eq!(h.board().len(), 10); | 223 | assert_eq!(h.board().len(), 10); |
| 223 | assert!(h.board()[7].contains("MEGA WIN! 750 PLN")); | 224 | assert!(h.board()[7].contains("MEGA WIN! 700 PLN")); |
| 224 | assert!(h.board()[8].contains("Winning lines: 1,2,3,4,5")); | 225 | assert!(h.board()[8].contains("Winning lines: 1 (top), 2 (middle), 3 (bottom), 4 (V), 5 (inverted V)")); |
| 225 | assert_eq!(h.board()[2].matches("{ C }").count(), 5); | 226 | assert_eq!(h.board()[2].matches("{ C }").count(), 5); |
| 226 | } | 227 | } |
| 227 | 228 | ||
| @@ -521,7 +522,7 @@ fn disconnect_clears_connection_state() { | |||
| 521 | 522 | ||
| 522 | #[test] | 523 | #[test] |
| 523 | fn parses_a_five_by_three_result_with_winning_lines() { | 524 | fn parses_a_five_by_three_result_with_winning_lines() { |
| 524 | let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1 0,3,6"); | 525 | let response = format!("GAME_RESULT session-1 50 50 1000 {WIN3} win 1 0,3,6"); |
| 525 | let result = parse_spin_response(&response).expect("valid 5x3 result"); | 526 | let result = parse_spin_response(&response).expect("valid 5x3 result"); |
| 526 | 527 | ||
| 527 | assert_eq!(result.reels.chars().count(), 15); | 528 | 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() { | |||
| 188 | 188 | ||
| 189 | let response = core.handle_message(&mut state, "PLAY_START slots 10"); | 189 | let response = core.handle_message(&mut state, "PLAY_START slots 10"); |
| 190 | assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); | 190 | assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); |
| 191 | assert!(response.contains(" 20 10 1010 "), "response: {response}"); | 191 | assert!(response.contains(" 10 10 1000 "), "response: {response}"); |
| 192 | assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); | 192 | assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); |
| 193 | 193 | ||
| 194 | assert_eq!(core.balance("alice"), Some(1010)); | 194 | assert_eq!(core.balance("alice"), Some(1000)); |
| 195 | assert_eq!(core.play_count("alice"), 1); | 195 | assert_eq!(core.play_count("alice"), 1); |
| 196 | assert_eq!(core.session_count(), 0); | 196 | assert_eq!(core.session_count(), 0); |
| 197 | } | 197 | } |
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 @@ | |||
| 1 | //! Regression tests for the server-side 5x3 slots game. | 1 | //! Regression tests for the server-side 5x3 slots game. |
| 2 | 2 | ||
| 3 | use zagan_bet::slots::{paytable_lines, PAYTABLE}; | 3 | use zagan_bet::slots::{paytable_lines, LINE_NAMES, SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; |
| 4 | use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; | 4 | use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; |
| 5 | use zagan_bet::server::games::traits::{Game, GamePhase}; | 5 | use zagan_bet::server::games::traits::{Game, GamePhase}; |
| 6 | 6 | ||
| @@ -44,7 +44,7 @@ fn the_wire_grid_is_five_reels_by_three_rows() { | |||
| 44 | fn three_of_a_kind_on_the_top_line_pays() { | 44 | fn three_of_a_kind_on_the_top_line_pays() { |
| 45 | let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); | 45 | let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); |
| 46 | 46 | ||
| 47 | assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); | 47 | assert_eq!(winnings, 10, "CCC: 10 * C(1) * shape3(1)"); |
| 48 | assert_eq!(lines(&wins), vec![1]); | 48 | assert_eq!(lines(&wins), vec![1]); |
| 49 | assert_eq!(wins[0].cells, vec![0, 3, 6]); | 49 | assert_eq!(wins[0].cells, vec![0, 3, 6]); |
| 50 | } | 50 | } |
| @@ -53,7 +53,7 @@ fn three_of_a_kind_on_the_top_line_pays() { | |||
| 53 | fn four_of_a_kind_pays_more_than_three() { | 53 | fn four_of_a_kind_pays_more_than_three() { |
| 54 | let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); | 54 | let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); |
| 55 | 55 | ||
| 56 | assert_eq!(winnings, 50, "CCCC pays 5x total bet"); | 56 | assert_eq!(winnings, 30, "CCCC: 10 * C(1) * shape4(3)"); |
| 57 | assert_eq!(lines(&wins), vec![1]); | 57 | assert_eq!(lines(&wins), vec![1]); |
| 58 | assert_eq!(wins[0].cells, vec![0, 3, 6, 9]); | 58 | assert_eq!(wins[0].cells, vec![0, 3, 6, 9]); |
| 59 | } | 59 | } |
| @@ -62,7 +62,7 @@ fn four_of_a_kind_pays_more_than_three() { | |||
| 62 | fn five_of_a_kind_pays_all_lines() { | 62 | fn five_of_a_kind_pays_all_lines() { |
| 63 | let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); | 63 | let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); |
| 64 | 64 | ||
| 65 | assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); | 65 | assert_eq!(winnings, 7000, "five SSSSS lines: 5 * 10 * S(10) * shape5(14)"); |
| 66 | assert_eq!(lines(&wins), vec![1, 2, 3, 4, 5]); | 66 | assert_eq!(lines(&wins), vec![1, 2, 3, 4, 5]); |
| 67 | assert_eq!(wins[0].cells, vec![0, 3, 6, 9, 12]); | 67 | assert_eq!(wins[0].cells, vec![0, 3, 6, 9, 12]); |
| 68 | assert_eq!(wins[3].cells, vec![0, 4, 8, 10, 12]); | 68 | assert_eq!(wins[3].cells, vec![0, 4, 8, 10, 12]); |
| @@ -73,7 +73,7 @@ fn five_of_a_kind_pays_all_lines() { | |||
| 73 | fn the_v_payline_is_evaluated() { | 73 | fn the_v_payline_is_evaluated() { |
| 74 | let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10); | 74 | let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10); |
| 75 | 75 | ||
| 76 | assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); | 76 | assert_eq!(winnings, 140, "V line: 10 * C(1) * shape5(14)"); |
| 77 | assert_eq!(lines(&wins), vec![4]); | 77 | assert_eq!(lines(&wins), vec![4]); |
| 78 | assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]); | 78 | assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]); |
| 79 | } | 79 | } |
| @@ -90,7 +90,7 @@ fn no_matching_line_pays_nothing() { | |||
| 90 | fn winning_lines_are_added_together() { | 90 | fn winning_lines_are_added_together() { |
| 91 | let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); | 91 | let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); |
| 92 | 92 | ||
| 93 | assert_eq!(winnings, (150 + 20 + 25) * 10); | 93 | assert_eq!(winnings, (10 + 2 + 3) * 14 * 10); |
| 94 | assert_eq!(lines(&wins), vec![1, 2, 3]); | 94 | assert_eq!(lines(&wins), vec![1, 2, 3]); |
| 95 | 95 | ||
| 96 | let mut cells: Vec<usize> = wins.iter().flat_map(|win| win.cells.clone()).collect(); | 96 | let mut cells: Vec<usize> = wins.iter().flat_map(|win| win.cells.clone()).collect(); |
| @@ -102,21 +102,35 @@ fn winning_lines_are_added_together() { | |||
| 102 | fn theoretical_rtp_is_just_under_one() { | 102 | fn theoretical_rtp_is_just_under_one() { |
| 103 | let rtp = SlotsGame::new().theoretical_rtp(); | 103 | let rtp = SlotsGame::new().theoretical_rtp(); |
| 104 | 104 | ||
| 105 | assert!((rtp - 425.0 / 432.0).abs() < 1e-12); | 105 | assert!((rtp - 3835.0 / 3888.0).abs() < 1e-12); |
| 106 | assert!(rtp < 1.0, "house edge must be positive"); | 106 | assert!(rtp < 1.0, "house edge must be positive"); |
| 107 | assert!(rtp > 0.98, "house edge should be tiny"); | 107 | assert!(rtp > 0.98, "house edge should be tiny"); |
| 108 | } | 108 | } |
| 109 | 109 | ||
| 110 | #[test] | 110 | #[test] |
| 111 | fn the_paytable_lines_list_every_symbol_and_fit_80_columns() { | 111 | fn payline_names_match_the_paylines() { |
| 112 | assert_eq!(LINE_NAMES.len(), PAYLINES.len()); | ||
| 113 | assert_eq!(LINE_NAMES[3], "V"); | ||
| 114 | assert_eq!(LINE_NAMES[4], "inverted V"); | ||
| 115 | } | ||
| 116 | |||
| 117 | #[test] | ||
| 118 | fn the_paytable_lines_show_the_product_formula_and_fit_80_columns() { | ||
| 112 | let lines = paytable_lines(); | 119 | let lines = paytable_lines(); |
| 113 | let joined = lines.join(" "); | 120 | let joined = lines.join(" "); |
| 114 | 121 | ||
| 115 | assert_eq!(lines.len(), 2, "paytable should be split into two lines"); | 122 | assert_eq!(lines.len(), 2, "paytable should be two lines"); |
| 116 | for (symbol, three, four, five) in PAYTABLE { | 123 | assert!(joined.contains("Pays bet x symbol x shape")); |
| 124 | for (symbol, multiplier) in SYMBOL_MULTIPLIERS { | ||
| 125 | assert!( | ||
| 126 | joined.contains(&format!("{symbol}{multiplier}")), | ||
| 127 | "paytable is missing symbol {symbol}" | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | for (length, multiplier) in SHAPE_MULTIPLIERS { | ||
| 117 | assert!( | 131 | assert!( |
| 118 | joined.contains(&format!("{symbol} {three}/{four}/{five}")), | 132 | joined.contains(&format!("{length}:{multiplier}")), |
| 119 | "paytable is missing {symbol}" | 133 | "paytable is missing shape {length}" |
| 120 | ); | 134 | ); |
| 121 | } | 135 | } |
| 122 | for line in lines { | 136 | for line in lines { |
| @@ -140,7 +154,7 @@ fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() { | |||
| 140 | assert_eq!(state.phase, GamePhase::Finished); | 154 | assert_eq!(state.phase, GamePhase::Finished); |
| 141 | 155 | ||
| 142 | let result = game.calculate_result(&state).unwrap(); | 156 | let result = game.calculate_result(&state).unwrap(); |
| 143 | assert_eq!(result.winnings, 20); | 157 | assert_eq!(result.winnings, 10); |
| 144 | assert_eq!(result.result_data.get("win_type").unwrap(), "win"); | 158 | assert_eq!(result.result_data.get("win_type").unwrap(), "win"); |
| 145 | assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); | 159 | assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); |
| 146 | assert_eq!(result.result_data.get("win_cells").unwrap(), "0,3,6"); | 160 | assert_eq!(result.result_data.get("win_cells").unwrap(), "0,3,6"); |
