diff options
Diffstat (limited to '')
| -rw-r--r-- | src/server/database.rs | 165 |
1 files changed, 156 insertions, 9 deletions
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) { | |||
| 31 | [], | 31 | [], |
| 32 | ) | 32 | ) |
| 33 | .unwrap(); | 33 | .unwrap(); |
| 34 | // A multi-step game's reserved bet is written here in the same transaction | ||
| 35 | // as the debit, so a crash between reserve and settle is recoverable. | ||
| 36 | conn.execute( | ||
| 37 | "CREATE TABLE IF NOT EXISTS pending_plays ( | ||
| 38 | session_id TEXT PRIMARY KEY, | ||
| 39 | username TEXT NOT NULL, | ||
| 40 | game_type TEXT NOT NULL, | ||
| 41 | bet_amount INTEGER NOT NULL, | ||
| 42 | details TEXT NOT NULL DEFAULT '{}', | ||
| 43 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| 44 | )", | ||
| 45 | [], | ||
| 46 | ) | ||
| 47 | .unwrap(); | ||
| 34 | } | 48 | } |
| 35 | 49 | ||
| 36 | pub fn add_user( | 50 | pub fn add_user( |
| @@ -179,16 +193,33 @@ pub fn count_plays(conn: &Connection, username: &str) -> i64 { | |||
| 179 | } | 193 | } |
| 180 | 194 | ||
| 181 | /// Reserve a bet before a multi-step hand starts. | 195 | /// Reserve a bet before a multi-step hand starts. |
| 182 | pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> { | 196 | /// |
| 197 | /// The debit and the `pending_plays` row are one transaction. If the server | ||
| 198 | /// dies before the hand settles, the row is what lets the next startup record | ||
| 199 | /// the lost bet instead of leaving an orphaned deduction. | ||
| 200 | #[allow(clippy::too_many_arguments)] | ||
| 201 | pub fn reserve_play( | ||
| 202 | conn: &mut Connection, | ||
| 203 | session_id: &str, | ||
| 204 | username: &str, | ||
| 205 | game_type: &str, | ||
| 206 | bet_amount: i64, | ||
| 207 | details: &str, | ||
| 208 | ) -> rusqlite::Result<i64> { | ||
| 183 | let tx = conn.transaction()?; | 209 | let tx = conn.transaction()?; |
| 184 | let updated = tx.execute( | 210 | let updated = tx.execute( |
| 185 | "UPDATE users SET balance = balance - ?1 | 211 | "UPDATE users SET balance = balance - ?1 |
| 186 | WHERE username = ?2 AND balance >= ?1", | 212 | WHERE username = ?2 AND balance >= ?1", |
| 187 | params![amount, username], | 213 | params![bet_amount, username], |
| 188 | )?; | 214 | )?; |
| 189 | if updated != 1 { | 215 | if updated != 1 { |
| 190 | return Err(rusqlite::Error::QueryReturnedNoRows); | 216 | return Err(rusqlite::Error::QueryReturnedNoRows); |
| 191 | } | 217 | } |
| 218 | tx.execute( | ||
| 219 | "INSERT INTO pending_plays (session_id, username, game_type, bet_amount, details) | ||
| 220 | VALUES (?1, ?2, ?3, ?4, ?5)", | ||
| 221 | params![session_id, username, game_type, bet_amount, details], | ||
| 222 | )?; | ||
| 192 | let balance = tx.query_row( | 223 | let balance = tx.query_row( |
| 193 | "SELECT balance FROM users WHERE username = ?1", | 224 | "SELECT balance FROM users WHERE username = ?1", |
| 194 | [username], | 225 | [username], |
| @@ -198,16 +229,57 @@ pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Re | |||
| 198 | Ok(balance) | 229 | Ok(balance) |
| 199 | } | 230 | } |
| 200 | 231 | ||
| 201 | /// Put a reserved bet back (disconnect or cancel). | 232 | /// Add to an existing reservation (blackjack double), keeping the pending row |
| 202 | pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> { | 233 | /// and the debit in step. |
| 234 | pub fn extend_reservation( | ||
| 235 | conn: &mut Connection, | ||
| 236 | session_id: &str, | ||
| 237 | amount: i64, | ||
| 238 | ) -> rusqlite::Result<i64> { | ||
| 239 | let tx = conn.transaction()?; | ||
| 240 | let updated = tx.execute( | ||
| 241 | "UPDATE users SET balance = balance - ?1 | ||
| 242 | WHERE balance >= ?1 | ||
| 243 | AND username = (SELECT username FROM pending_plays WHERE session_id = ?2)", | ||
| 244 | params![amount, session_id], | ||
| 245 | )?; | ||
| 246 | if updated != 1 { | ||
| 247 | return Err(rusqlite::Error::QueryReturnedNoRows); | ||
| 248 | } | ||
| 249 | tx.execute( | ||
| 250 | "UPDATE pending_plays SET bet_amount = bet_amount + ?1 WHERE session_id = ?2", | ||
| 251 | params![amount, session_id], | ||
| 252 | )?; | ||
| 253 | let balance = tx.query_row( | ||
| 254 | "SELECT balance FROM users | ||
| 255 | WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?1)", | ||
| 256 | [session_id], | ||
| 257 | |row| row.get(0), | ||
| 258 | )?; | ||
| 259 | tx.commit()?; | ||
| 260 | Ok(balance) | ||
| 261 | } | ||
| 262 | |||
| 263 | /// Undo part of a reservation when the action that needed it fails. | ||
| 264 | pub fn refund_reservation( | ||
| 265 | conn: &mut Connection, | ||
| 266 | session_id: &str, | ||
| 267 | amount: i64, | ||
| 268 | ) -> rusqlite::Result<i64> { | ||
| 203 | let tx = conn.transaction()?; | 269 | let tx = conn.transaction()?; |
| 204 | tx.execute( | 270 | tx.execute( |
| 205 | "UPDATE users SET balance = balance + ?1 WHERE username = ?2", | 271 | "UPDATE users SET balance = balance + ?1 |
| 206 | params![amount, username], | 272 | WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?2)", |
| 273 | params![amount, session_id], | ||
| 274 | )?; | ||
| 275 | tx.execute( | ||
| 276 | "UPDATE pending_plays SET bet_amount = MAX(bet_amount - ?1, 0) WHERE session_id = ?2", | ||
| 277 | params![amount, session_id], | ||
| 207 | )?; | 278 | )?; |
| 208 | let balance = tx.query_row( | 279 | let balance = tx.query_row( |
| 209 | "SELECT balance FROM users WHERE username = ?1", | 280 | "SELECT balance FROM users |
| 210 | [username], | 281 | WHERE username = (SELECT username FROM pending_plays WHERE session_id = ?1)", |
| 282 | [session_id], | ||
| 211 | |row| row.get(0), | 283 | |row| row.get(0), |
| 212 | )?; | 284 | )?; |
| 213 | tx.commit()?; | 285 | tx.commit()?; |
| @@ -215,7 +287,7 @@ pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::R | |||
| 215 | } | 287 | } |
| 216 | 288 | ||
| 217 | /// Settle a hand whose bet was already reserved: credit winnings, write the | 289 | /// Settle a hand whose bet was already reserved: credit winnings, write the |
| 218 | /// audit row, and return the new balance. | 290 | /// audit row, clear the reservation, and return the new balance. |
| 219 | #[allow(clippy::too_many_arguments)] | 291 | #[allow(clippy::too_many_arguments)] |
| 220 | pub fn settle_play( | 292 | pub fn settle_play( |
| 221 | conn: &mut Connection, | 293 | conn: &mut Connection, |
| @@ -255,6 +327,81 @@ pub fn settle_play( | |||
| 255 | ], | 327 | ], |
| 256 | )?; | 328 | )?; |
| 257 | 329 | ||
| 330 | tx.execute( | ||
| 331 | "DELETE FROM pending_plays WHERE session_id = ?1", | ||
| 332 | [session_id], | ||
| 333 | )?; | ||
| 334 | |||
| 258 | tx.commit()?; | 335 | tx.commit()?; |
| 259 | Ok(balance_after) | 336 | Ok(balance_after) |
| 260 | } | 337 | } |
| 338 | |||
| 339 | /// Settle reservations left behind by a server restart. | ||
| 340 | /// | ||
| 341 | /// A round cannot outlive the process that held its in-memory state, so the | ||
| 342 | /// reserved bet is forfeited and recorded, never refunded. Call this once at | ||
| 343 | /// startup, before serving players: sweeping while rounds are live would settle | ||
| 344 | /// someone else's hand. Returns how many orphaned plays were settled. | ||
| 345 | pub fn abandon_pending(conn: &mut Connection) -> rusqlite::Result<usize> { | ||
| 346 | let tx = conn.transaction()?; | ||
| 347 | let pending: Vec<(String, String, String, i64, String)> = { | ||
| 348 | let mut statement = tx.prepare( | ||
| 349 | "SELECT session_id, username, game_type, bet_amount, details | ||
| 350 | FROM pending_plays", | ||
| 351 | )?; | ||
| 352 | let rows = statement.query_map([], |row| { | ||
| 353 | Ok(( | ||
| 354 | row.get(0)?, | ||
| 355 | row.get(1)?, | ||
| 356 | row.get(2)?, | ||
| 357 | row.get(3)?, | ||
| 358 | row.get(4)?, | ||
| 359 | )) | ||
| 360 | })?; | ||
| 361 | rows.collect::<rusqlite::Result<_>>()? | ||
| 362 | }; | ||
| 363 | |||
| 364 | for (session_id, username, game_type, bet_amount, details) in &pending { | ||
| 365 | let balance_after: i64 = tx | ||
| 366 | .query_row( | ||
| 367 | "SELECT balance FROM users WHERE username = ?1", | ||
| 368 | [username], | ||
| 369 | |row| row.get(0), | ||
| 370 | ) | ||
| 371 | .unwrap_or(0); | ||
| 372 | let details = abandoned_details(details); | ||
| 373 | tx.execute( | ||
| 374 | "INSERT OR IGNORE INTO plays | ||
| 375 | (session_id, username, game_type, bet_amount, winnings, | ||
| 376 | balance_after, details) | ||
| 377 | VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6)", | ||
| 378 | params![ | ||
| 379 | session_id, | ||
| 380 | username, | ||
| 381 | game_type, | ||
| 382 | bet_amount, | ||
| 383 | balance_after, | ||
| 384 | details | ||
| 385 | ], | ||
| 386 | )?; | ||
| 387 | tx.execute( | ||
| 388 | "DELETE FROM pending_plays WHERE session_id = ?1", | ||
| 389 | [session_id], | ||
| 390 | )?; | ||
| 391 | } | ||
| 392 | |||
| 393 | tx.commit()?; | ||
| 394 | Ok(pending.len()) | ||
| 395 | } | ||
| 396 | |||
| 397 | /// Mark a game's own detail blob as abandoned without dropping its payload. | ||
| 398 | fn abandoned_details(raw: &str) -> String { | ||
| 399 | let mut value: serde_json::Value = serde_json::from_str(raw) | ||
| 400 | .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); | ||
| 401 | if let Some(object) = value.as_object_mut() { | ||
| 402 | object.insert("result".to_string(), serde_json::json!("abandoned")); | ||
| 403 | object.insert("phase".to_string(), serde_json::json!("abandoned")); | ||
| 404 | object.insert("reason".to_string(), serde_json::json!("server_restart")); | ||
| 405 | } | ||
| 406 | value.to_string() | ||
| 407 | } | ||
