summaryrefslogtreecommitdiffstats
path: root/tests/slots.rs
blob: b7292b87176c86e2ed38da3f78389b35bf723511 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
//! Regression tests for the server-side 5x3 slots game.

use zagan_bet::slots::{paytable_line, PAYTABLE};
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<Vec<char>> = 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<LineWin>) {
    let reels = grid(rows);
    let game = SlotsGame::with_grid(&reels);
    let cells: Vec<char> = reels.chars().collect();
    game.calculate_payout(&cells, bet)
}

fn lines(wins: &[LineWin]) -> Vec<usize> {
    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, 20, "CCC on line 1 pays 2x total bet");
    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, 50, "CCCC pays 5x total bet");
    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, 7500, "five SSSSS lines at 150x each");
    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, 150, "V line with five C symbols pays 15x");
    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, (150 + 20 + 25) * 10);
    assert_eq!(lines(&wins), vec![1, 2, 3]);

    let mut cells: Vec<usize> = wins.iter().flat_map(|win| win.cells.clone()).collect();
    cells.sort_unstable();
    assert_eq!(cells, (0..15).collect::<Vec<_>>());
}

#[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 the_paytable_line_lists_every_symbol_and_fits_80_columns() {
    let line = paytable_line();

    for (symbol, three, four, five) in PAYTABLE {
        assert!(
            line.contains(&format!("{symbol} {three}/{four}/{five}")),
            "paytable is missing {symbol}"
        );
    }
    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, 20);
    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());
}