From 772727b0ef7d63144bf629d774f8b6c1c996bf78 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 21:36:26 +0100 Subject: v2.3.1: settle orphaned reservations on server restart --- AGENTS.md | 6 ++ Cargo.toml | 2 +- docs/GAMES.md | 28 +++++++++ docs/TESTING.md | 10 +++ src/bin/server/main.rs | 14 +++++ src/server/core.rs | 52 +++++++++++++--- src/server/database.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++++--- tests/server_core.rs | 130 +++++++++++++++++++++++++++++++++++++- 8 files changed, 389 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be00df5..3f96b7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,12 @@ - Every `PLAY_START` must produce a session entry before the balance is touched. The session is the audit trail for the deduction; without it the deduction is orphaned. +- Multi-step games (blackjack, crash) reserve with `database::reserve_play`: + the debit and the `pending_plays` row are one transaction. Settlement deletes + that row and writes the `plays` row in the same transaction. +- Sweep orphaned reservations only at startup (`recover_orphaned_reservations`), + never per connection. A per-connection sweep would settle another player's + live hand. An orphaned reservation is a forfeit, never a refund. - Clean up sessions on client disconnect. If a game is in progress when the connection drops, settle it as a forfeit (the reserved bet is lost) and record the play; never refund an active hand. diff --git a/Cargo.toml b/Cargo.toml index 54c41df..43b696b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "2.3.0" +version = "2.3.1" edition = "2024" [dependencies] diff --git a/docs/GAMES.md b/docs/GAMES.md index 844d1b0..2b8adf5 100644 --- a/docs/GAMES.md +++ b/docs/GAMES.md @@ -137,6 +137,34 @@ plays ( The balance update and the `plays` insert happen in one transaction (`database::apply_play`). +### Reserved bets and restarts + +Blackjack and crash reserve the bet before the round can settle. The debit and +a `pending_plays` row are written in one transaction +(`database::reserve_play`); settling deletes that row and writes the `plays` +audit row in the same transaction. + +``` +pending_plays ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + game_type TEXT NOT NULL, + bet_amount INTEGER NOT NULL, + details TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) +``` + +If the server dies with a round in flight, the reservation survives the process. +The next startup calls `recover_orphaned_reservations` before the listener +accepts connections, and each leftover is settled as a **forfeit**: the bet is +not refunded, and the audit row gets `winnings = 0`, +`details.result = "abandoned"`, `details.reason = "server_restart"`. + +The sweep runs once per process, never per connection. A per-connection sweep +would settle another player's live hand, and only one server process may own a +database. + ## Wire protocol Slots keeps its original exchange: diff --git a/docs/TESTING.md b/docs/TESTING.md index 3c7fa61..60bc4ac 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -111,6 +111,16 @@ Use an 80-column terminal because several output lines are width-sensitive. 7. Confirm the crash point is absent from the `CRASH_START` line (it must only appear once the round is over). +## Manual restart check + +1. Start a blackjack hand (or a crash round) and confirm `pending_plays` has one + row while the round is live. +2. `kill -9` the server, then start it again on the same database. The log must + say `Settled 1 orphaned reservation(s) as abandoned after a restart`. +3. Confirm the balance did not change, `pending_plays` is empty, and `plays` has + one row with `winnings = 0`, `details.result = "abandoned"` and + `details.reason = "server_restart"`. + ## Manual server checks - Register, log out, log in again (bcrypt path). diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index 2770387..85e3134 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -35,6 +35,20 @@ fn main() { } }; + // A round cannot survive the process that held its state. Record any bet + // that was reserved but never settled, before serving new players. + let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); + match zagan_bet::server::core::recover_orphaned_reservations(&db_path) { + Ok(0) => {} + Ok(count) => println!( + "Settled {count} orphaned reservation(s) as abandoned after a restart" + ), + Err(error) => { + eprintln!("Failed to settle orphaned reservations in {db_path}: {error}"); + std::process::exit(1); + } + } + let tls = match tls::TlsServer::new("identity.p12") { Ok(t) => Arc::new(t), Err(e) => { diff --git a/src/server/core.rs b/src/server/core.rs index d4c95f6..e0f0ff2 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -17,6 +17,17 @@ pub struct ConnectionState { user: Option, } +/// Settle reservations left behind by a previous process. +/// +/// Call this **once at server startup, before accepting connections**. A +/// per-connection sweep would be wrong: it would settle another player's live +/// hand. Only one process may own the database. +pub fn recover_orphaned_reservations(db_path: &str) -> rusqlite::Result { + let mut conn = Connection::open(db_path)?; + database::init_db(&conn); + database::abandon_pending(&mut conn) +} + impl ConnectionState { pub fn user(&self) -> Option<&str> { self.user.as_deref() @@ -269,13 +280,23 @@ impl ServerCore { if balance < bet { return "ERROR Insufficient balance".to_string(); } - let balance = match database::debit(&mut self.conn, user, bet) { - Ok(balance) => balance, - Err(_) => return "ERROR Insufficient balance".to_string(), - }; let hand = self.blackjack.deal(bet as u64); let session_id = self.create_blackjack_session(user, &hand); + let balance = match database::reserve_play( + &mut self.conn, + &session_id, + user, + "blackjack", + bet, + &hand.to_json(), + ) { + Ok(balance) => balance, + Err(_) => { + let _ = self.sessions.remove_session(&session_id); + return "ERROR Insufficient balance".to_string(); + } + }; self.blackjack_response(user, &session_id, hand, balance) } @@ -330,11 +351,21 @@ impl ServerCore { if hand.player.len() != 2 { return "ERROR Double is only allowed on the first two cards".to_string(); } - if database::debit(&mut self.conn, user, hand.base_bet as i64).is_err() { + if database::extend_reservation( + &mut self.conn, + session_id, + hand.base_bet as i64, + ) + .is_err() + { return "ERROR Insufficient balance".to_string(); } if let Err(error) = hand.double() { - let _ = database::credit(&mut self.conn, user, hand.base_bet as i64); + let _ = database::refund_reservation( + &mut self.conn, + session_id, + hand.base_bet as i64, + ); return format!("ERROR {error}"); } } @@ -456,7 +487,14 @@ impl ServerCore { .sessions .create_session(user, "crash".to_string(), game_state); - let balance = match database::debit(&mut self.conn, user, bet) { + let balance = match database::reserve_play( + &mut self.conn, + &session_id, + user, + "crash", + bet, + &round.to_json(), + ) { Ok(balance) => balance, Err(_) => { let _ = self.sessions.remove_session(&session_id); diff --git a/src/server/database.rs b/src/server/database.rs index 99e0dde..3cd86e4 100644 --- a/src/server/database.rs +++ b/src/server/database.rs @@ -31,6 +31,20 @@ pub fn init_db(conn: &Connection) { [], ) .unwrap(); + // A multi-step game's reserved bet is written here in the same transaction + // as the debit, so a crash between reserve and settle is recoverable. + conn.execute( + "CREATE TABLE IF NOT EXISTS pending_plays ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + game_type TEXT NOT NULL, + bet_amount INTEGER NOT NULL, + details TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ) + .unwrap(); } pub fn add_user( @@ -179,16 +193,33 @@ pub fn count_plays(conn: &Connection, username: &str) -> i64 { } /// Reserve a bet before a multi-step hand starts. -pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result { +/// +/// The debit and the `pending_plays` row are one transaction. If the server +/// dies before the hand settles, the row is what lets the next startup record +/// the lost bet instead of leaving an orphaned deduction. +#[allow(clippy::too_many_arguments)] +pub fn reserve_play( + conn: &mut Connection, + session_id: &str, + username: &str, + game_type: &str, + bet_amount: i64, + details: &str, +) -> rusqlite::Result { let tx = conn.transaction()?; let updated = tx.execute( "UPDATE users SET balance = balance - ?1 WHERE username = ?2 AND balance >= ?1", - params![amount, username], + params![bet_amount, username], )?; if updated != 1 { return Err(rusqlite::Error::QueryReturnedNoRows); } + tx.execute( + "INSERT INTO pending_plays (session_id, username, game_type, bet_amount, details) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![session_id, username, game_type, bet_amount, details], + )?; let balance = tx.query_row( "SELECT balance FROM users WHERE username = ?1", [username], @@ -198,16 +229,57 @@ pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Re Ok(balance) } -/// Put a reserved bet back (disconnect or cancel). -pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result { +/// Add to an existing reservation (blackjack double), keeping the pending row +/// and the debit in step. +pub fn extend_reservation( + conn: &mut Connection, + session_id: &str, + amount: i64, +) -> rusqlite::Result { + let tx = conn.transaction()?; + let updated = tx.execute( + "UPDATE users SET balance = balance - ?1 + WHERE balance >= ?1 + AND username = (SELECT username FROM pending_plays WHERE session_id = ?2)", + params![amount, session_id], + )?; + if updated != 1 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + tx.execute( + "UPDATE pending_plays SET bet_amount = bet_amount + ?1 WHERE session_id = ?2", + params![amount, session_id], + )?; + let balance = tx.query_row( + "SELECT balance FROM users + WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?1)", + [session_id], + |row| row.get(0), + )?; + tx.commit()?; + Ok(balance) +} + +/// Undo part of a reservation when the action that needed it fails. +pub fn refund_reservation( + conn: &mut Connection, + session_id: &str, + amount: i64, +) -> rusqlite::Result { let tx = conn.transaction()?; tx.execute( - "UPDATE users SET balance = balance + ?1 WHERE username = ?2", - params![amount, username], + "UPDATE users SET balance = balance + ?1 + WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?2)", + params![amount, session_id], + )?; + tx.execute( + "UPDATE pending_plays SET bet_amount = MAX(bet_amount - ?1, 0) WHERE session_id = ?2", + params![amount, session_id], )?; let balance = tx.query_row( - "SELECT balance FROM users WHERE username = ?1", - [username], + "SELECT balance FROM users + WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?1)", + [session_id], |row| row.get(0), )?; tx.commit()?; @@ -215,7 +287,7 @@ pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::R } /// Settle a hand whose bet was already reserved: credit winnings, write the -/// audit row, and return the new balance. +/// audit row, clear the reservation, and return the new balance. #[allow(clippy::too_many_arguments)] pub fn settle_play( conn: &mut Connection, @@ -255,6 +327,81 @@ pub fn settle_play( ], )?; + tx.execute( + "DELETE FROM pending_plays WHERE session_id = ?1", + [session_id], + )?; + tx.commit()?; Ok(balance_after) } + +/// Settle reservations left behind by a server restart. +/// +/// A round cannot outlive the process that held its in-memory state, so the +/// reserved bet is forfeited and recorded, never refunded. Call this once at +/// startup, before serving players: sweeping while rounds are live would settle +/// someone else's hand. Returns how many orphaned plays were settled. +pub fn abandon_pending(conn: &mut Connection) -> rusqlite::Result { + let tx = conn.transaction()?; + let pending: Vec<(String, String, String, i64, String)> = { + let mut statement = tx.prepare( + "SELECT session_id, username, game_type, bet_amount, details + FROM pending_plays", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + })?; + rows.collect::>()? + }; + + for (session_id, username, game_type, bet_amount, details) in &pending { + let balance_after: i64 = tx + .query_row( + "SELECT balance FROM users WHERE username = ?1", + [username], + |row| row.get(0), + ) + .unwrap_or(0); + let details = abandoned_details(details); + tx.execute( + "INSERT OR IGNORE INTO plays + (session_id, username, game_type, bet_amount, winnings, + balance_after, details) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", + params![ + session_id, + username, + game_type, + bet_amount, + balance_after, + details + ], + )?; + tx.execute( + "DELETE FROM pending_plays WHERE session_id = ?1", + [session_id], + )?; + } + + tx.commit()?; + Ok(pending.len()) +} + +/// Mark a game's own detail blob as abandoned without dropping its payload. +fn abandoned_details(raw: &str) -> String { + let mut value: serde_json::Value = serde_json::from_str(raw) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + if let Some(object) = value.as_object_mut() { + object.insert("result".to_string(), serde_json::json!("abandoned")); + object.insert("phase".to_string(), serde_json::json!("abandoned")); + object.insert("reason".to_string(), serde_json::json!("server_restart")); + } + value.to_string() +} diff --git a/tests/server_core.rs b/tests/server_core.rs index 695cfe4..0350fb6 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -442,7 +442,7 @@ fn blackjack_reserves_the_bet_and_settles_a_hit_hand() { #[test] fn blackjack_double_reserves_an_extra_bet() { - let (mut core, _path) = setup_blackjack( + let (mut core, path) = setup_blackjack( "blackjack-double", &["5S", "9D", "5H", "8C", "10D"], ); @@ -464,6 +464,21 @@ fn blackjack_double_reserves_an_extra_bet() { 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] @@ -741,3 +756,116 @@ fn crash_rounds_are_owned_by_the_player_who_started_them() { 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); +} -- cgit v1.2.3