diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 21:36:26 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 21:36:26 +0100 |
| commit | 772727b0ef7d63144bf629d774f8b6c1c996bf78 (patch) | |
| tree | 1680dbbd61fce2aaf383ee81ad04ff0705ffe985 | |
| parent | v2.3.0: add crash with local multiplier and pushed crash event (diff) | |
| download | zagan-bet-772727b0ef7d63144bf629d774f8b6c1c996bf78.tar.gz zagan-bet-772727b0ef7d63144bf629d774f8b6c1c996bf78.zip | |
v2.3.1: settle orphaned reservations on server restart
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GAMES.md | 28 | ||||
| -rw-r--r-- | docs/TESTING.md | 10 | ||||
| -rw-r--r-- | src/bin/server/main.rs | 14 | ||||
| -rw-r--r-- | src/server/core.rs | 52 | ||||
| -rw-r--r-- | src/server/database.rs | 165 | ||||
| -rw-r--r-- | tests/server_core.rs | 130 |
8 files changed, 389 insertions, 18 deletions
| @@ -20,6 +20,12 @@ | |||
| 20 | - Every `PLAY_START` must produce a session entry before the balance is | 20 | - Every `PLAY_START` must produce a session entry before the balance is |
| 21 | touched. The session is the audit trail for the deduction; without it the | 21 | touched. The session is the audit trail for the deduction; without it the |
| 22 | deduction is orphaned. | 22 | deduction is orphaned. |
| 23 | - Multi-step games (blackjack, crash) reserve with `database::reserve_play`: | ||
| 24 | the debit and the `pending_plays` row are one transaction. Settlement deletes | ||
| 25 | that row and writes the `plays` row in the same transaction. | ||
| 26 | - Sweep orphaned reservations only at startup (`recover_orphaned_reservations`), | ||
| 27 | never per connection. A per-connection sweep would settle another player's | ||
| 28 | live hand. An orphaned reservation is a forfeit, never a refund. | ||
| 23 | - Clean up sessions on client disconnect. If a game is in progress when the | 29 | - Clean up sessions on client disconnect. If a game is in progress when the |
| 24 | connection drops, settle it as a forfeit (the reserved bet is lost) and | 30 | connection drops, settle it as a forfeit (the reserved bet is lost) and |
| 25 | record the play; never refund an active hand. | 31 | record the play; never refund an active hand. |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "2.3.0" | 3 | version = "2.3.1" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [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 ( | |||
| 137 | The balance update and the `plays` insert happen in one transaction | 137 | The balance update and the `plays` insert happen in one transaction |
| 138 | (`database::apply_play`). | 138 | (`database::apply_play`). |
| 139 | 139 | ||
| 140 | ### Reserved bets and restarts | ||
| 141 | |||
| 142 | Blackjack and crash reserve the bet before the round can settle. The debit and | ||
| 143 | a `pending_plays` row are written in one transaction | ||
| 144 | (`database::reserve_play`); settling deletes that row and writes the `plays` | ||
| 145 | audit row in the same transaction. | ||
| 146 | |||
| 147 | ``` | ||
| 148 | pending_plays ( | ||
| 149 | session_id TEXT PRIMARY KEY, | ||
| 150 | username TEXT NOT NULL, | ||
| 151 | game_type TEXT NOT NULL, | ||
| 152 | bet_amount INTEGER NOT NULL, | ||
| 153 | details TEXT NOT NULL DEFAULT '{}', | ||
| 154 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| 155 | ) | ||
| 156 | ``` | ||
| 157 | |||
| 158 | If the server dies with a round in flight, the reservation survives the process. | ||
| 159 | The next startup calls `recover_orphaned_reservations` before the listener | ||
| 160 | accepts connections, and each leftover is settled as a **forfeit**: the bet is | ||
| 161 | not refunded, and the audit row gets `winnings = 0`, | ||
| 162 | `details.result = "abandoned"`, `details.reason = "server_restart"`. | ||
| 163 | |||
| 164 | The sweep runs once per process, never per connection. A per-connection sweep | ||
| 165 | would settle another player's live hand, and only one server process may own a | ||
| 166 | database. | ||
| 167 | |||
| 140 | ## Wire protocol | 168 | ## Wire protocol |
| 141 | 169 | ||
| 142 | Slots keeps its original exchange: | 170 | 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. | |||
| 111 | 7. Confirm the crash point is absent from the `CRASH_START` line (it must only | 111 | 7. Confirm the crash point is absent from the `CRASH_START` line (it must only |
| 112 | appear once the round is over). | 112 | appear once the round is over). |
| 113 | 113 | ||
| 114 | ## Manual restart check | ||
| 115 | |||
| 116 | 1. Start a blackjack hand (or a crash round) and confirm `pending_plays` has one | ||
| 117 | row while the round is live. | ||
| 118 | 2. `kill -9` the server, then start it again on the same database. The log must | ||
| 119 | say `Settled 1 orphaned reservation(s) as abandoned after a restart`. | ||
| 120 | 3. Confirm the balance did not change, `pending_plays` is empty, and `plays` has | ||
| 121 | one row with `winnings = 0`, `details.result = "abandoned"` and | ||
| 122 | `details.reason = "server_restart"`. | ||
| 123 | |||
| 114 | ## Manual server checks | 124 | ## Manual server checks |
| 115 | 125 | ||
| 116 | - Register, log out, log in again (bcrypt path). | 126 | - 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() { | |||
| 35 | } | 35 | } |
| 36 | }; | 36 | }; |
| 37 | 37 | ||
| 38 | // A round cannot survive the process that held its state. Record any bet | ||
| 39 | // that was reserved but never settled, before serving new players. | ||
| 40 | let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()); | ||
| 41 | match zagan_bet::server::core::recover_orphaned_reservations(&db_path) { | ||
| 42 | Ok(0) => {} | ||
| 43 | Ok(count) => println!( | ||
| 44 | "Settled {count} orphaned reservation(s) as abandoned after a restart" | ||
| 45 | ), | ||
| 46 | Err(error) => { | ||
| 47 | eprintln!("Failed to settle orphaned reservations in {db_path}: {error}"); | ||
| 48 | std::process::exit(1); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 38 | let tls = match tls::TlsServer::new("identity.p12") { | 52 | let tls = match tls::TlsServer::new("identity.p12") { |
| 39 | Ok(t) => Arc::new(t), | 53 | Ok(t) => Arc::new(t), |
| 40 | Err(e) => { | 54 | 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 { | |||
| 17 | user: Option<String>, | 17 | user: Option<String>, |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | /// Settle reservations left behind by a previous process. | ||
| 21 | /// | ||
| 22 | /// Call this **once at server startup, before accepting connections**. A | ||
| 23 | /// per-connection sweep would be wrong: it would settle another player's live | ||
| 24 | /// hand. Only one process may own the database. | ||
| 25 | pub fn recover_orphaned_reservations(db_path: &str) -> rusqlite::Result<usize> { | ||
| 26 | let mut conn = Connection::open(db_path)?; | ||
| 27 | database::init_db(&conn); | ||
| 28 | database::abandon_pending(&mut conn) | ||
| 29 | } | ||
| 30 | |||
| 20 | impl ConnectionState { | 31 | impl ConnectionState { |
| 21 | pub fn user(&self) -> Option<&str> { | 32 | pub fn user(&self) -> Option<&str> { |
| 22 | self.user.as_deref() | 33 | self.user.as_deref() |
| @@ -269,13 +280,23 @@ impl ServerCore { | |||
| 269 | if balance < bet { | 280 | if balance < bet { |
| 270 | return "ERROR Insufficient balance".to_string(); | 281 | return "ERROR Insufficient balance".to_string(); |
| 271 | } | 282 | } |
| 272 | let balance = match database::debit(&mut self.conn, user, bet) { | ||
| 273 | Ok(balance) => balance, | ||
| 274 | Err(_) => return "ERROR Insufficient balance".to_string(), | ||
| 275 | }; | ||
| 276 | 283 | ||
| 277 | let hand = self.blackjack.deal(bet as u64); | 284 | let hand = self.blackjack.deal(bet as u64); |
| 278 | let session_id = self.create_blackjack_session(user, &hand); | 285 | let session_id = self.create_blackjack_session(user, &hand); |
| 286 | let balance = match database::reserve_play( | ||
| 287 | &mut self.conn, | ||
| 288 | &session_id, | ||
| 289 | user, | ||
| 290 | "blackjack", | ||
| 291 | bet, | ||
| 292 | &hand.to_json(), | ||
| 293 | ) { | ||
| 294 | Ok(balance) => balance, | ||
| 295 | Err(_) => { | ||
| 296 | let _ = self.sessions.remove_session(&session_id); | ||
| 297 | return "ERROR Insufficient balance".to_string(); | ||
| 298 | } | ||
| 299 | }; | ||
| 279 | self.blackjack_response(user, &session_id, hand, balance) | 300 | self.blackjack_response(user, &session_id, hand, balance) |
| 280 | } | 301 | } |
| 281 | 302 | ||
| @@ -330,11 +351,21 @@ impl ServerCore { | |||
| 330 | if hand.player.len() != 2 { | 351 | if hand.player.len() != 2 { |
| 331 | return "ERROR Double is only allowed on the first two cards".to_string(); | 352 | return "ERROR Double is only allowed on the first two cards".to_string(); |
| 332 | } | 353 | } |
| 333 | if database::debit(&mut self.conn, user, hand.base_bet as i64).is_err() { | 354 | if database::extend_reservation( |
| 355 | &mut self.conn, | ||
| 356 | session_id, | ||
| 357 | hand.base_bet as i64, | ||
| 358 | ) | ||
| 359 | .is_err() | ||
| 360 | { | ||
| 334 | return "ERROR Insufficient balance".to_string(); | 361 | return "ERROR Insufficient balance".to_string(); |
| 335 | } | 362 | } |
| 336 | if let Err(error) = hand.double() { | 363 | if let Err(error) = hand.double() { |
| 337 | let _ = database::credit(&mut self.conn, user, hand.base_bet as i64); | 364 | let _ = database::refund_reservation( |
| 365 | &mut self.conn, | ||
| 366 | session_id, | ||
| 367 | hand.base_bet as i64, | ||
| 368 | ); | ||
| 338 | return format!("ERROR {error}"); | 369 | return format!("ERROR {error}"); |
| 339 | } | 370 | } |
| 340 | } | 371 | } |
| @@ -456,7 +487,14 @@ impl ServerCore { | |||
| 456 | .sessions | 487 | .sessions |
| 457 | .create_session(user, "crash".to_string(), game_state); | 488 | .create_session(user, "crash".to_string(), game_state); |
| 458 | 489 | ||
| 459 | let balance = match database::debit(&mut self.conn, user, bet) { | 490 | let balance = match database::reserve_play( |
| 491 | &mut self.conn, | ||
| 492 | &session_id, | ||
| 493 | user, | ||
| 494 | "crash", | ||
| 495 | bet, | ||
| 496 | &round.to_json(), | ||
| 497 | ) { | ||
| 460 | Ok(balance) => balance, | 498 | Ok(balance) => balance, |
| 461 | Err(_) => { | 499 | Err(_) => { |
| 462 | let _ = self.sessions.remove_session(&session_id); | 500 | 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) { | |||
| 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 | } | ||
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() { | |||
| 442 | 442 | ||
| 443 | #[test] | 443 | #[test] |
| 444 | fn blackjack_double_reserves_an_extra_bet() { | 444 | fn blackjack_double_reserves_an_extra_bet() { |
| 445 | let (mut core, _path) = setup_blackjack( | 445 | let (mut core, path) = setup_blackjack( |
| 446 | "blackjack-double", | 446 | "blackjack-double", |
| 447 | &["5S", "9D", "5H", "8C", "10D"], | 447 | &["5S", "9D", "5H", "8C", "10D"], |
| 448 | ); | 448 | ); |
| @@ -464,6 +464,21 @@ fn blackjack_double_reserves_an_extra_bet() { | |||
| 464 | assert_eq!(done.balance, 1020); | 464 | assert_eq!(done.balance, 1020); |
| 465 | assert_eq!(core.balance("alice"), Some(1020)); | 465 | assert_eq!(core.balance("alice"), Some(1020)); |
| 466 | assert_eq!(core.play_count("alice"), 1); | 466 | assert_eq!(core.play_count("alice"), 1); |
| 467 | |||
| 468 | // A double extends the reservation, and settling clears it. | ||
| 469 | let conn = Connection::open(&path).unwrap(); | ||
| 470 | let pending: i64 = conn | ||
| 471 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 472 | .unwrap(); | ||
| 473 | assert_eq!(pending, 0); | ||
| 474 | let bet: i64 = conn | ||
| 475 | .query_row( | ||
| 476 | "SELECT bet_amount FROM plays WHERE username = 'alice'", | ||
| 477 | [], | ||
| 478 | |row| row.get(0), | ||
| 479 | ) | ||
| 480 | .unwrap(); | ||
| 481 | assert_eq!(bet, 20); | ||
| 467 | } | 482 | } |
| 468 | 483 | ||
| 469 | #[test] | 484 | #[test] |
| @@ -741,3 +756,116 @@ fn crash_rounds_are_owned_by_the_player_who_started_them() { | |||
| 741 | assert_eq!(core.balance("bob"), Some(1000)); | 756 | assert_eq!(core.balance("bob"), Some(1000)); |
| 742 | assert_eq!(core.session_count(), 1, "alice's round must survive"); | 757 | assert_eq!(core.session_count(), 1, "alice's round must survive"); |
| 743 | } | 758 | } |
| 759 | |||
| 760 | #[test] | ||
| 761 | fn a_crash_reservation_is_durable_until_the_round_settles() { | ||
| 762 | let (mut core, path, clock) = setup_crash("pending-crash", CRASH_AT_3); | ||
| 763 | let mut state = ConnectionState::default(); | ||
| 764 | register(&mut core, &mut state, "alice"); | ||
| 765 | login(&mut core, &mut state, "alice"); | ||
| 766 | |||
| 767 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 768 | assert_eq!(started.balance, 990); | ||
| 769 | |||
| 770 | let conn = Connection::open(&path).unwrap(); | ||
| 771 | let pending: i64 = conn | ||
| 772 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 773 | .unwrap(); | ||
| 774 | assert_eq!(pending, 1, "the reserve must be written with the debit"); | ||
| 775 | drop(conn); | ||
| 776 | |||
| 777 | clock.advance(5.0); | ||
| 778 | let cashed = crash_state(&core.handle_message( | ||
| 779 | &mut state, | ||
| 780 | &format!("CRASH_CASHOUT {}", started.session_id), | ||
| 781 | )); | ||
| 782 | assert_eq!(cashed.phase, "cashed"); | ||
| 783 | |||
| 784 | let conn = Connection::open(&path).unwrap(); | ||
| 785 | let pending: i64 = conn | ||
| 786 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 787 | .unwrap(); | ||
| 788 | assert_eq!(pending, 0, "settlement must clear the reservation"); | ||
| 789 | } | ||
| 790 | |||
| 791 | #[test] | ||
| 792 | fn a_restart_settles_an_orphaned_reservation_as_abandoned() { | ||
| 793 | let (mut core, path) = setup_blackjack("pending-restart", &["10S", "9D", "6H", "8C"]); | ||
| 794 | let mut state = ConnectionState::default(); | ||
| 795 | register(&mut core, &mut state, "alice"); | ||
| 796 | login(&mut core, &mut state, "alice"); | ||
| 797 | |||
| 798 | let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); | ||
| 799 | assert_eq!(current.phase, "player"); | ||
| 800 | assert_eq!(current.balance, 990); | ||
| 801 | |||
| 802 | // The process dies with the hand still running. | ||
| 803 | drop(core); | ||
| 804 | |||
| 805 | let conn = Connection::open(&path).unwrap(); | ||
| 806 | let pending: i64 = conn | ||
| 807 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 808 | .unwrap(); | ||
| 809 | assert_eq!(pending, 1, "the reservation must survive the process"); | ||
| 810 | drop(conn); | ||
| 811 | |||
| 812 | // Boot does this once, before accepting connections. | ||
| 813 | let recovered = zagan_bet::server::core::recover_orphaned_reservations(&path).unwrap(); | ||
| 814 | assert_eq!(recovered, 1); | ||
| 815 | |||
| 816 | let core = ServerCore::open(&path).unwrap(); | ||
| 817 | assert_eq!( | ||
| 818 | core.balance("alice"), | ||
| 819 | Some(990), | ||
| 820 | "an abandoned hand is a forfeit, not a refund" | ||
| 821 | ); | ||
| 822 | assert_eq!(core.play_count("alice"), 1); | ||
| 823 | |||
| 824 | let conn = Connection::open(&path).unwrap(); | ||
| 825 | let (game_type, bet, winnings, details): (String, i64, i64, String) = conn | ||
| 826 | .query_row( | ||
| 827 | "SELECT game_type, bet_amount, winnings, details | ||
| 828 | FROM plays WHERE username = 'alice'", | ||
| 829 | [], | ||
| 830 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | ||
| 831 | ) | ||
| 832 | .unwrap(); | ||
| 833 | assert_eq!(game_type, "blackjack"); | ||
| 834 | assert_eq!(bet, 10); | ||
| 835 | assert_eq!(winnings, 0); | ||
| 836 | let details: serde_json::Value = serde_json::from_str(&details).unwrap(); | ||
| 837 | assert_eq!(details["result"], "abandoned"); | ||
| 838 | assert_eq!(details["reason"], "server_restart"); | ||
| 839 | assert_eq!(details["player"][0], "10S", "the hand payload is preserved"); | ||
| 840 | |||
| 841 | let pending: i64 = conn | ||
| 842 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 843 | .unwrap(); | ||
| 844 | assert_eq!(pending, 0, "the sweep must clear the reservation"); | ||
| 845 | } | ||
| 846 | |||
| 847 | #[test] | ||
| 848 | fn an_insufficient_bet_leaves_no_reservation() { | ||
| 849 | let (mut core, path) = setup_blackjack("pending-broke", &["10S", "9D", "6H", "8C"]); | ||
| 850 | let mut state = ConnectionState::default(); | ||
| 851 | register(&mut core, &mut state, "alice"); | ||
| 852 | login(&mut core, &mut state, "alice"); | ||
| 853 | |||
| 854 | let conn = Connection::open(&path).unwrap(); | ||
| 855 | conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", []) | ||
| 856 | .unwrap(); | ||
| 857 | drop(conn); | ||
| 858 | |||
| 859 | assert_eq!( | ||
| 860 | core.handle_message(&mut state, "PLAY_START blackjack 10"), | ||
| 861 | "ERROR Insufficient balance" | ||
| 862 | ); | ||
| 863 | assert_eq!(core.session_count(), 0); | ||
| 864 | assert_eq!(core.balance("alice"), Some(5)); | ||
| 865 | |||
| 866 | let conn = Connection::open(&path).unwrap(); | ||
| 867 | let pending: i64 = conn | ||
| 868 | .query_row("SELECT COUNT(*) FROM pending_plays", [], |row| row.get(0)) | ||
| 869 | .unwrap(); | ||
| 870 | assert_eq!(pending, 0); | ||
| 871 | } | ||
