summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 21:36:26 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 21:36:26 +0100
commit772727b0ef7d63144bf629d774f8b6c1c996bf78 (patch)
tree1680dbbd61fce2aaf383ee81ad04ff0705ffe985
parentv2.3.0: add crash with local multiplier and pushed crash event (diff)
downloadzagan-bet-772727b0ef7d63144bf629d774f8b6c1c996bf78.tar.gz
zagan-bet-772727b0ef7d63144bf629d774f8b6c1c996bf78.zip
v2.3.1: settle orphaned reservations on server restart
-rw-r--r--AGENTS.md6
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md28
-rw-r--r--docs/TESTING.md10
-rw-r--r--src/bin/server/main.rs14
-rw-r--r--src/server/core.rs52
-rw-r--r--src/server/database.rs165
-rw-r--r--tests/server_core.rs130
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 @@
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.
diff --git a/Cargo.toml b/Cargo.toml
index 54c41df..43b696b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.3.0" 3version = "2.3.1"
4edition = "2024" 4edition = "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 (
137The balance update and the `plays` insert happen in one transaction 137The 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
142Blackjack and crash reserve the bet before the round can settle. The debit and
143a `pending_plays` row are written in one transaction
144(`database::reserve_play`); settling deletes that row and writes the `plays`
145audit row in the same transaction.
146
147```
148pending_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
158If the server dies with a round in flight, the reservation survives the process.
159The next startup calls `recover_orphaned_reservations` before the listener
160accepts connections, and each leftover is settled as a **forfeit**: the bet is
161not refunded, and the audit row gets `winnings = 0`,
162`details.result = "abandoned"`, `details.reason = "server_restart"`.
163
164The sweep runs once per process, never per connection. A per-connection sweep
165would settle another player's live hand, and only one server process may own a
166database.
167
140## Wire protocol 168## Wire protocol
141 169
142Slots keeps its original exchange: 170Slots 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.
1117. Confirm the crash point is absent from the `CRASH_START` line (it must only 1117. 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
1161. Start a blackjack hand (or a crash round) and confirm `pending_plays` has one
117 row while the round is live.
1182. `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`.
1203. 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.
25pub 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
20impl ConnectionState { 31impl 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
36pub fn add_user( 50pub 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.
182pub 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)]
201pub 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
202pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> { 233/// and the debit in step.
234pub 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.
264pub 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)]
220pub fn settle_play( 292pub 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.
345pub 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.
398fn 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]
444fn blackjack_double_reserves_an_extra_bet() { 444fn 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]
761fn 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]
792fn 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]
848fn 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}