From fc59ef77cda14d97d854dfcaa848307cfa512b69 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 23:13:24 +0100 Subject: v1.0.0: replace slots with 5x3 reels and five paylines --- tests/slots.rs | 159 +++++++++++++++++++++++++++++++++------------------------ 1 file changed, 91 insertions(+), 68 deletions(-) (limited to 'tests/slots.rs') 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 @@ -//! Regression tests for the server-side slots game logic. +//! Regression tests for the server-side 5x3 slots game. -use zagan_bet::server::games::slots::SlotsGame; +use zagan_bet::server::games::slots::{SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; -fn make_game() -> SlotsGame { - SlotsGame::new() +/// Convert row-major test rows into the column-major wire format. +fn grid(rows: [&str; 3]) -> String { + let cells: Vec> = rows.iter().map(|row| row.chars().collect()).collect(); + let mut result = String::new(); + for col in 0..REELS { + for row in 0..ROWS { + result.push(cells[row][col]); + } + } + result +} + +fn payout(rows: [&str; 3], bet: u64) -> (u64, Vec) { + let reels = grid(rows); + let game = SlotsGame::with_grid(&reels); + let cells: Vec = reels.chars().collect(); + game.calculate_payout(&cells, bet) +} + +#[test] +fn the_wire_grid_is_five_reels_by_three_rows() { + assert_eq!(REELS, 5); + assert_eq!(ROWS, 3); + assert_eq!(PAYLINES.len(), 5); + + let game = SlotsGame::new(); + let state = game.prepare_state("alice", 10); + let state = game.process_action(&state, "spin", &[]).unwrap(); + let reels = state.data.get("reels").unwrap(); + assert_eq!(reels.chars().count(), REELS * ROWS); } -/// Three of a kind pays the three-of-a-kind multiplier. #[test] -fn three_of_a_kind_pays() { - let game = make_game(); - // CCC = 2x - assert_eq!(game.calculate_payout(&['C', 'C', 'C'], 100), 200); - // SSS = 28x - assert_eq!(game.calculate_payout(&['S', 'S', 'S'], 10), 280); +fn three_of_a_kind_on_the_top_line_pays() { + let (winnings, lines) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); + + assert_eq!(winnings, 20, "CCC on line 1 pays 2x total bet"); + assert_eq!(lines, vec![1]); } -/// Two adjacent matching symbols pays the two-of-a-kind multiplier. #[test] -fn two_adjacent_pays() { - let game = make_game(); - // CC? = 1x - assert_eq!(game.calculate_payout(&['C', 'C', 'D'], 100), 100); - // ?LL = 1x - assert_eq!(game.calculate_payout(&['D', 'L', 'L'], 50), 50); +fn four_of_a_kind_pays_more_than_three() { + let (winnings, lines) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); + + assert_eq!(winnings, 50, "CCCC pays 5x total bet"); + assert_eq!(lines, vec![1]); } -/// Non-adjacent matching symbols (e.g. [C,D,C]) must also pay. -/// This was a bug: the old code only checked pairs (0,1) and (1,2). #[test] -fn two_non_adjacent_pays() { - let game = make_game(); - // C,D,C — two cherries at positions 0 and 2 - assert_eq!(game.calculate_payout(&['C', 'D', 'C'], 100), 100); - // S,X,S — two sevens at positions 0 and 2 - assert_eq!(game.calculate_payout(&['S', 'L', 'S'], 10), 30); - // L,O,L - assert_eq!(game.calculate_payout(&['L', 'O', 'L'], 50), 50); +fn five_of_a_kind_pays_all_lines() { + let (winnings, lines) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); + + assert_eq!(winnings, 7500, "five SSSSS lines at 150x each"); + assert_eq!(lines, vec![1, 2, 3, 4, 5]); } -/// No matching symbols returns 0. #[test] -fn no_match_returns_zero() { - let game = make_game(); - assert_eq!(game.calculate_payout(&['C', 'L', 'O'], 100), 0); - assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); +fn the_v_payline_is_evaluated() { + let (winnings, lines) = payout(["COGDC", "LCLCG", "OGCSD"], 10); + + assert_eq!(winnings, 150, "V line with five C symbols pays 15x"); + assert_eq!(lines, vec![4]); } #[test] -fn an_enormous_bet_saturates_instead_of_overflowing() { - let game = make_game(); - assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX); - assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX); +fn no_matching_line_pays_nothing() { + let (winnings, lines) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); + + assert_eq!(winnings, 0); + assert!(lines.is_empty()); } -/// process_action produces a finished state with correct reels and win_type. #[test] -fn process_action_spin_sets_finished() { - let game = make_game(); - let state = game.prepare_state("alice", 100); - assert_eq!(state.phase, GamePhase::WaitingToStart); - - // Manually set reels by going through process_action. - // Since spin is random, we test the state machine rather than the outcome. - let result = game.process_action(&state, "spin", &[]); - assert!(result.is_ok()); - let new_state = result.unwrap(); - assert_eq!(new_state.phase, GamePhase::Finished); - assert!(new_state.data.contains_key("reels")); - assert!(new_state.data.contains_key("win_type")); +fn winning_lines_are_added_together() { + let (winnings, lines) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); + + assert_eq!(winnings, (150 + 20 + 25) * 10); + assert_eq!(lines, vec![1, 2, 3]); +} + +#[test] +fn theoretical_rtp_is_just_under_one() { + let rtp = SlotsGame::new().theoretical_rtp(); + + assert!((rtp - 425.0 / 432.0).abs() < 1e-12); + assert!(rtp < 1.0, "house edge must be positive"); + assert!(rtp > 0.98, "house edge should be tiny"); +} + +#[test] +fn an_enormous_bet_saturates_instead_of_overflowing() { + let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX); + + assert_eq!(winnings, u64::MAX); } -/// calculate_result on a finished state returns valid GameResult. #[test] -fn calculate_result_on_finished_state() { - let game = make_game(); - let mut state = game.prepare_state("alice", 100); - state.phase = GamePhase::Finished; - state - .data - .insert("reels".to_string(), "CCD".to_string()); - state - .data - .insert("win_type".to_string(), "two".to_string()); +fn a_spin_produces_a_finished_state_and_a_result_with_win_lines() { + let game = SlotsGame::with_grid(&grid(["CCCDD", "CLLGG", "CLOSS"])); + let state = game.prepare_state("alice", 10); + + let state = game.process_action(&state, "spin", &[]).unwrap(); + assert_eq!(state.phase, GamePhase::Finished); let result = game.calculate_result(&state).unwrap(); - assert_eq!(result.bet_amount, 100); - assert_eq!(result.winnings, 100); // CC = 1x + assert_eq!(result.winnings, 20); + assert_eq!(result.result_data.get("win_type").unwrap(), "win"); + assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); } -/// calculate_result on a non-finished state returns an error. #[test] -fn calculate_result_on_unfinished_state() { - let game = make_game(); - let state = game.prepare_state("alice", 100); +fn calculate_result_rejects_an_unfinished_state() { + let game = SlotsGame::new(); + let state = game.prepare_state("alice", 10); + assert!(game.calculate_result(&state).is_err()); } -- cgit v1.2.3