summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 23:45:43 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 23:45:43 +0100
commit9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e (patch)
treee1179b0523356dfb5d43f3d6d4614203f6fa8dec
parentv1.2.1: track wrapped board lines and split the paytable (diff)
downloadzagan-bet-9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e.tar.gz
zagan-bet-9951e2d5afbcabb4bd8016b6d160653c1ec5ed8e.zip
v1.3.0: simplify payouts to bet x symbol x shape
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SLOTS.md92
-rw-r--r--docs/TESTING.md7
-rw-r--r--src/client/games/slots.rs33
-rw-r--r--src/server/games/slots.rs32
-rw-r--r--src/slots.rs53
-rw-r--r--tests/client_core.rs23
-rw-r--r--tests/server_core.rs4
-rw-r--r--tests/slots.rs40
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 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "1.2.1" 3version = "1.3.0"
4edition = "2024" 4edition = "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
23Multipliers are applied to the total bet. 23```
24 24payout = 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 | 27Symbol 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
38Shape 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
46Every 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 ===
69Bet: 10 PLN Balance: 1000 PLN 83Bet: 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 ]
73Pays 3/4/5 x bet: C 2/5/15 L 3/7/20 O 3/8/25 87Pays bet x symbol x shape
74G 4/12/40 D 6/18/70 S 9/30/150 88Symbol C1 L2 O3 G4 D6 S10 Shape 3:1 4:3 5:14
75Won 20 PLN 89Won 40 PLN
76Winning lines: 2 90Winning lines: 4 (V)
77[+/-] Bet [SPACE] Spin [Q] Quit 91[+/-] Bet [SPACE] Spin [Q] Quit
78``` 92```
79 93
80The two lines above the status are the compact paytable: for each symbol, the 94The two lines above the status show the payout formula and the two multiplier
81multipliers of the total bet for 3, 4 and 5 of a kind. It is split so it does 95tables. Winning cells use braces and are rendered yellow/bold in the terminal.
82not wrap on a narrow terminal. Winning cells use braces and are rendered 96The line list below the status names the paylines that won. Both come from the
83yellow/bold in the terminal. The line list below the status names the paylines 97server's `win_lines` and `win_cells`, which are already carried through the
84that won. Both come from the server's `win_lines` and `win_cells`, which are 98client state for the animation pass. Multiple winning lines are summed; the
85already carried through the client state for the animation pass. 99board lists all of them and highlights the union of their matched cells.
100
101## Reading a win
102
103A 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
113A line pays only when the first **3, 4 or 5** symbols on that line match,
114reading from the leftmost reel.
115
116In 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
123A diagonal of `L` symbols that is not one of the five paylines does not win,
124and a run must start on reel 1. That is why only the three `G` cells are
125marked 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.
374. `help` fits 80 columns. Tab completion wraps within 76 columns and the 374. `help` fits 80 columns. Tab completion wraps within 76 columns and the
38 cursor stays visible. 38 cursor stays visible.
395. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and 395. `play slots 50` shows a 5x3 grid, the `Pays bet x symbol x shape` lines, and
40 the fetched balance. 40 the fetched balance.
416. Space shows `Spinning...`, then the 3 rows, win text, winning-line list, and 416. 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`.
457. `+` / `-` after a spin changes only the bet; no stale grid or result lines 467. `+` / `-` after a spin changes only the bet; no stale grid or result lines
46 appear. 47 appear.
478. `q` clears the board. Ctrl+C in a game clears the board and exits. 488. `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 @@
1use super::traits::{Game, GamePhase, GameState}; 1use super::traits::{Game, GamePhase, GameState};
2use crate::slots::{paytable_lines, REELS, ROWS}; 2use 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.
5pub const SLOT_BOARD_LINES: usize = 10; 5pub 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 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; 1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::slots::PAYTABLE; 2use crate::slots::{SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS};
3use rand::Rng; 3use rand::Rng;
4use std::collections::HashMap; 4use std::collections::HashMap;
5 5
@@ -27,10 +27,18 @@ pub struct SlotsGame {
27 27
28impl SlotsGame { 28impl 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. 16pub const LINE_NAMES: [&str; 5] = ["top", "middle", "bottom", "V", "inverted V"];
17pub const PAYTABLE: [(char, u64, u64, u64); 6] = [ 17
18 ('C', 2, 5, 15), 18/// Symbol value multipliers.
19 ('L', 3, 7, 20), 19pub 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.
29pub 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`.
27pub fn paytable_lines() -> Vec<String> { 34pub 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]
523fn parses_a_five_by_three_result_with_winning_lines() { 524fn 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
3use zagan_bet::slots::{paytable_lines, PAYTABLE}; 3use zagan_bet::slots::{paytable_lines, LINE_NAMES, SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS};
4use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; 4use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS};
5use zagan_bet::server::games::traits::{Game, GamePhase}; 5use zagan_bet::server::games::traits::{Game, GamePhase};
6 6
@@ -44,7 +44,7 @@ fn the_wire_grid_is_five_reels_by_three_rows() {
44fn three_of_a_kind_on_the_top_line_pays() { 44fn 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() {
53fn four_of_a_kind_pays_more_than_three() { 53fn 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() {
62fn five_of_a_kind_pays_all_lines() { 62fn 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() {
73fn the_v_payline_is_evaluated() { 73fn 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() {
90fn winning_lines_are_added_together() { 90fn 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() {
102fn theoretical_rtp_is_just_under_one() { 102fn 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]
111fn the_paytable_lines_list_every_symbol_and_fit_80_columns() { 111fn 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]
118fn 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");