summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 18:43:09 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 18:43:09 +0100
commit08269639606c0481037cbde7ebaf2e0455460883 (patch)
tree57c1f967e8836a1a7da84b1887d932e0067d538f
parentv2.2.0: add blackjack with reserved bets and hit/stand/double (diff)
downloadzagan-bet-08269639606c0481037cbde7ebaf2e0455460883.tar.gz
zagan-bet-08269639606c0481037cbde7ebaf2e0455460883.zip
v2.2.1: forfeit blackjack bets when leaving or disconnecting
-rw-r--r--AGENTS.md3
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md6
-rw-r--r--docs/TESTING.md7
-rw-r--r--src/client/core.rs18
-rw-r--r--src/client/games/blackjack.rs9
-rw-r--r--src/server/core.rs50
-rw-r--r--src/server/games/blackjack.rs4
-rw-r--r--tests/blackjack.rs11
-rw-r--r--tests/client_core.rs19
-rw-r--r--tests/server_core.rs20
11 files changed, 107 insertions, 42 deletions
diff --git a/AGENTS.md b/AGENTS.md
index f7f0d2b..9bee619 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,8 @@
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- Clean up sessions on client disconnect. If a game is in progress when the 23- Clean up sessions on client disconnect. If a game is in progress when the
24 connection drops, the server must roll back the bet. 24 connection drops, settle it as a forfeit (the reserved bet is lost) and
25 record the play; never refund an active hand.
25 26
26## Server Handler 27## Server Handler
27- Never hold a lock, drop it, then re-acquire it in the same function to 28- Never hold a lock, drop it, then re-acquire it in the same function to
diff --git a/Cargo.toml b/Cargo.toml
index 04e866b..d65b9ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.2.0" 3version = "2.2.1"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/GAMES.md b/docs/GAMES.md
index 4c42369..ae15d50 100644
--- a/docs/GAMES.md
+++ b/docs/GAMES.md
@@ -23,7 +23,9 @@ protocol/loop change.
23- Dealer stands on all 17s (including soft 17). 23- Dealer stands on all 17s (including soft 17).
24- Blackjack pays 3:2, rounded down to whole PLN. 24- Blackjack pays 3:2, rounded down to whole PLN.
25- Hit, stand, and double; no split, insurance, or surrender. 25- Hit, stand, and double; no split, insurance, or surrender.
26- The bet is reserved when the hand starts. Cancel or disconnect refunds it. 26- The bet is reserved when the hand starts. Leaving an active hand with Q, or
27 disconnecting mid-hand, forfeits the reserved bet; it is not refunded. A
28 settled hand is unaffected.
27- A natural or dealer blackjack settles immediately. 29- A natural or dealer blackjack settles immediately.
28 30
29Client controls: 31Client controls:
@@ -34,7 +36,7 @@ S stand
34D double (on the first two cards) 36D double (on the first two cards)
35SPACE deal the next hand 37SPACE deal the next hand
36+ or = / - change the next hand's bet 38+ or = / - change the next hand's bet
37Q leave the table (active hand is cancelled and refunded) 39Q forfeit an active hand (bet is lost), or leave when no hand is active
38``` 40```
39 41
40## Roulette 42## Roulette
diff --git a/docs/TESTING.md b/docs/TESTING.md
index ed2f746..ec5c681 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -87,10 +87,11 @@ Use an 80-column terminal because several output lines are width-sensitive.
87 D is only offered on the first two cards and reserves the extra bet. 87 D is only offered on the first two cards and reserves the extra bet.
883. Stand and check the dealer stands on all 17s. Check a dealer bust pays the 883. Stand and check the dealer stands on all 17s. Check a dealer bust pays the
89 player, and a natural blackjack pays 3:2. 89 player, and a natural blackjack pays 3:2.
904. Q during a hand sends cancel and refunds the reserved bet; Q after a hand 904. Q during a hand forfeits the reserved bet (it is **not** refunded); Q after
91 just leaves. Disconnecting mid-hand also refunds it. 91 a hand just leaves. Disconnecting mid-hand also forfeits it.
925. Check the audit row: `game_type = blackjack`, the total bet (including a 925. Check the audit row: `game_type = blackjack`, the total bet (including a
93 double), winnings, and the final cards/result in `details`. 93 double), winnings, and the final cards/result in `details`. A forfeit has
94 `result = forfeit`, `winnings = 0`.
94 95
95## Manual server checks 96## Manual server checks
96 97
diff --git a/src/client/core.rs b/src/client/core.rs
index 6e9c28b..77761b4 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -65,7 +65,7 @@ enum Pending {
65 RouletteSpin, 65 RouletteSpin,
66 BlackjackStart, 66 BlackjackStart,
67 BlackjackAction, 67 BlackjackAction,
68 BlackjackCancel, 68 BlackjackForfeit,
69 Ping, 69 Ping,
70 Echo, 70 Echo,
71 Info, 71 Info,
@@ -209,12 +209,16 @@ impl ClientCore {
209 } 209 }
210 } 210 }
211 } 211 }
212 Some(Pending::BlackjackCancel) => match parse_blackjack_response(reply) { 212 Some(Pending::BlackjackForfeit) => match parse_blackjack_response(reply) {
213 Ok(_) => { 213 Ok(state) => {
214 self.game.end_game(); 214 self.game.end_game();
215 let message = format!(
216 "Game exited. Active hand forfeited ({} PLN lost).",
217 state.bet
218 );
215 vec![ 219 vec![
216 Effect::ClearRegion, 220 Effect::ClearRegion,
217 Effect::Print(vec!["Game exited.".to_string()]), 221 Effect::Print(vec![message]),
218 ] 222 ]
219 } 223 }
220 Err(message) => { 224 Err(message) => {
@@ -457,10 +461,10 @@ impl ClientCore {
457 Key::CtrlC => vec![Effect::Quit], 461 Key::CtrlC => vec![Effect::Quit],
458 Key::Char('q') => { 462 Key::Char('q') => {
459 if let Some(hand) = hand.filter(|hand| hand.phase == "player") { 463 if let Some(hand) = hand.filter(|hand| hand.phase == "player") {
460 self.game.set_blackjack_status("cancelling"); 464 self.game.set_blackjack_status("forfeiting");
461 self.pending = Some(Pending::BlackjackCancel); 465 self.pending = Some(Pending::BlackjackForfeit);
462 vec![Effect::SendLine(format!( 466 vec![Effect::SendLine(format!(
463 "BLACKJACK_ACTION {} cancel", 467 "BLACKJACK_ACTION {} forfeit",
464 hand.session_id 468 hand.session_id
465 ))] 469 ))]
466 } else { 470 } else {
diff --git a/src/client/games/blackjack.rs b/src/client/games/blackjack.rs
index a34eb63..bad1781 100644
--- a/src/client/games/blackjack.rs
+++ b/src/client/games/blackjack.rs
@@ -38,6 +38,7 @@ impl BlackjackGame {
38 "push" => "Push. Bet returned.".to_string(), 38 "push" => "Push. Bet returned.".to_string(),
39 "lose" => "Dealer wins.".to_string(), 39 "lose" => "Dealer wins.".to_string(),
40 "bust" => "Bust.".to_string(), 40 "bust" => "Bust.".to_string(),
41 "forfeit" => "Forfeited. Bet lost.".to_string(),
41 "cancelled" => "Cancelled.".to_string(), 42 "cancelled" => "Cancelled.".to_string(),
42 _ => "Hand complete.".to_string(), 43 _ => "Hand complete.".to_string(),
43 } 44 }
@@ -85,15 +86,15 @@ impl Game for BlackjackGame {
85 } 86 }
86 } 87 }
87 "dealing" => "Dealing...".to_string(), 88 "dealing" => "Dealing...".to_string(),
88 "cancelling" => "Cancelling...".to_string(), 89 "forfeiting" => "Forfeiting...".to_string(),
89 "done" => Self::result_text(&hand.result, hand.winnings), 90 "done" => Self::result_text(&hand.result, hand.winnings),
90 _ => "Press [SPACE] to deal.".to_string(), 91 _ => "Press [SPACE] to deal.".to_string(),
91 }; 92 };
92 let controls = if status == "player" { 93 let controls = if status == "player" {
93 if hand.actions.iter().any(|action| action == "double") { 94 if hand.actions.iter().any(|action| action == "double") {
94 "[H]it [S]tand [D]ouble [Q]uit".to_string() 95 "[H]it [S]tand [D]ouble [Q] Forfeit".to_string()
95 } else { 96 } else {
96 "[H]it [S]tand [Q]uit".to_string() 97 "[H]it [S]tand [Q] Forfeit".to_string()
97 } 98 }
98 } else { 99 } else {
99 "[SPACE] New hand [+/-] Bet [Q] Quit".to_string() 100 "[SPACE] New hand [+/-] Bet [Q] Quit".to_string()
@@ -142,7 +143,7 @@ impl Game for BlackjackGame {
142 D - double (when available)\n\ 143 D - double (when available)\n\
143 SPACE - deal a new hand\n\ 144 SPACE - deal a new hand\n\
144 +/- - change the next hand's bet\n\ 145 +/- - change the next hand's bet\n\
145 Q - leave the table" 146 Q - forfeit an active hand (bet is lost), or leave when no hand is active"
146 .to_string() 147 .to_string()
147 } 148 }
148} 149}
diff --git a/src/server/core.rs b/src/server/core.rs
index cf8c4b9..259f936 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -297,11 +297,27 @@ impl ServerCore {
297 return format!("ERROR {error}"); 297 return format!("ERROR {error}");
298 } 298 }
299 } 299 }
300 "cancel" => { 300 "forfeit" | "cancel" => {
301 let refund = session.bet_amount as i64; 301 hand.forfeit();
302 let balance = database::credit(&mut self.conn, user, refund).unwrap_or(balance); 302 let state = hand.state(session_id, balance);
303 let details = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string());
304 let balance = match database::settle_play(
305 &mut self.conn,
306 session_id,
307 user,
308 "blackjack",
309 hand.bet as i64,
310 0,
311 &details,
312 ) {
313 Ok(balance) => balance,
314 Err(error) => {
315 eprintln!("Blackjack forfeit settlement failed for {user}: {error}");
316 let _ = self.sessions.remove_session(session_id);
317 return "ERROR Failed to update balance".to_string();
318 }
319 };
303 let _ = self.sessions.remove_session(session_id); 320 let _ = self.sessions.remove_session(session_id);
304 hand.cancel();
305 let state = hand.state(session_id, balance); 321 let state = hand.state(session_id, balance);
306 return blackjack_wire(&state); 322 return blackjack_wire(&state);
307 } 323 }
@@ -485,7 +501,31 @@ impl ServerCore {
485 if let Some(user) = state.user.as_deref() { 501 if let Some(user) = state.user.as_deref() {
486 for session in self.sessions.get_user_sessions(user) { 502 for session in self.sessions.get_user_sessions(user) {
487 if session.game_type == "blackjack" { 503 if session.game_type == "blackjack" {
488 let _ = database::credit(&mut self.conn, user, session.bet_amount as i64); 504 if let Some(raw) = session.state.data.get("hand") {
505 if let Ok(mut hand) = BlackjackHand::from_json(raw) {
506 if !hand.is_done() {
507 hand.forfeit();
508 let balance =
509 database::get_balance(&self.conn, user).unwrap_or(0);
510 let final_state = hand.state(&session.session_id, balance);
511 let details = serde_json::to_string(&final_state)
512 .unwrap_or_else(|_| "{}".to_string());
513 if let Err(error) = database::settle_play(
514 &mut self.conn,
515 &session.session_id,
516 user,
517 "blackjack",
518 hand.bet as i64,
519 0,
520 &details,
521 ) {
522 eprintln!(
523 "Blackjack disconnect forfeit failed for {user}: {error}"
524 );
525 }
526 }
527 }
528 }
489 } 529 }
490 let _ = self.sessions.remove_session(&session.session_id); 530 let _ = self.sessions.remove_session(&session.session_id);
491 } 531 }
diff --git a/src/server/games/blackjack.rs b/src/server/games/blackjack.rs
index 9505fed..df5a6ac 100644
--- a/src/server/games/blackjack.rs
+++ b/src/server/games/blackjack.rs
@@ -133,9 +133,9 @@ impl BlackjackHand {
133 Ok(()) 133 Ok(())
134 } 134 }
135 135
136 pub fn cancel(&mut self) { 136 pub fn forfeit(&mut self) {
137 self.phase = "done".to_string(); 137 self.phase = "done".to_string();
138 self.result = "cancelled".to_string(); 138 self.result = "forfeit".to_string();
139 self.winnings = 0; 139 self.winnings = 0;
140 } 140 }
141 141
diff --git a/tests/blackjack.rs b/tests/blackjack.rs
index 79721d3..f90ca8d 100644
--- a/tests/blackjack.rs
+++ b/tests/blackjack.rs
@@ -80,6 +80,17 @@ fn doubling_adds_the_base_bet_and_draws_one_card() {
80} 80}
81 81
82#[test] 82#[test]
83fn forfeiting_loses_the_bet() {
84 let mut hand = hand(&["10S", "9D", "6H", "8C"], 10);
85 hand.forfeit();
86
87 assert!(hand.is_done());
88 assert_eq!(hand.result, "forfeit");
89 assert_eq!(hand.winnings, 0);
90 assert_eq!(hand.bet, 10);
91}
92
93#[test]
83fn the_dealer_busts_and_the_player_wins() { 94fn the_dealer_busts_and_the_player_wins() {
84 let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10); 95 let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10);
85 assert_eq!(hand.player_total(), 18); 96 assert_eq!(hand.player_total(), 18);
diff --git a/tests/client_core.rs b/tests/client_core.rs
index cfcf608..ec444bd 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -722,6 +722,7 @@ fn blackjack_deals_hits_and_settles() {
722 assert!(h.board()[3].contains("You: [10♠] [6♥] 16")); 722 assert!(h.board()[3].contains("You: [10♠] [6♥] 16"));
723 assert!(h.board()[4].contains("Hit, stand or double.")); 723 assert!(h.board()[4].contains("Hit, stand or double."));
724 assert!(h.board()[5].contains("[D]ouble")); 724 assert!(h.board()[5].contains("[D]ouble"));
725 assert!(h.board()[5].contains("[Q] Forfeit"));
725 726
726 h.key(Key::Char('h')); 727 h.key(Key::Char('h'));
727 assert_eq!( 728 assert_eq!(
@@ -757,7 +758,7 @@ fn blackjack_deals_hits_and_settles() {
757} 758}
758 759
759#[test] 760#[test]
760fn blackjack_cancel_sends_a_cancel_action() { 761fn blackjack_forfeit_is_sent_when_leaving_an_active_hand() {
761 let mut h = Harness::new(); 762 let mut h = Harness::new();
762 h.enter_blackjack(); 763 h.enter_blackjack();
763 h.key(Key::Char(' ')); 764 h.key(Key::Char(' '));
@@ -784,10 +785,10 @@ fn blackjack_cancel_sends_a_cancel_action() {
784 h.key(Key::Char('q')); 785 h.key(Key::Char('q'));
785 assert_eq!( 786 assert_eq!(
786 h.sent.last().map(String::as_str), 787 h.sent.last().map(String::as_str),
787 Some("BLACKJACK_ACTION bj1 cancel") 788 Some("BLACKJACK_ACTION bj1 forfeit")
788 ); 789 );
789 790
790 let cancelled = bj_state( 791 let forfeited = bj_state(
791 "bj1", 792 "bj1",
792 "done", 793 "done",
793 &["10S", "6H"], 794 &["10S", "6H"],
@@ -796,18 +797,22 @@ fn blackjack_cancel_sends_a_cancel_action() {
796 9, 797 9,
797 10, 798 10,
798 10, 799 10,
799 1000, 800 990,
800 &[], 801 &[],
801 "cancelled", 802 "forfeit",
802 0, 803 0,
803 ); 804 );
804 h.reply(&format!( 805 h.reply(&format!(
805 "BLACKJACK_STATE {}", 806 "BLACKJACK_STATE {}",
806 serde_json::to_string(&cancelled).unwrap() 807 serde_json::to_string(&forfeited).unwrap()
807 )); 808 ));
808 809
809 assert!(h.board().is_empty()); 810 assert!(h.board().is_empty());
810 assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); 811 assert!(h
812 .screen
813 .lines
814 .iter()
815 .any(|line| line.contains("forfeited (10 PLN lost)")));
811} 816}
812 817
813#[test] 818#[test]
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 3ad277a..08a2bf2 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -457,7 +457,7 @@ fn blackjack_natural_settles_immediately() {
457} 457}
458 458
459#[test] 459#[test]
460fn blackjack_cancel_refunds_the_reserved_bet() { 460fn blackjack_forfeit_keeps_the_reserved_bet() {
461 let (mut core, _path) = 461 let (mut core, _path) =
462 setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); 462 setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]);
463 let mut state = ConnectionState::default(); 463 let mut state = ConnectionState::default();
@@ -467,20 +467,20 @@ fn blackjack_cancel_refunds_the_reserved_bet() {
467 let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); 467 let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10"));
468 assert_eq!(current.balance, 990); 468 assert_eq!(current.balance, 990);
469 469
470 let cancelled = blackjack_state(&core.handle_message( 470 let forfeited = blackjack_state(&core.handle_message(
471 &mut state, 471 &mut state,
472 &format!("BLACKJACK_ACTION {} cancel", current.session_id), 472 &format!("BLACKJACK_ACTION {} forfeit", current.session_id),
473 )); 473 ));
474 474
475 assert_eq!(cancelled.result, "cancelled"); 475 assert_eq!(forfeited.result, "forfeit");
476 assert_eq!(cancelled.balance, 1000); 476 assert_eq!(forfeited.balance, 990);
477 assert_eq!(core.balance("alice"), Some(1000)); 477 assert_eq!(core.balance("alice"), Some(990));
478 assert_eq!(core.play_count("alice"), 0); 478 assert_eq!(core.play_count("alice"), 1);
479 assert_eq!(core.session_count(), 0); 479 assert_eq!(core.session_count(), 0);
480} 480}
481 481
482#[test] 482#[test]
483fn blackjack_disconnect_refunds_the_reserved_bet() { 483fn blackjack_disconnect_forfeits_the_reserved_bet() {
484 let (mut core, _path) = 484 let (mut core, _path) =
485 setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); 485 setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]);
486 let mut state = ConnectionState::default(); 486 let mut state = ConnectionState::default();
@@ -492,7 +492,7 @@ fn blackjack_disconnect_refunds_the_reserved_bet() {
492 492
493 core.disconnect(&state); 493 core.disconnect(&state);
494 494
495 assert_eq!(core.balance("alice"), Some(1000)); 495 assert_eq!(core.balance("alice"), Some(990));
496 assert_eq!(core.play_count("alice"), 0); 496 assert_eq!(core.play_count("alice"), 1);
497 assert_eq!(core.session_count(), 0); 497 assert_eq!(core.session_count(), 0);
498} 498}