diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:24:08 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:24:08 +0100 |
| commit | 33452f55afcad603f72e054d56ad7848a3d09503 (patch) | |
| tree | fcef1b0e4a88284ff3fc652fbaecdb087413a17f | |
| parent | v1.1.0: highlight winning symbols on the slots board (diff) | |
| download | zagan-bet-33452f55afcad603f72e054d56ad7848a3d09503.tar.gz zagan-bet-33452f55afcad603f72e054d56ad7848a3d09503.zip | |
v1.2.0: show the symbol paytable on the slots board
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SLOTS.md | 14 | ||||
| -rw-r--r-- | docs/TESTING.md | 3 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 8 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/server/games/slots.rs | 29 | ||||
| -rw-r--r-- | src/slots.rs | 33 | ||||
| -rw-r--r-- | tests/client_core.rs | 4 | ||||
| -rw-r--r-- | tests/slots.rs | 14 |
9 files changed, 73 insertions, 35 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "1.1.0" | 3 | version = "1.2.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
diff --git a/docs/SLOTS.md b/docs/SLOTS.md index a64bdbf..a2e778d 100644 --- a/docs/SLOTS.md +++ b/docs/SLOTS.md | |||
| @@ -66,17 +66,19 @@ The board is nine lines: | |||
| 66 | 66 | ||
| 67 | ``` | 67 | ``` |
| 68 | === SLOTS === | 68 | === SLOTS === |
| 69 | Bet: 10 PLN Lines: 5 Balance: 1000 PLN | 69 | Bet: 10 PLN Balance: 1000 PLN |
| 70 | [ C ] [ L ] [ O ] [ G ] [ D ] | 70 | [ C ] [ L ] [ O ] [ G ] [ D ] |
| 71 | [ S ] { C } { C } [ O ] [ G ] | 71 | [ S ] { C } { C } [ O ] [ G ] |
| 72 | [ D ] [ S ] { C } [ L ] [ O ] | 72 | [ D ] [ S ] { C } [ L ] [ O ] |
| 73 | 73 | 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 | |
| 74 | Won 20 PLN | 74 | Won 20 PLN |
| 75 | Winning lines: 2 | 75 | Winning lines: 2 |
| 76 | [+/-] Bet [SPACE] Spin [Q] Quit | 76 | [+/-] Bet [SPACE] Spin [Q] Quit |
| 77 | ``` | 77 | ``` |
| 78 | 78 | ||
| 79 | Winning cells use braces and are rendered yellow/bold in the terminal. The | 79 | The line above the status is the compact paytable: for each symbol, the |
| 80 | line list below the status names the paylines that won. Both come from the | 80 | multipliers of the total bet for 3, 4 and 5 of a kind. Winning cells use |
| 81 | server's `win_lines` and `win_cells`, which are already carried through the | 81 | braces and are rendered yellow/bold in the terminal. The line list below the |
| 82 | client state for the animation pass. | 82 | status names the paylines that won. Both come from the server's `win_lines` |
| 83 | and `win_cells`, which are already carried through the client state for the | ||
| 84 | animation pass. | ||
diff --git a/docs/TESTING.md b/docs/TESTING.md index 8e35c5a..7f54798 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md | |||
| @@ -36,7 +36,8 @@ 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, `Lines: 5`, and the fetched balance. | 39 | 5. `play slots 50` shows a 5x3 grid, the `Pays 3/4/5 x bet:` paytable line, and |
| 40 | the fetched balance. | ||
| 40 | 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 |
| 41 | new balance inside the same 9-line board. Winning symbols appear as | 42 | new balance inside the same 9-line board. Winning symbols appear as |
| 42 | yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls | 43 | yellow/bold `{ X }` cells; the `Winning lines:` list matches. The controls |
diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index c367fcb..7e59919 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs | |||
| @@ -1,10 +1,8 @@ | |||
| 1 | use super::traits::{Game, GamePhase, GameState}; | 1 | use super::traits::{Game, GamePhase, GameState}; |
| 2 | use crate::slots::{paytable_line, REELS, ROWS}; | ||
| 2 | 3 | ||
| 3 | /// 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. |
| 4 | pub const SLOT_BOARD_LINES: usize = 9; | 5 | pub const SLOT_BOARD_LINES: usize = 9; |
| 5 | pub const LINES: usize = 5; | ||
| 6 | pub const REELS: usize = 5; | ||
| 7 | pub const ROWS: usize = 3; | ||
| 8 | 6 | ||
| 9 | pub struct SlotsGame; | 7 | pub struct SlotsGame; |
| 10 | 8 | ||
| @@ -106,13 +104,13 @@ impl Game for SlotsGame { | |||
| 106 | let grid = self.render_grid(&reels, &winning); | 104 | let grid = self.render_grid(&reels, &winning); |
| 107 | 105 | ||
| 108 | format!( | 106 | format!( |
| 109 | "=== SLOTS ===\r\nBet: {} PLN Lines: {} Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", | 107 | "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", |
| 110 | state.bet_amount, | 108 | state.bet_amount, |
| 111 | LINES, | ||
| 112 | balance, | 109 | balance, |
| 113 | grid[0], | 110 | grid[0], |
| 114 | grid[1], | 111 | grid[1], |
| 115 | grid[2], | 112 | grid[2], |
| 113 | paytable_line(), | ||
| 116 | self.render_status(state), | 114 | self.render_status(state), |
| 117 | self.render_win_lines(state) | 115 | self.render_win_lines(state) |
| 118 | ) | 116 | ) |
| @@ -8,4 +8,5 @@ | |||
| 8 | pub mod client; | 8 | pub mod client; |
| 9 | pub mod protocol; | 9 | pub mod protocol; |
| 10 | pub mod server; | 10 | pub mod server; |
| 11 | pub mod slots; | ||
| 11 | pub mod tofu; | 12 | pub mod tofu; |
diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs index 1d71c9d..e9f58b1 100644 --- a/src/server/games/slots.rs +++ b/src/server/games/slots.rs | |||
| @@ -1,19 +1,9 @@ | |||
| 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 rand::Rng; | 3 | use rand::Rng; |
| 3 | use std::collections::HashMap; | 4 | use std::collections::HashMap; |
| 4 | 5 | ||
| 5 | pub const REELS: usize = 5; | 6 | pub use crate::slots::{PAYLINES, REELS, ROWS, SYMBOLS}; |
| 6 | pub const ROWS: usize = 3; | ||
| 7 | pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; | ||
| 8 | |||
| 9 | /// Five paylines, each entry is the row used on reel 0..4. | ||
| 10 | pub const PAYLINES: [[usize; REELS]; 5] = [ | ||
| 11 | [0, 0, 0, 0, 0], // line 1: top | ||
| 12 | [1, 1, 1, 1, 1], // line 2: middle | ||
| 13 | [2, 2, 2, 2, 2], // line 3: bottom | ||
| 14 | [0, 1, 2, 1, 0], // line 4: V | ||
| 15 | [2, 1, 0, 1, 2], // line 5: inverted V | ||
| 16 | ]; | ||
| 17 | 7 | ||
| 18 | #[derive(Clone, Copy)] | 8 | #[derive(Clone, Copy)] |
| 19 | struct Payout { | 9 | struct Payout { |
| @@ -37,15 +27,12 @@ pub struct SlotsGame { | |||
| 37 | 27 | ||
| 38 | impl SlotsGame { | 28 | impl SlotsGame { |
| 39 | pub fn new() -> Self { | 29 | pub fn new() -> Self { |
| 40 | let mut payouts = HashMap::new(); | 30 | let payouts = PAYTABLE |
| 41 | 31 | .iter() | |
| 42 | // RTP = 425/432 = 0.9838 (1.62% house edge) with five paylines. | 32 | .map(|&(symbol, three, four, five)| { |
| 43 | payouts.insert('C', Payout { three: 2, four: 5, five: 15 }); | 33 | (symbol, Payout { three, four, five }) |
| 44 | payouts.insert('L', Payout { three: 3, four: 7, five: 20 }); | 34 | }) |
| 45 | payouts.insert('O', Payout { three: 3, four: 8, five: 25 }); | 35 | .collect(); |
| 46 | payouts.insert('G', Payout { three: 4, four: 12, five: 40 }); | ||
| 47 | payouts.insert('D', Payout { three: 6, four: 18, five: 70 }); | ||
| 48 | payouts.insert('S', Payout { three: 9, four: 30, five: 150 }); | ||
| 49 | 36 | ||
| 50 | Self { | 37 | Self { |
| 51 | payouts, | 38 | payouts, |
diff --git a/src/slots.rs b/src/slots.rs new file mode 100644 index 0000000..561c449 --- /dev/null +++ b/src/slots.rs | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | //! Shared slots rules used by both the server engine and the client board. | ||
| 2 | |||
| 3 | pub const REELS: usize = 5; | ||
| 4 | pub const ROWS: usize = 3; | ||
| 5 | pub const SYMBOLS: [char; 6] = ['C', 'L', 'O', 'G', 'D', 'S']; | ||
| 6 | |||
| 7 | /// Five paylines, each entry is the row used on reel 0..4. | ||
| 8 | pub const PAYLINES: [[usize; REELS]; 5] = [ | ||
| 9 | [0, 0, 0, 0, 0], // line 1: top | ||
| 10 | [1, 1, 1, 1, 1], // line 2: middle | ||
| 11 | [2, 2, 2, 2, 2], // line 3: bottom | ||
| 12 | [0, 1, 2, 1, 0], // line 4: V | ||
| 13 | [2, 1, 0, 1, 2], // line 5: inverted V | ||
| 14 | ]; | ||
| 15 | |||
| 16 | /// (symbol, 3-of-a-kind, 4-of-a-kind, 5-of-a-kind) multipliers of total bet. | ||
| 17 | pub const PAYTABLE: [(char, u64, u64, u64); 6] = [ | ||
| 18 | ('C', 2, 5, 15), | ||
| 19 | ('L', 3, 7, 20), | ||
| 20 | ('O', 3, 8, 25), | ||
| 21 | ('G', 4, 12, 40), | ||
| 22 | ('D', 6, 18, 70), | ||
| 23 | ('S', 9, 30, 150), | ||
| 24 | ]; | ||
| 25 | |||
| 26 | /// One-line paytable rendered under the reels. | ||
| 27 | pub fn paytable_line() -> String { | ||
| 28 | let entries: Vec<String> = PAYTABLE | ||
| 29 | .iter() | ||
| 30 | .map(|(symbol, three, four, five)| format!("{symbol} {three}/{four}/{five}")) | ||
| 31 | .collect(); | ||
| 32 | format!("Pays 3/4/5 x bet: {}", entries.join(" ")) | ||
| 33 | } | ||
diff --git a/tests/client_core.rs b/tests/client_core.rs index 91ddcab..ea99d52 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -134,7 +134,9 @@ fn play_shows_a_nine_line_board_and_fetches_the_balance() { | |||
| 134 | 134 | ||
| 135 | assert_eq!(h.board().len(), 9); | 135 | assert_eq!(h.board().len(), 9); |
| 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: 5")); | 137 | assert!(!h.board()[1].contains("Lines:")); |
| 138 | assert!(h.board()[5].contains("Pays 3/4/5 x bet:")); | ||
| 139 | assert!(h.board()[5].contains("S 9/30/150")); | ||
| 138 | assert!(h.sent.contains(&"INFO".to_string())); | 140 | assert!(h.sent.contains(&"INFO".to_string())); |
| 139 | 141 | ||
| 140 | h.reply("INFO alice 1 1000"); | 142 | h.reply("INFO alice 1 1000"); |
diff --git a/tests/slots.rs b/tests/slots.rs index 857e607..b7292b8 100644 --- a/tests/slots.rs +++ b/tests/slots.rs | |||
| @@ -1,5 +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_line, PAYTABLE}; | ||
| 3 | use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; | 4 | use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; |
| 4 | use zagan_bet::server::games::traits::{Game, GamePhase}; | 5 | use zagan_bet::server::games::traits::{Game, GamePhase}; |
| 5 | 6 | ||
| @@ -107,6 +108,19 @@ fn theoretical_rtp_is_just_under_one() { | |||
| 107 | } | 108 | } |
| 108 | 109 | ||
| 109 | #[test] | 110 | #[test] |
| 111 | fn the_paytable_line_lists_every_symbol_and_fits_80_columns() { | ||
| 112 | let line = paytable_line(); | ||
| 113 | |||
| 114 | for (symbol, three, four, five) in PAYTABLE { | ||
| 115 | assert!( | ||
| 116 | line.contains(&format!("{symbol} {three}/{four}/{five}")), | ||
| 117 | "paytable is missing {symbol}" | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | assert!(line.len() <= 80, "paytable is too wide: {line:?}"); | ||
| 121 | } | ||
| 122 | |||
| 123 | #[test] | ||
| 110 | fn an_enormous_bet_saturates_instead_of_overflowing() { | 124 | fn an_enormous_bet_saturates_instead_of_overflowing() { |
| 111 | let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX); | 125 | let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX); |
| 112 | 126 | ||
