diff options
Diffstat (limited to '')
| -rw-r--r-- | tests/slots.rs | 159 |
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 | ||
| 3 | use zagan_bet::server::games::slots::SlotsGame; | 3 | use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; |
| 4 | use zagan_bet::server::games::traits::{Game, GamePhase}; | 4 | use zagan_bet::server::games::traits::{Game, GamePhase}; |
| 5 | 5 | ||
| 6 | fn make_game() -> SlotsGame { | 6 | /// Convert row-major test rows into the column-major wire format. |
| 7 | SlotsGame::new() | 7 | fn 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 | |||
| 18 | fn 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] | ||
| 26 | fn 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] |
| 12 | fn three_of_a_kind_pays() { | 39 | fn 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] |
| 22 | fn two_adjacent_pays() { | 47 | fn 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] |
| 33 | fn two_non_adjacent_pays() { | 55 | fn 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] |
| 45 | fn no_match_returns_zero() { | 63 | fn 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] |
| 52 | fn an_enormous_bet_saturates_instead_of_overflowing() { | 71 | fn 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] |
| 60 | fn process_action_spin_sets_finished() { | 79 | fn 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()); | 87 | fn 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] | ||
| 96 | fn 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] |
| 77 | fn calculate_result_on_finished_state() { | 103 | fn 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] |
| 95 | fn calculate_result_on_unfinished_state() { | 117 | fn 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 | } |
