use std::path::PathBuf; use std::sync::Arc; use rusqlite::Connection; use zagan_bet::blackjack::BlackjackState; use zagan_bet::crash::CrashState; use zagan_bet::roulette::{BetTarget, Bets, Outside}; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::crash::ManualClock; use zagan_bet::server::games::registry::GameRegistry; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::slots::SlotsGame; fn setup(name: &str, grid: &str) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let registry = GameRegistry::new(); registry.register_game(SlotsGame::with_grid(grid)); // Low bcrypt cost keeps the tests fast. (ServerCore::with_registry(conn, registry, 4), path) } /// Column-major 5x3 grid with no winning line. const LOSS: &str = "CLOOGDSLCDCOGLS"; /// Top line is C C C D D; it pays 2x total bet. const WIN3: &str = "CCCCLLCLODGSDGS"; fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let registry = GameRegistry::new(); registry.register_game(RouletteGame::with_result(number)); (ServerCore::with_registry(conn, registry, 4), path) } fn setup_blackjack(name: &str, deck: &[&str]) -> (ServerCore, String) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let deck = deck.iter().map(|card| card.to_string()).collect(); ( ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4).with_blackjack_deck(deck), path, ) } fn blackjack_state(response: &str) -> BlackjackState { let raw = response .strip_prefix("BLACKJACK_STATE ") .expect("blackjack state response"); serde_json::from_str(raw).expect("valid blackjack state") } fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) .join(format!("server-core-{name}.db")) .to_string_lossy() .into_owned(); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).unwrap(); database::init_db(&conn); let clock = ManualClock::new(); let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) .with_crash_uniform(uniform) .with_clock(Arc::new(clock.clone())); (core, path, clock) } fn crash_state(response: &str) -> CrashState { let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "] .iter() .find_map(|prefix| response.strip_prefix(prefix)) .unwrap_or_else(|| panic!("not a crash response: {response}")); serde_json::from_str(raw).expect("valid crash state") } fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), format!("REGISTERED {user}") ); } fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("AUTH {user} credential")), format!("OK {user}") ); } #[test] fn register_stores_a_bcrypt_hash_and_auth_accepts_it() { let (mut core, path) = setup("register", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); let stored = database::get_hashed_password(&conn, "alice").unwrap(); assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}"); assert_ne!(stored, "credential"); assert!(bcrypt::verify("credential", &stored).unwrap()); assert_eq!( core.handle_message(&mut state, "AUTH alice credential"), "OK alice" ); assert_eq!(state.user(), Some("alice")); } #[test] fn the_same_credential_gets_a_different_salt() { let (mut core, path) = setup("salt", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); register(&mut core, &mut state, "bob"); let conn = Connection::open(&path).unwrap(); let alice = database::get_hashed_password(&conn, "alice").unwrap(); let bob = database::get_hashed_password(&conn, "bob").unwrap(); assert_ne!(alice, bob); } #[test] fn legacy_sha256_rows_are_upgraded_on_login() { let (mut core, path) = setup("legacy", LOSS); let conn = Connection::open(&path).unwrap(); database::add_user(&conn, "legacy", "deadbeef").unwrap(); let mut state = ConnectionState::default(); assert_eq!( core.handle_message(&mut state, "AUTH legacy deadbeef"), "OK legacy" ); let stored = database::get_hashed_password(&conn, "legacy").unwrap(); assert!(stored.starts_with("$2"), "legacy hash was not upgraded"); assert!(bcrypt::verify("deadbeef", &stored).unwrap()); let mut other = ConnectionState::default(); assert_eq!( core.handle_message(&mut other, "AUTH legacy wrong"), "ERROR Invalid credentials" ); } #[test] fn duplicate_registration_returns_an_error() { let (mut core, _path) = setup("duplicate", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "REGISTER alice credential"), "ERROR User already exists" ); } #[test] fn a_malicious_username_cannot_change_another_balance() { let (mut core, _path) = setup("injection", LOSS); let mut admin = ConnectionState::default(); register(&mut core, &mut admin, "victim"); // No spaces, so it survives the three-part REGISTER/AUTH parser. let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--"; register(&mut core, &mut admin, evil); let mut evil_state = ConnectionState::default(); login(&mut core, &mut evil_state, evil); let response = core.handle_message(&mut evil_state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.balance("victim"), Some(1000)); assert_eq!(core.balance(evil), Some(990)); assert_eq!(core.play_count(evil), 1); } #[test] fn plays_record_the_game_type_and_game_specific_details() { let (mut core, path) = setup("details", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "slots"); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["reels"], "CCCCLLCLODGSDGS"); assert_eq!(details["win_type"], "win"); assert_eq!(details["win_lines"], "1"); assert_eq!(details["win_cells"], "0,3,6"); } #[test] fn roulette_settles_split_bets_and_records_details() { let (mut core, path) = setup_roulette("roulette", 17); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let mut bets = Bets::default(); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); bets.add(BetTarget::Outside { kind: Outside::Black }, 10); bets.add(BetTarget::Outside { kind: Outside::Even }, 10); bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); bets.add(BetTarget::Straight { number: 17 }, 5); let bets = bets.to_json(); let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); assert!( response.starts_with("ROULETTE_RESULT"), "response: {response}" ); assert!( response.contains(" 220 45 1175 17 black "), "response: {response}" ); assert!( response.ends_with(&format!(" {bets}")), "response: {response}" ); assert_eq!(core.balance("alice"), Some(1175)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "roulette"); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["number"], "17"); assert_eq!(details["color"], "black"); let stored: Bets = serde_json::from_str(details["bets"].as_str().unwrap()).unwrap(); assert_eq!(stored.total(), 45); } #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { let (mut core, path) = setup("rollback", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute_batch( "CREATE TRIGGER fail_plays BEFORE INSERT ON plays BEGIN SELECT RAISE(ABORT, 'boom'); END;", ) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Failed to update balance" ); assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn spins_write_audit_rows_and_leave_no_sessions() { let (mut core, _path) = setup("audit", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); for _ in 0..3 { let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert_eq!(core.session_count(), 0); } assert_eq!(core.balance("alice"), Some(970)); assert_eq!(core.play_count("alice"), 3); } #[test] fn a_winning_spin_updates_the_balance_and_the_audit_row() { let (mut core, _path) = setup("win", WIN3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START slots 10"); assert!( response.starts_with("GAME_RESULT"), "spin failed: {response}" ); assert!(response.contains(" 10 10 1000 "), "response: {response}"); assert!(response.ends_with(" win 1 0,3,6"), "response: {response}"); assert_eq!(core.balance("alice"), Some(1000)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn insufficient_balance_does_not_write_an_audit_row() { let (mut core, path) = setup("insufficient", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Insufficient balance" ); assert_eq!(core.balance("alice"), Some(5)); assert_eq!(core.play_count("alice"), 0); assert_eq!(core.session_count(), 0); } #[test] fn a_negative_balance_is_rejected_without_wrapping() { let (mut core, path) = setup("negative", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute("UPDATE users SET balance = -5 WHERE username = 'alice'", []) .unwrap(); assert_eq!( core.handle_message(&mut state, "PLAY_START slots 10"), "ERROR Invalid balance" ); assert_eq!(core.balance("alice"), Some(-5)); assert_eq!(core.play_count("alice"), 0); } #[test] fn unknown_games_are_rejected_before_a_session_is_created() { let (mut core, _path) = setup("unknown", LOSS); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); assert_eq!( core.handle_message(&mut state, "PLAY_START nope 10"), "ERROR Unknown game type: nope" ); assert_eq!(core.session_count(), 0); } #[test] fn blackjack_reserves_the_bet_and_settles_a_hit_hand() { let (mut core, path) = setup_blackjack("blackjack-hit", &["10S", "9D", "6H", "8C", "5D"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let response = core.handle_message(&mut state, "PLAY_START blackjack 10"); let current = blackjack_state(&response); assert_eq!(current.phase, "player"); assert_eq!(current.player_total, 16); assert_eq!(current.dealer, vec!["9D".to_string(), "?".to_string()]); assert_eq!(current.balance, 990); assert_eq!(current.actions, vec!["hit", "stand", "double"]); assert_eq!(core.session_count(), 1); let response = core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} hit", current.session_id), ); let done = blackjack_state(&response); assert_eq!(done.phase, "done"); assert_eq!(done.result, "win"); assert_eq!(done.winnings, 20); assert_eq!(done.balance, 1010); assert_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); let conn = Connection::open(&path).unwrap(); let (game_type, details): (String, String) = conn .query_row( "SELECT game_type, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(game_type, "blackjack"); let details: BlackjackState = serde_json::from_str(&details).unwrap(); assert_eq!(details.result, "win"); assert_eq!(details.winnings, 20); } #[test] fn blackjack_double_reserves_an_extra_bet() { let (mut core, path) = setup_blackjack("blackjack-double", &["5S", "9D", "5H", "8C", "10D"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); let done = blackjack_state(&core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} double", current.session_id), )); assert_eq!(done.phase, "done"); assert_eq!(done.bet, 20); assert_eq!(done.winnings, 40); assert_eq!(done.balance, 1020); assert_eq!(core.balance("alice"), Some(1020)); assert_eq!(core.play_count("alice"), 1); // A double extends the reservation, and settling clears it. let conn = Connection::open(&path).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 0); let bet: i64 = conn .query_row( "SELECT bet_amount FROM plays WHERE username = 'alice'", [], |row| row.get(0), ) .unwrap(); assert_eq!(bet, 20); } #[test] fn blackjack_natural_settles_immediately() { let (mut core, _path) = setup_blackjack("blackjack-natural", &["AS", "9D", "KH", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let done = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(done.phase, "done"); assert_eq!(done.result, "blackjack"); assert_eq!(done.winnings, 25); assert_eq!(done.balance, 1015); assert_eq!(core.session_count(), 0); assert_eq!(core.play_count("alice"), 1); } #[test] fn blackjack_forfeit_keeps_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); let forfeited = blackjack_state(&core.handle_message( &mut state, &format!("BLACKJACK_ACTION {} forfeit", current.session_id), )); assert_eq!(forfeited.result, "forfeit"); assert_eq!(forfeited.balance, 990); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn blackjack_disconnect_forfeits_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); core.disconnect(&state); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } // 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0. const CRASH_AT_3: f64 = 0.67; #[test] fn crash_start_reserves_the_bet_and_hides_the_crash_point() { let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); assert_eq!(started.phase, "running"); assert_eq!(started.bet, 10); assert_eq!(started.balance, 990); assert_eq!(started.multiplier, 1.0); assert_eq!(started.potential, 10); assert_eq!(started.payout, 0); assert_eq!( started.crash_point, None, "the crash point must not leak before the round ends" ); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.session_count(), 1); assert_eq!(core.play_count("alice"), 0); } #[test] fn crash_cashout_pays_the_multiplier_from_the_server_clock() { let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); // Five seconds of round time: 2.00x, still below the 3.00x crash point. clock.advance(5.0); let cashed = crash_state( &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), ); assert_eq!(cashed.phase, "cashed"); assert_eq!(cashed.multiplier, 2.0); assert_eq!(cashed.potential, 20); assert_eq!(cashed.payout, 20); assert_eq!(cashed.balance, 1010); let crash_point = cashed.crash_point.unwrap(); assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); assert_eq!(core.balance("alice"), Some(1010)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); let conn = Connection::open(&path).unwrap(); let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn .query_row( "SELECT game_type, bet_amount, winnings, balance_after, details FROM plays WHERE username = 'alice'", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, ) .unwrap(); assert_eq!(game_type, "crash"); assert_eq!(bet, 10); assert_eq!(winnings, 20); assert_eq!(balance_after, 1010); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["phase"], "cashed"); let audited_crash_point = details["crash_point"].as_f64().unwrap(); assert!( (audited_crash_point - 3.0).abs() < 1e-9, "got {audited_crash_point}" ); assert_eq!(details["multiplier"], 2.0); } #[test] fn a_cashout_after_the_crash_time_loses_the_reserved_bet() { let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); clock.advance(20.0); let late = crash_state( &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), ); assert_eq!(late.phase, "crashed"); assert_eq!(late.payout, 0); assert_eq!(late.balance, 990); let crash_point = late.crash_point.unwrap(); assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}"); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn crash_poll_pushes_the_bust_exactly_once() { let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); clock.advance(1.0); assert!( core.poll(&state).is_empty(), "a running round must not push anything" ); clock.advance(15.0); let pushed = core.poll(&state); assert_eq!(pushed.len(), 1); let busted = crash_state(&pushed[0]); assert_eq!(busted.session_id, started.session_id); assert_eq!(busted.phase, "crashed"); assert_eq!(busted.payout, 0); assert_eq!(busted.balance, 990); assert!(core.poll(&state).is_empty(), "the bust must not repeat"); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn crash_forfeit_keeps_the_reserved_bet() { let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); let forfeited = crash_state( &core.handle_message(&mut state, &format!("CRASH_FORFEIT {}", started.session_id)), ); assert_eq!(forfeited.phase, "forfeit"); assert_eq!(forfeited.payout, 0); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn crash_disconnect_forfeits_the_reserved_bet() { let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); assert_eq!(started.balance, 990); core.disconnect(&state); assert_eq!(core.balance("alice"), Some(990)); assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] fn crash_rejects_a_second_round_and_bad_cashout_formats() { let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); assert_eq!( core.handle_message(&mut state, "PLAY_START crash 10"), "ERROR Crash round already in progress" ); assert_eq!( core.handle_message( &mut state, &format!("CRASH_CASHOUT {} 50.0", started.session_id) ), "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT " ); assert_eq!( core.handle_message(&mut state, "CRASH_CASHOUT nope"), "ERROR Session not found" ); } #[test] fn crash_rounds_are_owned_by_the_player_who_started_them() { let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3); let mut alice = ConnectionState::default(); let mut bob = ConnectionState::default(); register(&mut core, &mut alice, "alice"); register(&mut core, &mut bob, "bob"); login(&mut core, &mut alice, "alice"); login(&mut core, &mut bob, "bob"); let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10")); clock.advance(5.0); assert_eq!( core.handle_message(&mut bob, &format!("CRASH_CASHOUT {}", started.session_id)), "ERROR Invalid session" ); assert_eq!(core.balance("bob"), Some(1000)); assert_eq!(core.session_count(), 1, "alice's round must survive"); } #[test] fn a_crash_reservation_is_durable_until_the_round_settles() { let (mut core, path, clock) = setup_crash("pending-crash", CRASH_AT_3); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); assert_eq!(started.balance, 990); let conn = Connection::open(&path).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 1, "the reserve must be written with the debit"); drop(conn); clock.advance(5.0); let cashed = crash_state( &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), ); assert_eq!(cashed.phase, "cashed"); let conn = Connection::open(&path).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 0, "settlement must clear the reservation"); } #[test] fn a_restart_settles_an_orphaned_reservation_as_abandoned() { let (mut core, path) = setup_blackjack("pending-restart", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.phase, "player"); assert_eq!(current.balance, 990); // The process dies with the hand still running. drop(core); let conn = Connection::open(&path).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 1, "the reservation must survive the process"); drop(conn); // Boot does this once, before accepting connections. let recovered = zagan_bet::server::core::recover_orphaned_reservations(&path).unwrap(); assert_eq!(recovered, 1); let core = ServerCore::open(&path).unwrap(); assert_eq!( core.balance("alice"), Some(990), "an abandoned hand is a forfeit, not a refund" ); assert_eq!(core.play_count("alice"), 1); let conn = Connection::open(&path).unwrap(); let (game_type, bet, winnings, details): (String, i64, i64, String) = conn .query_row( "SELECT game_type, bet_amount, winnings, details FROM plays WHERE username = 'alice'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .unwrap(); assert_eq!(game_type, "blackjack"); assert_eq!(bet, 10); assert_eq!(winnings, 0); let details: serde_json::Value = serde_json::from_str(&details).unwrap(); assert_eq!(details["result"], "abandoned"); assert_eq!(details["reason"], "server_restart"); assert_eq!(details["player"][0], "10S", "the hand payload is preserved"); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 0, "the sweep must clear the reservation"); } #[test] fn an_insufficient_bet_leaves_no_reservation() { let (mut core, path) = setup_blackjack("pending-broke", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); let conn = Connection::open(&path).unwrap(); conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) .unwrap(); drop(conn); assert_eq!( core.handle_message(&mut state, "PLAY_START blackjack 10"), "ERROR Insufficient balance" ); assert_eq!(core.session_count(), 0); assert_eq!(core.balance("alice"), Some(5)); let conn = Connection::open(&path).unwrap(); let pending: i64 = conn .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) .unwrap(); assert_eq!(pending, 0); }