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 --- src/bin/server/main.rs | 14 +++++ src/server/core.rs | 52 +++++++++++++--- src/server/database.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 215 insertions(+), 16 deletions(-) (limited to 'src') 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() +} -- cgit v1.2.3