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/server/database.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 9 deletions(-) (limited to 'src/server/database.rs') 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