summaryrefslogtreecommitdiffstats
path: root/tests/slots.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 23:13:24 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 23:13:24 +0100
commitfc59ef77cda14d97d854dfcaa848307cfa512b69 (patch)
treeb0fc97390b96078e0a0538e76d7c76f7c04135f1 /tests/slots.rs
parentv0.4.0: add first-time TLS setup and server help (diff)
downloadzagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.tar.gz
zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.zip
v1.0.0: replace slots with 5x3 reels and five paylines
Diffstat (limited to 'tests/slots.rs')
-rw-r--r--tests/slots.rs159
1 files changed, 91 insertions, 68 deletions
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 @@
1//! Regression tests for the server-side slots game logic. 1//! Regression tests for the server-side 5x3 slots game.
2 2
3use zagan_bet::server::games::slots::SlotsGame; 3use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS};
4use zagan_bet::server::games::traits::{Game, GamePhase}; 4use zagan_bet::server::games::traits::{Game, GamePhase};
5 5
6fn make_game() -> SlotsGame { 6/// Convert row-major test rows into the column-major wire format.
7 SlotsGame::new() 7fn grid(rows: [&str; 3]) -> String {
8 let cells: Vec<Vec<char>> = rows.iter().map(|row| row.chars().collect()).collect();
9 let mut result = String::new();
10 for col in 0..REELS {
11 for row in 0..ROWS {
12 result.push(cells[row][col]);
13 }
14 }
15 result
16}
17
18fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec<usize>) {
19 let reels = grid(rows);
20 let game = SlotsGame::with_grid(&reels);
21 let cells: Vec<char> = reels.chars().collect();
22 game.calculate_payout(&cells, bet)
23}
24
25#[test]
26fn the_wire_grid_is_five_reels_by_three_rows() {
27 assert_eq!(REELS, 5);
28 assert_eq!(ROWS, 3);
29 assert_eq!(PAYLINES.len(), 5);
30
31 let game = SlotsGame::new();
32 let state = game.prepare_state("alice", 10);
33 let state = game.process_action(&state, "spin", &[]).unwrap();
34 let reels = state.data.get("reels").unwrap();
35 assert_eq!(reels.chars().count(), REELS * ROWS);
8} 36}
9 37
10/// Three of a kind pays the three-of-a-kind multiplier.
11#[test] 38#[test]
12fn three_of_a_kind_pays() { 39fn three_of_a_kind_on_the_top_line_pays() {
13 let game = make_game(); 40 let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10);
14 // CCC = 2x 41
15 assert_eq!(game.calculate_payout(&['C', 'C', 'C'], 100), 200); 42 assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet");
16 // SSS = 28x 43 assert_eq!(lines, vec![1]);
17 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], 10), 280);
18} 44}
19 45
20/// Two adjacent matching symbols pays the two-of-a-kind multiplier.
21#[test] 46#[test]
22fn two_adjacent_pays() { 47fn four_of_a_kind_pays_more_than_three() {
23 let game = make_game(); 48 let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10);
24 // CC? = 1x 49
25 assert_eq!(game.calculate_payout(&['C', 'C', 'D'], 100), 100); 50 assert_eq!(winnings, 50, "CCCC pays 5x total bet");
26 // ?LL = 1x 51 assert_eq!(lines, vec![1]);
27 assert_eq!(game.calculate_payout(&['D', 'L', 'L'], 50), 50);
28} 52}
29 53
30/// Non-adjacent matching symbols (e.g. [C,D,C]) must also pay.
31/// This was a bug: the old code only checked pairs (0,1) and (1,2).
32#[test] 54#[test]
33fn two_non_adjacent_pays() { 55fn five_of_a_kind_pays_all_lines() {
34 let game = make_game(); 56 let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10);
35 // C,D,C — two cherries at positions 0 and 2 57
36 assert_eq!(game.calculate_payout(&['C', 'D', 'C'], 100), 100); 58 assert_eq!(winnings, 7500, "five SSSSS lines at 150x each");
37 // S,X,S — two sevens at positions 0 and 2 59 assert_eq!(lines, vec![1, 2, 3, 4, 5]);
38 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], 10), 30);
39 // L,O,L
40 assert_eq!(game.calculate_payout(&['L', 'O', 'L'], 50), 50);
41} 60}
42 61
43/// No matching symbols returns 0.
44#[test] 62#[test]
45fn no_match_returns_zero() { 63fn the_v_payline_is_evaluated() {
46 let game = make_game(); 64 let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10);
47 assert_eq!(game.calculate_payout(&['C', 'L', 'O'], 100), 0); 65
48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); 66 assert_eq!(winnings, 150, "V line with five C symbols pays 15x");
67 assert_eq!(lines, vec![4]);
49} 68}
50 69
51#[test] 70#[test]
52fn an_enormous_bet_saturates_instead_of_overflowing() { 71fn no_matching_line_pays_nothing() {
53 let game = make_game(); 72 let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100);
54 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX); 73
55 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX); 74 assert_eq!(winnings, 0);
75 assert!(lines.is_empty());
56} 76}
57 77
58/// process_action produces a finished state with correct reels and win_type.
59#[test] 78#[test]
60fn process_action_spin_sets_finished() { 79fn winning_lines_are_added_together() {
61 let game = make_game(); 80 let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10);
62 let state = game.prepare_state("alice", 100); 81
63 assert_eq!(state.phase, GamePhase::WaitingToStart); 82 assert_eq!(winnings, (150 + 20 + 25) * 10);
64 83 assert_eq!(lines, vec![1, 2, 3]);
65 // Manually set reels by going through process_action. 84}
66 // Since spin is random, we test the state machine rather than the outcome. 85
67 let result = game.process_action(&state, "spin", &[]); 86#[test]
68 assert!(result.is_ok()); 87fn theoretical_rtp_is_just_under_one() {
69 let new_state = result.unwrap(); 88 let rtp = SlotsGame::new().theoretical_rtp();
70 assert_eq!(new_state.phase, GamePhase::Finished); 89
71 assert!(new_state.data.contains_key("reels")); 90 assert!((rtp - 425.0 / 432.0).abs() < 1e-12);
72 assert!(new_state.data.contains_key("win_type")); 91 assert!(rtp < 1.0, "house edge must be positive");
92 assert!(rtp > 0.98, "house edge should be tiny");
93}
94
95#[test]
96fn an_enormous_bet_saturates_instead_of_overflowing() {
97 let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX);
98
99 assert_eq!(winnings, u64::MAX);
73} 100}
74 101
75/// calculate_result on a finished state returns valid GameResult.
76#[test] 102#[test]
77fn calculate_result_on_finished_state() { 103fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() {
78 let game = make_game(); 104 let game = SlotsGame::with_grid(&grid(["CCCDD", "CLLGG", "CLOSS"]));
79 let mut state = game.prepare_state("alice", 100); 105 let state = game.prepare_state("alice", 10);
80 state.phase = GamePhase::Finished; 106
81 state 107 let state = game.process_action(&state, "spin", &[]).unwrap();
82 .data 108 assert_eq!(state.phase, GamePhase::Finished);
83 .insert("reels".to_string(), "CCD".to_string());
84 state
85 .data
86 .insert("win_type".to_string(), "two".to_string());
87 109
88 let result = game.calculate_result(&state).unwrap(); 110 let result = game.calculate_result(&state).unwrap();
89 assert_eq!(result.bet_amount, 100); 111 assert_eq!(result.winnings, 20);
90 assert_eq!(result.winnings, 100); // CC = 1x 112 assert_eq!(result.result_data.get("win_type").unwrap(), "win");
113 assert_eq!(result.result_data.get("win_lines").unwrap(), "1");
91} 114}
92 115
93/// calculate_result on a non-finished state returns an error.
94#[test] 116#[test]
95fn calculate_result_on_unfinished_state() { 117fn calculate_result_rejects_an_unfinished_state() {
96 let game = make_game(); 118 let game = SlotsGame::new();
97 let state = game.prepare_state("alice", 100); 119 let state = game.prepare_state("alice", 10);
120
98 assert!(game.calculate_result(&state).is_err()); 121 assert!(game.calculate_result(&state).is_err());
99} 122}