//! Regression tests for the server-side 5x3 slots game. use zagan_bet::slots::{paytable_lines, LINE_NAMES, SHAPE_MULTIPLIERS, SYMBOL_MULTIPLIERS}; use zagan_bet::server::games::slots::{LineWin, SlotsGame, PAYLINES, REELS, ROWS}; use zagan_bet::server::games::traits::{Game, GamePhase}; /// 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) } fn lines(wins: &[LineWin]) -> Vec { wins.iter().map(|win| win.line).collect() } #[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); } #[test] fn three_of_a_kind_on_the_top_line_pays() { let (winnings, wins) = payout(["CCCDD", "CLLGG", "CLOSS"], 10); assert_eq!(winnings, 10, "CCC: 10 * C(1) * shape3(1)"); assert_eq!(lines(&wins), vec![1]); assert_eq!(wins[0].cells, vec![0, 3, 6]); } #[test] fn four_of_a_kind_pays_more_than_three() { let (winnings, wins) = payout(["CCCCD", "LOGDS", "OGDSL"], 10); assert_eq!(winnings, 30, "CCCC: 10 * C(1) * shape4(3)"); assert_eq!(lines(&wins), vec![1]); assert_eq!(wins[0].cells, vec![0, 3, 6, 9]); } #[test] fn five_of_a_kind_pays_all_lines() { let (winnings, wins) = payout(["SSSSS", "SSSSS", "SSSSS"], 10); assert_eq!(winnings, 7000, "five SSSSS lines: 5 * 10 * S(10) * shape5(14)"); assert_eq!(lines(&wins), vec![1, 2, 3, 4, 5]); assert_eq!(wins[0].cells, vec![0, 3, 6, 9, 12]); assert_eq!(wins[3].cells, vec![0, 4, 8, 10, 12]); assert_eq!(wins[4].cells, vec![2, 4, 6, 10, 14]); } #[test] fn the_v_payline_is_evaluated() { let (winnings, wins) = payout(["COGDC", "LCLCG", "OGCSD"], 10); assert_eq!(winnings, 140, "V line: 10 * C(1) * shape5(14)"); assert_eq!(lines(&wins), vec![4]); assert_eq!(wins[0].cells, vec![0, 4, 8, 10, 12]); } #[test] fn no_matching_line_pays_nothing() { let (winnings, wins) = payout(["CLOGD", "LOSGC", "DGDSC"], 100); assert_eq!(winnings, 0); assert!(wins.is_empty()); } #[test] fn winning_lines_are_added_together() { let (winnings, wins) = payout(["SSSSS", "LLLLL", "OOOOO"], 10); assert_eq!(winnings, (10 + 2 + 3) * 14 * 10); assert_eq!(lines(&wins), vec![1, 2, 3]); let mut cells: Vec = wins.iter().flat_map(|win| win.cells.clone()).collect(); cells.sort_unstable(); assert_eq!(cells, (0..15).collect::>()); } #[test] fn theoretical_rtp_is_just_under_one() { let rtp = SlotsGame::new().theoretical_rtp(); assert!((rtp - 3835.0 / 3888.0).abs() < 1e-12); assert!(rtp < 1.0, "house edge must be positive"); assert!(rtp > 0.98, "house edge should be tiny"); } #[test] fn payline_names_match_the_paylines() { assert_eq!(LINE_NAMES.len(), PAYLINES.len()); assert_eq!(LINE_NAMES[3], "V"); assert_eq!(LINE_NAMES[4], "inverted V"); } #[test] fn the_paytable_lines_show_the_product_formula_and_fit_80_columns() { let lines = paytable_lines(); let joined = lines.join(" "); assert_eq!(lines.len(), 2, "paytable should be two lines"); assert!(joined.contains("Pays bet x symbol x shape")); for (symbol, multiplier) in SYMBOL_MULTIPLIERS { assert!( joined.contains(&format!("{symbol}{multiplier}")), "paytable is missing symbol {symbol}" ); } for (length, multiplier) in SHAPE_MULTIPLIERS { assert!( joined.contains(&format!("{length}:{multiplier}")), "paytable is missing shape {length}" ); } for line in lines { assert!(line.len() <= 80, "paytable is too wide: {line:?}"); } } #[test] fn an_enormous_bet_saturates_instead_of_overflowing() { let (winnings, _) = payout(["SSSSS", "SSSSS", "SSSSS"], u64::MAX); assert_eq!(winnings, u64::MAX); } #[test] 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.winnings, 10); assert_eq!(result.result_data.get("win_type").unwrap(), "win"); assert_eq!(result.result_data.get("win_lines").unwrap(), "1"); assert_eq!(result.result_data.get("win_cells").unwrap(), "0,3,6"); } #[test] 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()); }