summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/client_core.rs106
-rw-r--r--tests/server_core.rs33
-rw-r--r--tests/slots.rs159
3 files changed, 197 insertions, 101 deletions
diff --git a/tests/client_core.rs b/tests/client_core.rs
index c298ab7..80ec587 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -5,8 +5,13 @@
5//! player would see, not about ANSI sequences. 5//! player would see, not about ANSI sequences.
6 6
7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
8use zagan_bet::client::games::response::parse_spin_response;
8use zagan_bet::client::ui::command_names; 9use zagan_bet::client::ui::command_names;
9 10
11const WIN3: &str = "CCCCLLCLODGSDGS";
12const LOSS: &str = "CLOOGDSLCDCOGLS";
13const ALL_C: &str = "CCCCCCCCCCCCCCC";
14
10#[derive(Default)] 15#[derive(Default)]
11struct Screen { 16struct Screen {
12 lines: Vec<String>, 17 lines: Vec<String>,
@@ -123,16 +128,17 @@ impl Harness {
123} 128}
124 129
125#[test] 130#[test]
126fn play_shows_a_seven_line_board_and_fetches_the_balance() { 131fn play_shows_a_nine_line_board_and_fetches_the_balance() {
127 let mut h = Harness::new(); 132 let mut h = Harness::new();
128 h.line("play slots 50"); 133 h.line("play slots 50");
129 134
130 assert_eq!(h.board().len(), 7); 135 assert_eq!(h.board().len(), 9);
131 assert!(h.board()[1].contains("Bet: 50 PLN")); 136 assert!(h.board()[1].contains("Bet: 50 PLN"));
137 assert!(h.board()[1].contains("Lines: 5"));
132 assert!(h.sent.contains(&"INFO".to_string())); 138 assert!(h.sent.contains(&"INFO".to_string()));
133 139
134 h.reply("INFO alice 1 1000"); 140 h.reply("INFO alice 1 1000");
135 assert_eq!(h.board().len(), 7); 141 assert_eq!(h.board().len(), 9);
136 assert!(h.board()[1].contains("Balance: 1000 PLN")); 142 assert!(h.board()[1].contains("Balance: 1000 PLN"));
137} 143}
138 144
@@ -143,18 +149,22 @@ fn spin_replaces_the_board_and_keeps_the_controls_line() {
143 let lines_before_spin = h.screen.lines.len(); 149 let lines_before_spin = h.screen.lines.len();
144 150
145 h.key(Key::Char(' ')); 151 h.key(Key::Char(' '));
146 assert_eq!(h.board().len(), 7); 152 assert_eq!(h.board().len(), 9);
147 assert!(h.board()[4].contains("Spinning...")); 153 assert!(h.board()[6].contains("Spinning..."));
148 assert!(h.board()[6].contains("[Q] Quit")); 154 assert!(h.board()[8].contains("[Q] Quit"));
149 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); 155 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50"));
150 assert_eq!(h.screen.lines.len(), lines_before_spin); 156 assert_eq!(h.screen.lines.len(), lines_before_spin);
151 157
152 h.reply("GAME_RESULT session-1 60 50 1010 OSS two"); 158 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1");
153 assert_eq!(h.board().len(), 7); 159 assert_eq!(h.board().len(), 9);
154 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); 160 assert!(h
155 assert!(h.board()[4].contains("Two of a kind! Winnings: 60 PLN")); 161 .board()
156 assert!(h.board()[1].contains("Balance: 1010 PLN")); 162 .get(2)
157 assert!(h.board()[6].contains("[Q] Quit")); 163 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]")));
164 assert!(h.board()[6].contains("Won 100 PLN"));
165 assert!(h.board()[7].contains("Winning lines: 1"));
166 assert!(h.board()[1].contains("Balance: 1050 PLN"));
167 assert!(h.board()[8].contains("[Q] Quit"));
158 assert_eq!(h.screen.lines.len(), lines_before_spin); 168 assert_eq!(h.screen.lines.len(), lines_before_spin);
159} 169}
160 170
@@ -163,14 +173,17 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
163 let mut h = Harness::new(); 173 let mut h = Harness::new();
164 h.enter_slots("50"); 174 h.enter_slots("50");
165 h.key(Key::Char(' ')); 175 h.key(Key::Char(' '));
166 h.reply("GAME_RESULT session-1 60 50 1010 OSS two"); 176 h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1");
167 let lines_before_bet = h.screen.lines.len(); 177 let lines_before_bet = h.screen.lines.len();
168 178
169 h.key(Key::Char('+')); 179 h.key(Key::Char('+'));
170 180
171 assert_eq!(h.board().len(), 7); 181 assert_eq!(h.board().len(), 9);
172 assert!(h.board()[1].contains("Bet: 60 PLN")); 182 assert!(h.board()[1].contains("Bet: 60 PLN"));
173 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); 183 assert!(h
184 .board()
185 .get(2)
186 .is_some_and(|line| line.contains("[ C ] [ C ] [ C ] [ D ] [ D ]")));
174 assert_eq!(h.screen.lines.len(), lines_before_bet); 187 assert_eq!(h.screen.lines.len(), lines_before_bet);
175 assert_eq!( 188 assert_eq!(
176 h.screen 189 h.screen
@@ -183,6 +196,32 @@ fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
183} 196}
184 197
185#[test] 198#[test]
199fn a_losing_spin_shows_no_win_and_no_winning_lines() {
200 let mut h = Harness::new();
201 h.enter_slots("10");
202 h.key(Key::Char(' '));
203
204 h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -"));
205
206 assert_eq!(h.board().len(), 9);
207 assert!(h.board()[6].contains("No win."));
208 assert_eq!(h.board()[7], "");
209}
210
211#[test]
212fn a_mega_win_shows_all_winning_lines() {
213 let mut h = Harness::new();
214 h.enter_slots("10");
215 h.key(Key::Char(' '));
216
217 h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5"));
218
219 assert_eq!(h.board().len(), 9);
220 assert!(h.board()[6].contains("MEGA WIN! 750 PLN"));
221 assert!(h.board()[7].contains("Winning lines: 1,2,3,4,5"));
222}
223
224#[test]
186fn quitting_clears_the_board_and_returns_the_prompt() { 225fn quitting_clears_the_board_and_returns_the_prompt() {
187 let mut h = Harness::new(); 226 let mut h = Harness::new();
188 h.enter_slots("50"); 227 h.enter_slots("50");
@@ -202,8 +241,8 @@ fn a_server_error_replaces_the_status_line() {
202 241
203 h.reply("ERROR Insufficient balance"); 242 h.reply("ERROR Insufficient balance");
204 243
205 assert_eq!(h.board().len(), 7); 244 assert_eq!(h.board().len(), 9);
206 assert!(h.board()[4].contains("ERROR: Insufficient balance")); 245 assert!(h.board()[6].contains("ERROR: Insufficient balance"));
207 assert!(h.board()[2].contains("[ ? ]")); 246 assert!(h.board()[2].contains("[ ? ]"));
208 assert!(!h.board().iter().any(|line| line.contains("Spinning"))); 247 assert!(!h.board().iter().any(|line| line.contains("Spinning")));
209} 248}
@@ -216,8 +255,8 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() {
216 255
217 h.reply(""); 256 h.reply("");
218 257
219 assert_eq!(h.board().len(), 7); 258 assert_eq!(h.board().len(), 9);
220 assert!(h.board()[4].contains("No response from server")); 259 assert!(h.board()[6].contains("No response from server"));
221} 260}
222 261
223#[test] 262#[test]
@@ -301,7 +340,7 @@ fn play_without_a_bet_uses_the_default() {
301 let mut h = Harness::new(); 340 let mut h = Harness::new();
302 h.line("play slots"); 341 h.line("play slots");
303 342
304 assert_eq!(h.board().len(), 7); 343 assert_eq!(h.board().len(), 9);
305 assert!(h.board()[1].contains("Bet: 10 PLN")); 344 assert!(h.board()[1].contains("Bet: 10 PLN"));
306} 345}
307 346
@@ -475,3 +514,30 @@ fn disconnect_clears_connection_state() {
475 .iter() 514 .iter()
476 .any(|line| line.contains("Not connected"))); 515 .any(|line| line.contains("Not connected")));
477} 516}
517
518#[test]
519fn parses_a_five_by_three_result_with_winning_lines() {
520 let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1");
521 let result = parse_spin_response(&response).expect("valid 5x3 result");
522
523 assert_eq!(result.reels.chars().count(), 15);
524 assert_eq!(result.win_type, "win");
525 assert_eq!(result.win_lines, vec![1]);
526}
527
528#[test]
529fn accepts_a_legacy_three_symbol_result() {
530 let result = parse_spin_response("GAME_RESULT session-1 60 10 1050 OSS two")
531 .expect("legacy result should still parse");
532
533 assert_eq!(result.reels, "OSS");
534 assert_eq!(result.win_type, "two");
535 assert!(result.win_lines.is_empty());
536}
537
538#[test]
539fn rejects_a_grid_with_the_wrong_size() {
540 let response = "GAME_RESULT session-1 100 50 1050 CCCCCCCCCCCCCC win 1";
541
542 assert!(parse_spin_response(response).is_err());
543}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index fe2b3d3..f3736e9 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -6,7 +6,7 @@ use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry; 6use zagan_bet::server::games::registry::GameRegistry;
7use zagan_bet::server::games::slots::SlotsGame; 7use zagan_bet::server::games::slots::SlotsGame;
8 8
9fn setup(name: &str, reels: &str) -> (ServerCore, String) { 9fn setup(name: &str, grid: &str) -> (ServerCore, String) {
10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) 10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
11 .join(format!("server-core-{name}.db")) 11 .join(format!("server-core-{name}.db"))
12 .to_string_lossy() 12 .to_string_lossy()
@@ -17,12 +17,18 @@ fn setup(name: &str, reels: &str) -> (ServerCore, String) {
17 database::init_db(&conn); 17 database::init_db(&conn);
18 18
19 let registry = GameRegistry::new(); 19 let registry = GameRegistry::new();
20 registry.register_game(SlotsGame::with_reels(reels)); 20 registry.register_game(SlotsGame::with_grid(grid));
21 21
22 // Low bcrypt cost keeps the tests fast. 22 // Low bcrypt cost keeps the tests fast.
23 (ServerCore::with_registry(conn, registry, 4), path) 23 (ServerCore::with_registry(conn, registry, 4), path)
24} 24}
25 25
26/// Column-major 5x3 grid with no winning line.
27const LOSS: &str = "CLOOGDSLCDCOGLS";
28
29/// Top line is C C C D D; it pays 2x total bet.
30const WIN3: &str = "CCCCLLCLODGSDGS";
31
26fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 32fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
27 assert_eq!( 33 assert_eq!(
28 core.handle_message(state, &format!("REGISTER {user} credential")), 34 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -39,7 +45,7 @@ fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
39 45
40#[test] 46#[test]
41fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { 47fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
42 let (mut core, path) = setup("register", "CLO"); 48 let (mut core, path) = setup("register", LOSS);
43 let mut state = ConnectionState::default(); 49 let mut state = ConnectionState::default();
44 50
45 register(&mut core, &mut state, "alice"); 51 register(&mut core, &mut state, "alice");
@@ -59,7 +65,7 @@ fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
59 65
60#[test] 66#[test]
61fn the_same_credential_gets_a_different_salt() { 67fn the_same_credential_gets_a_different_salt() {
62 let (mut core, path) = setup("salt", "CLO"); 68 let (mut core, path) = setup("salt", LOSS);
63 let mut state = ConnectionState::default(); 69 let mut state = ConnectionState::default();
64 70
65 register(&mut core, &mut state, "alice"); 71 register(&mut core, &mut state, "alice");
@@ -73,7 +79,7 @@ fn the_same_credential_gets_a_different_salt() {
73 79
74#[test] 80#[test]
75fn legacy_sha256_rows_are_upgraded_on_login() { 81fn legacy_sha256_rows_are_upgraded_on_login() {
76 let (mut core, path) = setup("legacy", "CLO"); 82 let (mut core, path) = setup("legacy", LOSS);
77 let conn = Connection::open(&path).unwrap(); 83 let conn = Connection::open(&path).unwrap();
78 database::add_user(&conn, "legacy", "deadbeef").unwrap(); 84 database::add_user(&conn, "legacy", "deadbeef").unwrap();
79 85
@@ -96,7 +102,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() {
96 102
97#[test] 103#[test]
98fn duplicate_registration_returns_an_error() { 104fn duplicate_registration_returns_an_error() {
99 let (mut core, _path) = setup("duplicate", "CLO"); 105 let (mut core, _path) = setup("duplicate", LOSS);
100 let mut state = ConnectionState::default(); 106 let mut state = ConnectionState::default();
101 107
102 register(&mut core, &mut state, "alice"); 108 register(&mut core, &mut state, "alice");
@@ -108,7 +114,7 @@ fn duplicate_registration_returns_an_error() {
108 114
109#[test] 115#[test]
110fn a_malicious_username_cannot_change_another_balance() { 116fn a_malicious_username_cannot_change_another_balance() {
111 let (mut core, _path) = setup("injection", "CLO"); 117 let (mut core, _path) = setup("injection", LOSS);
112 let mut admin = ConnectionState::default(); 118 let mut admin = ConnectionState::default();
113 register(&mut core, &mut admin, "victim"); 119 register(&mut core, &mut admin, "victim");
114 120
@@ -132,7 +138,7 @@ fn a_malicious_username_cannot_change_another_balance() {
132 138
133#[test] 139#[test]
134fn a_failed_audit_insert_rolls_back_the_balance_change() { 140fn a_failed_audit_insert_rolls_back_the_balance_change() {
135 let (mut core, path) = setup("rollback", "CLO"); 141 let (mut core, path) = setup("rollback", LOSS);
136 let mut state = ConnectionState::default(); 142 let mut state = ConnectionState::default();
137 register(&mut core, &mut state, "alice"); 143 register(&mut core, &mut state, "alice");
138 login(&mut core, &mut state, "alice"); 144 login(&mut core, &mut state, "alice");
@@ -155,7 +161,7 @@ fn a_failed_audit_insert_rolls_back_the_balance_change() {
155 161
156#[test] 162#[test]
157fn spins_write_audit_rows_and_leave_no_sessions() { 163fn spins_write_audit_rows_and_leave_no_sessions() {
158 let (mut core, _path) = setup("audit", "CLO"); 164 let (mut core, _path) = setup("audit", LOSS);
159 let mut state = ConnectionState::default(); 165 let mut state = ConnectionState::default();
160 register(&mut core, &mut state, "alice"); 166 register(&mut core, &mut state, "alice");
161 login(&mut core, &mut state, "alice"); 167 login(&mut core, &mut state, "alice");
@@ -175,7 +181,7 @@ fn spins_write_audit_rows_and_leave_no_sessions() {
175 181
176#[test] 182#[test]
177fn a_winning_spin_updates_the_balance_and_the_audit_row() { 183fn a_winning_spin_updates_the_balance_and_the_audit_row() {
178 let (mut core, _path) = setup("win", "CCC"); 184 let (mut core, _path) = setup("win", WIN3);
179 let mut state = ConnectionState::default(); 185 let mut state = ConnectionState::default();
180 register(&mut core, &mut state, "alice"); 186 register(&mut core, &mut state, "alice");
181 login(&mut core, &mut state, "alice"); 187 login(&mut core, &mut state, "alice");
@@ -183,6 +189,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() {
183 let response = core.handle_message(&mut state, "PLAY_START slots 10"); 189 let response = core.handle_message(&mut state, "PLAY_START slots 10");
184 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); 190 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
185 assert!(response.contains(" 20 10 1010 "), "response: {response}"); 191 assert!(response.contains(" 20 10 1010 "), "response: {response}");
192 assert!(response.ends_with(" win 1"), "response: {response}");
186 193
187 assert_eq!(core.balance("alice"), Some(1010)); 194 assert_eq!(core.balance("alice"), Some(1010));
188 assert_eq!(core.play_count("alice"), 1); 195 assert_eq!(core.play_count("alice"), 1);
@@ -191,7 +198,7 @@ fn a_winning_spin_updates_the_balance_and_the_audit_row() {
191 198
192#[test] 199#[test]
193fn insufficient_balance_does_not_write_an_audit_row() { 200fn insufficient_balance_does_not_write_an_audit_row() {
194 let (mut core, path) = setup("insufficient", "CLO"); 201 let (mut core, path) = setup("insufficient", LOSS);
195 let mut state = ConnectionState::default(); 202 let mut state = ConnectionState::default();
196 register(&mut core, &mut state, "alice"); 203 register(&mut core, &mut state, "alice");
197 login(&mut core, &mut state, "alice"); 204 login(&mut core, &mut state, "alice");
@@ -211,7 +218,7 @@ fn insufficient_balance_does_not_write_an_audit_row() {
211 218
212#[test] 219#[test]
213fn a_negative_balance_is_rejected_without_wrapping() { 220fn a_negative_balance_is_rejected_without_wrapping() {
214 let (mut core, path) = setup("negative", "CLO"); 221 let (mut core, path) = setup("negative", LOSS);
215 let mut state = ConnectionState::default(); 222 let mut state = ConnectionState::default();
216 register(&mut core, &mut state, "alice"); 223 register(&mut core, &mut state, "alice");
217 login(&mut core, &mut state, "alice"); 224 login(&mut core, &mut state, "alice");
@@ -233,7 +240,7 @@ fn a_negative_balance_is_rejected_without_wrapping() {
233 240
234#[test] 241#[test]
235fn unknown_games_are_rejected_before_a_session_is_created() { 242fn unknown_games_are_rejected_before_a_session_is_created() {
236 let (mut core, _path) = setup("unknown", "CLO"); 243 let (mut core, _path) = setup("unknown", LOSS);
237 let mut state = ConnectionState::default(); 244 let mut state = ConnectionState::default();
238 register(&mut core, &mut state, "alice"); 245 register(&mut core, &mut state, "alice");
239 login(&mut core, &mut state, "alice"); 246 login(&mut core, &mut state, "alice");
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}