diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 23:13:24 +0100 |
| commit | fc59ef77cda14d97d854dfcaa848307cfa512b69 (patch) | |
| tree | b0fc97390b96078e0a0538e76d7c76f7c04135f1 /tests | |
| parent | v0.4.0: add first-time TLS setup and server help (diff) | |
| download | zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.tar.gz zagan-bet-fc59ef77cda14d97d854dfcaa848307cfa512b69.zip | |
v1.0.0: replace slots with 5x3 reels and five paylines
Diffstat (limited to '')
| -rw-r--r-- | tests/client_core.rs | 106 | ||||
| -rw-r--r-- | tests/server_core.rs | 33 | ||||
| -rw-r--r-- | tests/slots.rs | 159 |
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 | ||
| 7 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 7 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 8 | use zagan_bet::client::games::response::parse_spin_response; | ||
| 8 | use zagan_bet::client::ui::command_names; | 9 | use zagan_bet::client::ui::command_names; |
| 9 | 10 | ||
| 11 | const WIN3: &str = "CCCCLLCLODGSDGS"; | ||
| 12 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | ||
| 13 | const ALL_C: &str = "CCCCCCCCCCCCCCC"; | ||
| 14 | |||
| 10 | #[derive(Default)] | 15 | #[derive(Default)] |
| 11 | struct Screen { | 16 | struct Screen { |
| 12 | lines: Vec<String>, | 17 | lines: Vec<String>, |
| @@ -123,16 +128,17 @@ impl Harness { | |||
| 123 | } | 128 | } |
| 124 | 129 | ||
| 125 | #[test] | 130 | #[test] |
| 126 | fn play_shows_a_seven_line_board_and_fetches_the_balance() { | 131 | fn 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] |
| 199 | fn 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] | ||
| 212 | fn 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] | ||
| 186 | fn quitting_clears_the_board_and_returns_the_prompt() { | 225 | fn 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] | ||
| 519 | fn 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] | ||
| 529 | fn 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] | ||
| 539 | fn 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; | |||
| 6 | use zagan_bet::server::games::registry::GameRegistry; | 6 | use zagan_bet::server::games::registry::GameRegistry; |
| 7 | use zagan_bet::server::games::slots::SlotsGame; | 7 | use zagan_bet::server::games::slots::SlotsGame; |
| 8 | 8 | ||
| 9 | fn setup(name: &str, reels: &str) -> (ServerCore, String) { | 9 | fn 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. | ||
| 27 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | ||
| 28 | |||
| 29 | /// Top line is C C C D D; it pays 2x total bet. | ||
| 30 | const WIN3: &str = "CCCCLLCLODGSDGS"; | ||
| 31 | |||
| 26 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 32 | fn 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] |
| 41 | fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { | 47 | fn 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] |
| 61 | fn the_same_credential_gets_a_different_salt() { | 67 | fn 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] |
| 75 | fn legacy_sha256_rows_are_upgraded_on_login() { | 81 | fn 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] |
| 98 | fn duplicate_registration_returns_an_error() { | 104 | fn 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] |
| 110 | fn a_malicious_username_cannot_change_another_balance() { | 116 | fn 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] |
| 134 | fn a_failed_audit_insert_rolls_back_the_balance_change() { | 140 | fn 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] |
| 157 | fn spins_write_audit_rows_and_leave_no_sessions() { | 163 | fn 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] |
| 177 | fn a_winning_spin_updates_the_balance_and_the_audit_row() { | 183 | fn 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] |
| 193 | fn insufficient_balance_does_not_write_an_audit_row() { | 200 | fn 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] |
| 213 | fn a_negative_balance_is_rejected_without_wrapping() { | 220 | fn 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] |
| 235 | fn unknown_games_are_rejected_before_a_session_is_created() { | 242 | fn 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 | ||
| 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 | } |
