From 08269639606c0481037cbde7ebaf2e0455460883 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 18:43:09 +0100 Subject: v2.2.1: forfeit blackjack bets when leaving or disconnecting --- AGENTS.md | 3 ++- Cargo.toml | 2 +- docs/GAMES.md | 6 ++++-- docs/TESTING.md | 7 +++--- src/client/core.rs | 18 ++++++++++------ src/client/games/blackjack.rs | 9 ++++---- src/server/core.rs | 50 ++++++++++++++++++++++++++++++++++++++----- src/server/games/blackjack.rs | 4 ++-- tests/blackjack.rs | 11 ++++++++++ tests/client_core.rs | 19 ++++++++++------ tests/server_core.rs | 20 ++++++++--------- 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 @@ touched. The session is the audit trail for the deduction; without it the deduction is orphaned. - Clean up sessions on client disconnect. If a game is in progress when the - connection drops, the server must roll back the bet. + connection drops, settle it as a forfeit (the reserved bet is lost) and + record the play; never refund an active hand. ## Server Handler - 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 @@ [package] name = "zagan-bet" -version = "2.2.0" +version = "2.2.1" edition = "2024" [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. - Dealer stands on all 17s (including soft 17). - Blackjack pays 3:2, rounded down to whole PLN. - Hit, stand, and double; no split, insurance, or surrender. -- The bet is reserved when the hand starts. Cancel or disconnect refunds it. +- The bet is reserved when the hand starts. Leaving an active hand with Q, or + disconnecting mid-hand, forfeits the reserved bet; it is not refunded. A + settled hand is unaffected. - A natural or dealer blackjack settles immediately. Client controls: @@ -34,7 +36,7 @@ S stand D double (on the first two cards) SPACE deal the next hand + or = / - change the next hand's bet -Q leave the table (active hand is cancelled and refunded) +Q forfeit an active hand (bet is lost), or leave when no hand is active ``` ## 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. D is only offered on the first two cards and reserves the extra bet. 3. Stand and check the dealer stands on all 17s. Check a dealer bust pays the player, and a natural blackjack pays 3:2. -4. Q during a hand sends cancel and refunds the reserved bet; Q after a hand - just leaves. Disconnecting mid-hand also refunds it. +4. Q during a hand forfeits the reserved bet (it is **not** refunded); Q after + a hand just leaves. Disconnecting mid-hand also forfeits it. 5. Check the audit row: `game_type = blackjack`, the total bet (including a - double), winnings, and the final cards/result in `details`. + double), winnings, and the final cards/result in `details`. A forfeit has + `result = forfeit`, `winnings = 0`. ## Manual server checks 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 { RouletteSpin, BlackjackStart, BlackjackAction, - BlackjackCancel, + BlackjackForfeit, Ping, Echo, Info, @@ -209,12 +209,16 @@ impl ClientCore { } } } - Some(Pending::BlackjackCancel) => match parse_blackjack_response(reply) { - Ok(_) => { + Some(Pending::BlackjackForfeit) => match parse_blackjack_response(reply) { + Ok(state) => { self.game.end_game(); + let message = format!( + "Game exited. Active hand forfeited ({} PLN lost).", + state.bet + ); vec![ Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), + Effect::Print(vec![message]), ] } Err(message) => { @@ -457,10 +461,10 @@ impl ClientCore { Key::CtrlC => vec![Effect::Quit], Key::Char('q') => { if let Some(hand) = hand.filter(|hand| hand.phase == "player") { - self.game.set_blackjack_status("cancelling"); - self.pending = Some(Pending::BlackjackCancel); + self.game.set_blackjack_status("forfeiting"); + self.pending = Some(Pending::BlackjackForfeit); vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} cancel", + "BLACKJACK_ACTION {} forfeit", hand.session_id ))] } 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 { "push" => "Push. Bet returned.".to_string(), "lose" => "Dealer wins.".to_string(), "bust" => "Bust.".to_string(), + "forfeit" => "Forfeited. Bet lost.".to_string(), "cancelled" => "Cancelled.".to_string(), _ => "Hand complete.".to_string(), } @@ -85,15 +86,15 @@ impl Game for BlackjackGame { } } "dealing" => "Dealing...".to_string(), - "cancelling" => "Cancelling...".to_string(), + "forfeiting" => "Forfeiting...".to_string(), "done" => Self::result_text(&hand.result, hand.winnings), _ => "Press [SPACE] to deal.".to_string(), }; let controls = if status == "player" { if hand.actions.iter().any(|action| action == "double") { - "[H]it [S]tand [D]ouble [Q]uit".to_string() + "[H]it [S]tand [D]ouble [Q] Forfeit".to_string() } else { - "[H]it [S]tand [Q]uit".to_string() + "[H]it [S]tand [Q] Forfeit".to_string() } } else { "[SPACE] New hand [+/-] Bet [Q] Quit".to_string() @@ -142,7 +143,7 @@ impl Game for BlackjackGame { D - double (when available)\n\ SPACE - deal a new hand\n\ +/- - change the next hand's bet\n\ - Q - leave the table" + Q - forfeit an active hand (bet is lost), or leave when no hand is active" .to_string() } } 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 { return format!("ERROR {error}"); } } - "cancel" => { - let refund = session.bet_amount as i64; - let balance = database::credit(&mut self.conn, user, refund).unwrap_or(balance); + "forfeit" | "cancel" => { + hand.forfeit(); + let state = hand.state(session_id, balance); + let details = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string()); + let balance = match database::settle_play( + &mut self.conn, + session_id, + user, + "blackjack", + hand.bet as i64, + 0, + &details, + ) { + Ok(balance) => balance, + Err(error) => { + eprintln!("Blackjack forfeit settlement failed for {user}: {error}"); + let _ = self.sessions.remove_session(session_id); + return "ERROR Failed to update balance".to_string(); + } + }; let _ = self.sessions.remove_session(session_id); - hand.cancel(); let state = hand.state(session_id, balance); return blackjack_wire(&state); } @@ -485,7 +501,31 @@ impl ServerCore { if let Some(user) = state.user.as_deref() { for session in self.sessions.get_user_sessions(user) { if session.game_type == "blackjack" { - let _ = database::credit(&mut self.conn, user, session.bet_amount as i64); + if let Some(raw) = session.state.data.get("hand") { + if let Ok(mut hand) = BlackjackHand::from_json(raw) { + if !hand.is_done() { + hand.forfeit(); + let balance = + database::get_balance(&self.conn, user).unwrap_or(0); + let final_state = hand.state(&session.session_id, balance); + let details = serde_json::to_string(&final_state) + .unwrap_or_else(|_| "{}".to_string()); + if let Err(error) = database::settle_play( + &mut self.conn, + &session.session_id, + user, + "blackjack", + hand.bet as i64, + 0, + &details, + ) { + eprintln!( + "Blackjack disconnect forfeit failed for {user}: {error}" + ); + } + } + } + } } let _ = self.sessions.remove_session(&session.session_id); } 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 { Ok(()) } - pub fn cancel(&mut self) { + pub fn forfeit(&mut self) { self.phase = "done".to_string(); - self.result = "cancelled".to_string(); + self.result = "forfeit".to_string(); self.winnings = 0; } diff --git a/tests/blackjack.rs b/tests/blackjack.rs index 79721d3..f90ca8d 100644 --- a/tests/blackjack.rs +++ b/tests/blackjack.rs @@ -79,6 +79,17 @@ fn doubling_adds_the_base_bet_and_draws_one_card() { assert_eq!(hand.winnings, 40); } +#[test] +fn forfeiting_loses_the_bet() { + let mut hand = hand(&["10S", "9D", "6H", "8C"], 10); + hand.forfeit(); + + assert!(hand.is_done()); + assert_eq!(hand.result, "forfeit"); + assert_eq!(hand.winnings, 0); + assert_eq!(hand.bet, 10); +} + #[test] fn the_dealer_busts_and_the_player_wins() { let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10); 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() { assert!(h.board()[3].contains("You: [10♠] [6♥] 16")); assert!(h.board()[4].contains("Hit, stand or double.")); assert!(h.board()[5].contains("[D]ouble")); + assert!(h.board()[5].contains("[Q] Forfeit")); h.key(Key::Char('h')); assert_eq!( @@ -757,7 +758,7 @@ fn blackjack_deals_hits_and_settles() { } #[test] -fn blackjack_cancel_sends_a_cancel_action() { +fn blackjack_forfeit_is_sent_when_leaving_an_active_hand() { let mut h = Harness::new(); h.enter_blackjack(); h.key(Key::Char(' ')); @@ -784,10 +785,10 @@ fn blackjack_cancel_sends_a_cancel_action() { h.key(Key::Char('q')); assert_eq!( h.sent.last().map(String::as_str), - Some("BLACKJACK_ACTION bj1 cancel") + Some("BLACKJACK_ACTION bj1 forfeit") ); - let cancelled = bj_state( + let forfeited = bj_state( "bj1", "done", &["10S", "6H"], @@ -796,18 +797,22 @@ fn blackjack_cancel_sends_a_cancel_action() { 9, 10, 10, - 1000, + 990, &[], - "cancelled", + "forfeit", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", - serde_json::to_string(&cancelled).unwrap() + serde_json::to_string(&forfeited).unwrap() )); assert!(h.board().is_empty()); - assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("forfeited (10 PLN lost)"))); } #[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() { } #[test] -fn blackjack_cancel_refunds_the_reserved_bet() { +fn blackjack_forfeit_keeps_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); @@ -467,20 +467,20 @@ fn blackjack_cancel_refunds_the_reserved_bet() { let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); assert_eq!(current.balance, 990); - let cancelled = blackjack_state(&core.handle_message( + let forfeited = blackjack_state(&core.handle_message( &mut state, - &format!("BLACKJACK_ACTION {} cancel", current.session_id), + &format!("BLACKJACK_ACTION {} forfeit", current.session_id), )); - assert_eq!(cancelled.result, "cancelled"); - assert_eq!(cancelled.balance, 1000); - assert_eq!(core.balance("alice"), Some(1000)); - assert_eq!(core.play_count("alice"), 0); + assert_eq!(forfeited.result, "forfeit"); + assert_eq!(forfeited.balance, 990); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } #[test] -fn blackjack_disconnect_refunds_the_reserved_bet() { +fn blackjack_disconnect_forfeits_the_reserved_bet() { let (mut core, _path) = setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); let mut state = ConnectionState::default(); @@ -492,7 +492,7 @@ fn blackjack_disconnect_refunds_the_reserved_bet() { core.disconnect(&state); - assert_eq!(core.balance("alice"), Some(1000)); - assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.balance("alice"), Some(990)); + assert_eq!(core.play_count("alice"), 1); assert_eq!(core.session_count(), 0); } -- cgit v1.2.3