summaryrefslogtreecommitdiffstats
path: root/src/server
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 /src/server
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
Diffstat (limited to 'src/server')
-rw-r--r--src/server/core.rs52
-rw-r--r--src/server/database.rs165
2 files changed, 201 insertions, 16 deletions
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}