From b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 18:22:40 +0100 Subject: v2.2.0: add blackjack with reserved bets and hit/stand/double --- Cargo.toml | 2 +- docs/GAMES.md | 42 +++++-- docs/TESTING.md | 13 ++ src/blackjack.rs | 90 ++++++++++++++ src/client/core.rs | 154 +++++++++++++++++++++++- src/client/games/blackjack.rs | 148 +++++++++++++++++++++++ src/client/games/manager.rs | 49 ++++++++ src/client/games/mod.rs | 1 + src/client/games/response.rs | 22 ++++ src/lib.rs | 1 + src/server/core.rs | 187 +++++++++++++++++++++++++++- src/server/database.rs | 81 +++++++++++++ src/server/games/blackjack.rs | 274 ++++++++++++++++++++++++++++++++++++++++++ src/server/games/mod.rs | 1 + src/server/games/registry.rs | 2 + src/server/games/session.rs | 1 + tests/blackjack.rs | 139 +++++++++++++++++++++ tests/client_core.rs | 201 ++++++++++++++++++++++++++++++- tests/server_core.rs | 159 ++++++++++++++++++++++++ 19 files changed, 1549 insertions(+), 18 deletions(-) create mode 100644 src/blackjack.rs create mode 100644 src/client/games/blackjack.rs create mode 100644 src/server/games/blackjack.rs create mode 100644 tests/blackjack.rs diff --git a/Cargo.toml b/Cargo.toml index 1725705..04e866b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "2.1.2" +version = "2.2.0" edition = "2024" [dependencies] diff --git a/docs/GAMES.md b/docs/GAMES.md index 12b0e7c..4c42369 100644 --- a/docs/GAMES.md +++ b/docs/GAMES.md @@ -6,17 +6,36 @@ | --- | --- | --- | | Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | | Roulette | Done | European wheel, split bets, 97.3% RTP | +| Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double | ## Planned | Game | Shape | Main open decisions | | --- | --- | --- | -| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence | | Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | -Blackjack needs server-side hand state and player actions. Crash needs a timed -round loop and a cashout action, so it should come after blackjack unless we -redesign the loop first. +Crash needs a timed round loop and a cashout action, so it should be its own +protocol/loop change. + +## Blackjack + +- Six-deck shoe, shuffled fresh for every hand and discarded afterwards. +- 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. +- A natural or dealer blackjack settles immediately. + +Client controls: + +``` +H hit +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) +``` ## Roulette @@ -68,6 +87,9 @@ plays ( // roulette {"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"} + +// blackjack +{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20} ``` The balance update and the `plays` insert happen in one transaction @@ -89,12 +111,14 @@ PLAY_START roulette ROULETTE_RESULT ``` -Blackjack and crash still need a generic state/action exchange, for example: +Blackjack is a multi-step session: ``` -GAME_START -GAME_ACTION [json] -GAME_STATE # cards, bets, multiplier, legal actions, result +PLAY_START blackjack +BLACKJACK_STATE +BLACKJACK_ACTION hit|stand|double|cancel +BLACKJACK_STATE ``` -That will be its own protocol change. +`BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, +result and winnings. The dealer's hole card is `?` until the hand is done. diff --git a/docs/TESTING.md b/docs/TESTING.md index d944b42..ed2f746 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -13,6 +13,7 @@ shared global database. Each test owns its state. | --- | --- | | `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | +| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | | `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | @@ -79,6 +80,18 @@ Use an 80-column terminal because several output lines are width-sensitive. 6. The result board shows the winning number/color/parity, the payout, and the new balance. The next spin reuses the placed bets. +## Manual blackjack checklist + +1. `play blackjack` shows the ready board; Space deals. +2. The dealer's hole card is hidden while it is the player's turn. H/S/D work; + 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. +5. Check the audit row: `game_type = blackjack`, the total bet (including a + double), winnings, and the final cards/result in `details`. + ## Manual server checks - Register, log out, log in again (bcrypt path). diff --git a/src/blackjack.rs b/src/blackjack.rs new file mode 100644 index 0000000..ac0060a --- /dev/null +++ b/src/blackjack.rs @@ -0,0 +1,90 @@ +//! Shared blackjack rules and wire state. + +use serde::{Deserialize, Serialize}; + +pub const DECKS: usize = 6; +pub const SUITS: [char; 4] = ['C', 'D', 'H', 'S']; +pub const RANKS: [&str; 13] = [ + "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", +]; + +/// A fresh, unshuffled six-deck shoe. +pub fn fresh_shoe() -> Vec { + let mut shoe = Vec::with_capacity(DECKS * 52); + for _ in 0..DECKS { + for suit in SUITS { + for rank in RANKS { + shoe.push(format!("{rank}{suit}")); + } + } + } + shoe +} + +pub fn card_value(card: &str) -> u8 { + let rank = &card[..card.len().saturating_sub(1)]; + match rank { + "A" => 11, + "10" | "J" | "Q" | "K" => 10, + _ => rank.parse().unwrap_or(0), + } +} + +/// Best total: aces count as 11 until that would bust the hand. +pub fn hand_total(cards: &[String]) -> u8 { + let mut total = 0u16; + let mut aces = 0u16; + for card in cards { + if card_value(card) == 11 { + aces += 1; + total += 1; + } else { + total += card_value(card) as u16; + } + } + for _ in 0..aces { + if total + 10 <= 21 { + total += 10; + } + } + total.min(255) as u8 +} + +pub fn is_blackjack(cards: &[String]) -> bool { + cards.len() == 2 && hand_total(cards) == 21 +} + +pub fn is_red_suit(card: &str) -> bool { + matches!(card.chars().last(), Some('D') | Some('H')) +} + +pub fn card_label(card: &str) -> String { + let Some(suit) = card.chars().last() else { + return card.to_string(); + }; + let rank = &card[..card.len() - 1]; + let suit = match suit { + 'C' => '♣', + 'D' => '♦', + 'H' => '♥', + 'S' => '♠', + other => other, + }; + format!("{rank}{suit}") +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlackjackState { + pub session_id: String, + pub phase: String, + pub player: Vec, + pub dealer: Vec, + pub player_total: u8, + pub dealer_total: u8, + pub bet: u64, + pub base_bet: u64, + pub balance: i64, + pub actions: Vec, + pub result: String, + pub winnings: u64, +} diff --git a/src/client/core.rs b/src/client/core.rs index e005f2a..6e9c28b 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -1,5 +1,7 @@ use crate::client::games::manager::GameManager; -use crate::client::games::response::{parse_roulette_response, parse_spin_response}; +use crate::client::games::response::{ + parse_blackjack_response, parse_roulette_response, parse_spin_response, +}; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, record_history, @@ -61,6 +63,9 @@ pub enum Effect { enum Pending { Spin, RouletteSpin, + BlackjackStart, + BlackjackAction, + BlackjackCancel, Ping, Echo, Info, @@ -191,6 +196,32 @@ impl ClientCore { self.board_effects() } }, + Some(Pending::BlackjackStart) | Some(Pending::BlackjackAction) => { + match parse_blackjack_response(reply) { + Ok(state) => { + self.balance = Some(state.balance); + self.game.apply_blackjack_state(state); + self.board_effects() + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + } + } + Some(Pending::BlackjackCancel) => match parse_blackjack_response(reply) { + Ok(_) => { + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -225,14 +256,16 @@ impl ClientCore { } fn handle_game_key(&mut self, key: Key) -> Vec { - if self + let game_type = self .game .get_current_state() - .map(|state| state.game_type.as_str()) - == Some("roulette") - { + .map(|state| state.game_type.as_str()); + if game_type == Some("roulette") { return self.handle_roulette_key(key); } + if game_type == Some("blackjack") { + return self.handle_blackjack_key(key); + } match key { Key::CtrlC => vec![Effect::Quit], @@ -411,6 +444,117 @@ impl ClientCore { .unwrap_or_default() } + fn handle_blackjack_key(&mut self, key: Key) -> Vec { + let status = self + .game + .get_current_state() + .and_then(|state| state.data.get("status")) + .cloned() + .unwrap_or_else(|| "ready".to_string()); + let hand = self.game.blackjack_state(); + + match key { + 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); + vec![Effect::SendLine(format!( + "BLACKJACK_ACTION {} cancel", + hand.session_id + ))] + } else { + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + } + Key::Char(' ') => self.start_blackjack(), + Key::Char('h') + if hand.as_ref().is_some_and(|hand| hand.phase == "player") => + { + self.blackjack_action("hit") + } + Key::Char('s') + if hand.as_ref().is_some_and(|hand| hand.phase == "player") => + { + self.blackjack_action("stand") + } + Key::Char('d') + if hand.as_ref().is_some_and(|hand| { + hand.phase == "player" + && hand.actions.iter().any(|action| action == "double") + }) => + { + self.blackjack_action("double") + } + Key::Char('+') | Key::Char('=') + if matches!(status.as_str(), "ready" | "done") => + { + let _ = self.game.adjust_bet(10); + self.board_effects() + } + Key::Char('-') if matches!(status.as_str(), "ready" | "done") => { + let _ = self.game.adjust_bet(-10); + self.board_effects() + } + _ => Vec::new(), + } + } + + fn start_blackjack(&mut self) -> Vec { + let status = self + .game + .get_current_state() + .and_then(|state| state.data.get("status")) + .cloned() + .unwrap_or_else(|| "ready".to_string()); + if !matches!(status.as_str(), "ready" | "done") { + return Vec::new(); + } + let Some(request) = self.game.spin_request() else { + return Vec::new(); + }; + if let Some(balance) = self.balance { + let bet = self + .game + .get_current_state() + .map(|state| state.bet_amount) + .unwrap_or(0); + if bet as i64 > balance { + self.game.set_error("Insufficient balance"); + return self.board_effects(); + } + } + + self.game.set_blackjack_status("dealing"); + self.pending = Some(Pending::BlackjackStart); + let mut effects = self.board_effects(); + effects.push(Effect::SendLine(request)); + effects + } + + fn blackjack_action(&mut self, action: &str) -> Vec { + let Some(hand) = self.game.blackjack_state() else { + return Vec::new(); + }; + if hand.phase != "player" { + return Vec::new(); + } + if action == "double" + && !hand.actions.iter().any(|available| available == "double") + { + return Vec::new(); + } + self.pending = Some(Pending::BlackjackAction); + vec![Effect::SendLine(format!( + "BLACKJACK_ACTION {} {}", + hand.session_id, action + ))] + } + fn submit(&mut self) -> Vec { let line = self.input.trim().to_string(); self.input.clear(); diff --git a/src/client/games/blackjack.rs b/src/client/games/blackjack.rs new file mode 100644 index 0000000..a34eb63 --- /dev/null +++ b/src/client/games/blackjack.rs @@ -0,0 +1,148 @@ +use super::traits::{Game, GameState}; +use crate::blackjack::{card_label, BlackjackState}; + +pub const BLACKJACK_BOARD_LINES: usize = 6; + +pub struct BlackjackGame; + +impl BlackjackGame { + pub fn new() -> Self { + Self + } + + fn state(state: &GameState) -> Option { + state + .data + .get("blackjack") + .and_then(|raw| serde_json::from_str(raw).ok()) + } + + fn cards(cards: &[String]) -> String { + cards + .iter() + .map(|card| { + if card == "?" { + "[ ? ]".to_string() + } else { + format!("[{}]", card_label(card)) + } + }) + .collect::>() + .join(" ") + } + + fn result_text(result: &str, winnings: u64) -> String { + match result { + "blackjack" => format!("BLACKJACK! Won {winnings} PLN"), + "win" => format!("Win! Won {winnings} PLN"), + "push" => "Push. Bet returned.".to_string(), + "lose" => "Dealer wins.".to_string(), + "bust" => "Bust.".to_string(), + "cancelled" => "Cancelled.".to_string(), + _ => "Hand complete.".to_string(), + } + } +} + +impl Game for BlackjackGame { + fn get_name(&self) -> &'static str { + "Blackjack" + } + + fn render(&self, state: &GameState) -> String { + let status = state + .data + .get("status") + .map(String::as_str) + .unwrap_or("ready"); + let balance = state + .data + .get("balance") + .map(String::as_str) + .unwrap_or("?"); + let hand = Self::state(state); + + if let Some(error) = state.data.get("error") { + let bet = hand.as_ref().map(|hand| hand.bet).unwrap_or(state.bet_amount); + return format!( + "=== BLACKJACK ===\r\nBalance: {balance} PLN Bet: {bet} PLN\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit" + ); + } + + let (dealer, dealer_total, player, player_total, bet, text, controls) = + if let Some(hand) = &hand { + let bet = if status == "player" { + hand.bet + } else { + state.bet_amount + }; + let text = match status { + "player" => { + if hand.actions.iter().any(|action| action == "double") { + "Hit, stand or double.".to_string() + } else { + "Hit or stand.".to_string() + } + } + "dealing" => "Dealing...".to_string(), + "cancelling" => "Cancelling...".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() + } else { + "[H]it [S]tand [Q]uit".to_string() + } + } else { + "[SPACE] New hand [+/-] Bet [Q] Quit".to_string() + }; + ( + Self::cards(&hand.dealer), + hand.dealer_total, + Self::cards(&hand.player), + hand.player_total, + bet, + text, + controls, + ) + } else { + ( + "—".to_string(), + 0, + "—".to_string(), + 0, + state.bet_amount, + "Press [SPACE] to deal.".to_string(), + "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(), + ) + }; + + format!( + "=== BLACKJACK ===\r\nBalance: {} PLN Bet: {} PLN\r\nDealer: {} {}\r\nYou: {} {}\r\n{}\r\n{}", + balance, bet, dealer, dealer_total, player, player_total, text, controls + ) + } + + fn render_lines(&self, _state: &GameState) -> usize { + BLACKJACK_BOARD_LINES + } + + fn get_help(&self) -> String { + "BLACKJACK\n\ + =========\n\ + Six-deck shoe, fresh for every hand.\n\ + Dealer stands on all 17s.\n\ + Blackjack pays 3:2.\n\ + Double is allowed on the first two cards.\n\ + \n\ + H - hit\n\ + S - stand\n\ + D - double (when available)\n\ + SPACE - deal a new hand\n\ + +/- - change the next hand's bet\n\ + Q - leave the table" + .to_string() + } +} diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 02e9451..4e0218b 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -1,7 +1,9 @@ +use super::blackjack::BlackjackGame; use super::response::{RouletteResult, SpinResult}; use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; +use crate::blackjack::BlackjackState; use crate::roulette::Bets; use std::collections::HashMap; @@ -15,6 +17,7 @@ impl GameManager { let mut games: HashMap> = HashMap::new(); games.insert("slots".to_string(), Box::new(SlotsGame::new())); games.insert("roulette".to_string(), Box::new(RouletteGame::new())); + games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); Self { current_session: None, @@ -51,6 +54,9 @@ impl GameManager { data.insert("status".to_string(), "place".to_string()); data.insert("number_input".to_string(), String::new()); 0 + } else if game_type == "blackjack" { + data.insert("status".to_string(), "ready".to_string()); + 10 } else { 10 }; @@ -260,6 +266,49 @@ impl GameManager { .insert("number_input".to_string(), String::new()); } + pub fn apply_blackjack_state(&mut self, state: BlackjackState) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = if state.phase == "player" { + GamePhase::Playing + } else { + GamePhase::Finished + }; + session.bet_amount = state.base_bet; + session.data.remove("error"); + session + .data + .insert("balance".to_string(), state.balance.to_string()); + session.data.insert( + "status".to_string(), + if state.phase == "player" { + "player".to_string() + } else { + "done".to_string() + }, + ); + session.data.insert( + "blackjack".to_string(), + serde_json::to_string(&state).unwrap_or_default(), + ); + } + + pub fn blackjack_state(&self) -> Option { + self.current_session + .as_ref() + .and_then(|session| session.data.get("blackjack")) + .and_then(|raw| serde_json::from_str(raw).ok()) + } + + pub fn set_blackjack_status(&mut self, status: &str) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("status".to_string(), status.to_string()); + } + } + pub fn get_current_game(&self) -> Option<&dyn Game> { self.current_session .as_ref() diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index 131ffbb..40ba32a 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs @@ -1,3 +1,4 @@ +pub mod blackjack; pub mod manager; pub mod response; pub mod roulette; diff --git a/src/client/games/response.rs b/src/client/games/response.rs index 73080dd..d9883f7 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs @@ -141,3 +141,25 @@ pub fn parse_roulette_response(response: &str) -> Result bets, }) } + +pub fn parse_blackjack_response( + response: &str, +) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let raw = response + .strip_prefix("BLACKJACK_STATE ") + .ok_or_else(|| format!("Unexpected server response: {response}"))?; + serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) +} diff --git a/src/lib.rs b/src/lib.rs index 84628e5..74af6e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! handling, storage, and view logic live here so they can be tested from //! `tests/` without a network or a terminal. +pub mod blackjack; pub mod client; pub mod protocol; pub mod roulette; diff --git a/src/server/core.rs b/src/server/core.rs index 2ce8b2b..cf8c4b9 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -1,10 +1,13 @@ use bcrypt::{hash, verify, DEFAULT_COST}; use rusqlite::Connection; +use std::collections::HashMap; +use crate::blackjack::BlackjackState; use crate::server::database; +use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; use crate::server::games::registry::GameRegistry; use crate::server::games::session::SessionManager; -use crate::server::games::traits::GameError; +use crate::server::games::traits::{GameError, GamePhase, GameState}; #[derive(Debug, Default)] pub struct ConnectionState { @@ -22,6 +25,7 @@ pub struct ServerCore { sessions: SessionManager, registry: GameRegistry, bcrypt_cost: u32, + blackjack: BlackjackGame, } impl ServerCore { @@ -39,9 +43,16 @@ impl ServerCore { sessions: SessionManager::new(), registry, bcrypt_cost, + blackjack: BlackjackGame::new(), } } + /// Test/utility hook: use a fixed shoe for the next hand(s). + pub fn with_blackjack_deck(mut self, deck: Vec) -> Self { + self.blackjack = BlackjackGame::with_deck(deck); + self + } + pub fn open(path: &str) -> rusqlite::Result { let conn = Connection::open(path)?; database::init_db(&conn); @@ -85,13 +96,23 @@ impl ServerCore { } } "PLAY_START" => { - if parts.len() != 3 && parts.len() != 4 { + if parts.len() == 3 && parts[1] == "blackjack" { + self.handle_blackjack_start(state, parts[2]) + } else if parts.len() != 3 && parts.len() != 4 { "ERROR Invalid PLAY_START format. Usage: PLAY_START [payload]" .to_string() } else { self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) } } + "BLACKJACK_ACTION" => { + if parts.len() != 3 { + "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION " + .to_string() + } else { + self.handle_blackjack_action(state, parts[1], parts[2]) + } + } "REGISTER" => { if parts.len() != 3 { "ERROR Invalid REGISTER format".to_string() @@ -182,6 +203,158 @@ impl ServerCore { } } + fn handle_blackjack_start(&mut self, state: &ConnectionState, bet_str: &str) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + if self + .sessions + .get_user_sessions(user) + .iter() + .any(|session| session.game_type == "blackjack") + { + return "ERROR Blackjack hand already in progress".to_string(); + } + let bet: i64 = match bet_str.parse() { + Ok(bet) => bet, + Err(_) => return "ERROR Invalid bet amount".to_string(), + }; + if bet <= 0 { + return "ERROR Invalid bet amount".to_string(); + } + let Some(balance) = database::get_balance(&self.conn, user) else { + return "ERROR Could not retrieve balance".to_string(); + }; + if balance < bet { + return "ERROR Insufficient balance".to_string(); + } + let balance = match database::debit(&mut self.conn, user, bet) { + Ok(balance) => balance, + Err(_) => return "ERROR Insufficient balance".to_string(), + }; + + let hand = self.blackjack.deal(bet as u64); + let session_id = self.create_blackjack_session(user, &hand); + self.blackjack_response(user, &session_id, hand, balance) + } + + fn create_blackjack_session(&mut self, user: &str, hand: &BlackjackHand) -> String { + let mut data = HashMap::new(); + data.insert("hand".to_string(), hand.to_json()); + let game_state = GameState { + session_id: String::new(), + game_type: "blackjack".to_string(), + bet_amount: hand.bet, + phase: GamePhase::Playing, + data, + }; + self.sessions + .create_session(user, "blackjack".to_string(), game_state) + } + + fn handle_blackjack_action( + &mut self, + state: &ConnectionState, + session_id: &str, + action: &str, + ) -> String { + let Some(user) = state.user.as_deref() else { + return "ERROR Not logged in".to_string(); + }; + let Ok(session) = self.sessions.get_session(session_id) else { + return "ERROR Session not found".to_string(); + }; + if session.username != user || session.game_type != "blackjack" { + return "ERROR Invalid session".to_string(); + } + let Some(raw) = session.state.data.get("hand") else { + return "ERROR Missing hand".to_string(); + }; + let Ok(mut hand) = BlackjackHand::from_json(raw) else { + return "ERROR Invalid hand".to_string(); + }; + if hand.is_done() { + return "ERROR Hand is over".to_string(); + } + + let balance = database::get_balance(&self.conn, user).unwrap_or(0); + match action { + "hit" => { + if let Err(error) = hand.hit() { + return format!("ERROR {error}"); + } + } + "stand" => hand.stand(), + "double" => { + if hand.player.len() != 2 { + return "ERROR Double is only allowed on the first two cards".to_string(); + } + if database::debit(&mut self.conn, user, hand.base_bet as i64).is_err() { + return "ERROR Insufficient balance".to_string(); + } + if let Err(error) = hand.double() { + let _ = database::credit(&mut self.conn, user, hand.base_bet as i64); + return format!("ERROR {error}"); + } + } + "cancel" => { + let refund = session.bet_amount as i64; + let balance = database::credit(&mut self.conn, user, refund).unwrap_or(balance); + let _ = self.sessions.remove_session(session_id); + hand.cancel(); + let state = hand.state(session_id, balance); + return blackjack_wire(&state); + } + _ => return "ERROR Unknown action".to_string(), + } + + let balance = database::get_balance(&self.conn, user).unwrap_or(balance); + self.blackjack_response(user, session_id, hand, balance) + } + + fn blackjack_response( + &mut self, + user: &str, + session_id: &str, + hand: BlackjackHand, + balance: i64, + ) -> String { + if hand.is_done() { + 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, + hand.winnings as i64, + &details, + ) { + Ok(balance) => balance, + Err(error) => { + eprintln!("Blackjack 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); + let state = hand.state(session_id, balance); + blackjack_wire(&state) + } else { + if let Ok(mut session) = self.sessions.get_session(session_id) { + session.state.bet_amount = hand.bet; + session + .state + .data + .insert("hand".to_string(), hand.to_json()); + let _ = self.sessions.update_session(session_id, session.state); + } + let state = hand.state(session_id, balance); + blackjack_wire(&state) + } + } + fn handle_play_start( &mut self, state: &ConnectionState, @@ -311,6 +484,9 @@ impl ServerCore { pub fn disconnect(&mut self, state: &ConnectionState) { 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); + } let _ = self.sessions.remove_session(&session.session_id); } } @@ -339,3 +515,10 @@ fn game_error(error: GameError) -> String { GameError::InternalError(message) => format!("ERROR {message}"), } } + +fn blackjack_wire(state: &BlackjackState) -> String { + format!( + "BLACKJACK_STATE {}", + serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) + ) +} diff --git a/src/server/database.rs b/src/server/database.rs index 2a1fc40..99e0dde 100644 --- a/src/server/database.rs +++ b/src/server/database.rs @@ -177,3 +177,84 @@ pub fn count_plays(conn: &Connection, username: &str) -> i64 { ) .unwrap_or(0) } + +/// Reserve a bet before a multi-step hand starts. +pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result { + let tx = conn.transaction()?; + let updated = tx.execute( + "UPDATE users SET balance = balance - ?1 + WHERE username = ?2 AND balance >= ?1", + params![amount, username], + )?; + if updated != 1 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + let balance = tx.query_row( + "SELECT balance FROM users WHERE username = ?1", + [username], + |row| row.get(0), + )?; + tx.commit()?; + Ok(balance) +} + +/// Put a reserved bet back (disconnect or cancel). +pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result { + let tx = conn.transaction()?; + tx.execute( + "UPDATE users SET balance = balance + ?1 WHERE username = ?2", + params![amount, username], + )?; + let balance = tx.query_row( + "SELECT balance FROM users WHERE username = ?1", + [username], + |row| row.get(0), + )?; + tx.commit()?; + Ok(balance) +} + +/// Settle a hand whose bet was already reserved: credit winnings, write the +/// audit row, and return the new balance. +#[allow(clippy::too_many_arguments)] +pub fn settle_play( + conn: &mut Connection, + session_id: &str, + username: &str, + game_type: &str, + bet_amount: i64, + winnings: i64, + details: &str, +) -> rusqlite::Result { + let tx = conn.transaction()?; + + tx.execute( + "UPDATE users SET balance = balance + ?1 WHERE username = ?2", + params![winnings, username], + )?; + + let balance_after: i64 = tx.query_row( + "SELECT balance FROM users WHERE username = ?1", + [username], + |row| row.get(0), + )?; + + tx.execute( + "INSERT INTO plays + (session_id, username, game_type, bet_amount, winnings, + balance_after, details) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + session_id, + username, + game_type, + bet_amount, + winnings, + balance_after, + details + ], + )?; + + tx.commit()?; + Ok(balance_after) +} diff --git a/src/server/games/blackjack.rs b/src/server/games/blackjack.rs new file mode 100644 index 0000000..9505fed --- /dev/null +++ b/src/server/games/blackjack.rs @@ -0,0 +1,274 @@ +use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; +use crate::blackjack::{ + card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, +}; +use rand::seq::SliceRandom; +use rand::thread_rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub struct BlackjackGame { + fixed_deck: Option>, +} + +impl BlackjackGame { + pub fn new() -> Self { + Self { fixed_deck: None } + } + + pub fn with_deck(deck: Vec) -> Self { + Self { + fixed_deck: Some(deck), + } + } + + fn shoe(&self) -> Vec { + if let Some(deck) = &self.fixed_deck { + return deck.clone(); + } + let mut shoe = fresh_shoe(); + shoe.shuffle(&mut thread_rng()); + shoe + } + + pub fn deal(&self, bet: u64) -> BlackjackHand { + let mut hand = BlackjackHand::new(self.shoe(), bet); + if is_blackjack(&hand.player) || is_blackjack(&hand.dealer) { + hand.settle(); + } + hand + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlackjackHand { + pub shoe: Vec, + pub player: Vec, + pub dealer: Vec, + pub phase: String, + pub base_bet: u64, + pub bet: u64, + pub result: String, + pub winnings: u64, +} + +impl BlackjackHand { + fn new(shoe: Vec, bet: u64) -> Self { + let mut hand = Self { + shoe, + player: Vec::new(), + dealer: Vec::new(), + phase: "player".to_string(), + base_bet: bet, + bet, + result: "playing".to_string(), + winnings: 0, + }; + + let card = hand.draw(); + hand.player.push(card); + let card = hand.draw(); + hand.dealer.push(card); + let card = hand.draw(); + hand.player.push(card); + let card = hand.draw(); + hand.dealer.push(card); + + hand + } + + fn draw(&mut self) -> String { + self.shoe.remove(0) + } + + pub fn player_total(&self) -> u8 { + hand_total(&self.player) + } + + pub fn dealer_total(&self) -> u8 { + hand_total(&self.dealer) + } + + pub fn is_done(&self) -> bool { + self.phase == "done" + } + + pub fn hit(&mut self) -> Result<(), String> { + if self.is_done() { + return Err("Hand is over".to_string()); + } + let card = self.draw(); + self.player.push(card); + let total = self.player_total(); + if total > 21 { + self.settle(); + } else if total == 21 { + self.dealer_play(); + self.settle(); + } + Ok(()) + } + + pub fn stand(&mut self) { + if self.is_done() { + return; + } + self.dealer_play(); + self.settle(); + } + + /// Caller must debit the extra `base_bet` before calling this. + pub fn double(&mut self) -> Result<(), String> { + if self.is_done() { + return Err("Hand is over".to_string()); + } + if self.player.len() != 2 { + return Err("Double is only allowed on the first two cards".to_string()); + } + self.bet = self.bet.saturating_add(self.base_bet); + let card = self.draw(); + self.player.push(card); + self.dealer_play(); + self.settle(); + Ok(()) + } + + pub fn cancel(&mut self) { + self.phase = "done".to_string(); + self.result = "cancelled".to_string(); + self.winnings = 0; + } + + fn dealer_play(&mut self) { + while self.dealer_total() < 17 { + let card = self.draw(); + self.dealer.push(card); + } + } + + fn settle(&mut self) { + if self.is_done() { + return; + } + self.phase = "done".to_string(); + + let player = self.player_total(); + let dealer = self.dealer_total(); + let player_blackjack = is_blackjack(&self.player); + let dealer_blackjack = is_blackjack(&self.dealer); + + self.result = if player > 21 { + "bust".to_string() + } else if player_blackjack && !dealer_blackjack { + "blackjack".to_string() + } else if dealer_blackjack && !player_blackjack { + "lose".to_string() + } else if dealer > 21 { + "win".to_string() + } else if player == dealer { + "push".to_string() + } else if player > dealer { + "win".to_string() + } else { + "lose".to_string() + }; + + self.winnings = match self.result.as_str() { + "push" => self.bet, + "blackjack" => self.bet + (self.bet * 3) / 2, + "win" => self.bet * 2, + _ => 0, + }; + } + + pub fn state(&self, session_id: &str, balance: i64) -> BlackjackState { + let player_total = self.player_total(); + let (dealer, dealer_total, actions) = if self.is_done() { + (self.dealer.clone(), self.dealer_total(), Vec::new()) + } else { + let visible = self + .dealer + .first() + .map(|card| card_value(card)) + .unwrap_or(0); + let mut actions = vec!["hit".to_string(), "stand".to_string()]; + if self.player.len() == 2 { + actions.push("double".to_string()); + } + ( + vec![ + self.dealer.first().cloned().unwrap_or_default(), + "?".to_string(), + ], + visible, + actions, + ) + }; + + BlackjackState { + session_id: session_id.to_string(), + phase: self.phase.clone(), + player: self.player.clone(), + dealer, + player_total, + dealer_total, + bet: self.bet, + base_bet: self.base_bet, + balance, + actions, + result: self.result.clone(), + winnings: self.winnings, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + pub fn from_json(raw: &str) -> Result { + serde_json::from_str(raw) + } +} + +impl Game for BlackjackGame { + fn game_type(&self) -> &'static str { + "blackjack" + } + + fn get_name(&self) -> &'static str { + "Blackjack" + } + + fn get_description(&self) -> &'static str { + "Blackjack: hit, stand or double. Dealer stands on all 17s." + } + + fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { + let mut data = HashMap::new(); + data.insert("status".to_string(), "ready".to_string()); + GameState { + session_id: String::new(), + game_type: "blackjack".to_string(), + bet_amount, + phase: GamePhase::WaitingToStart, + data, + } + } + + fn process_action( + &self, + _state: &GameState, + _action: &str, + _params: &[&str], + ) -> Result { + Err(GameError::InvalidAction( + "Blackjack uses BLACKJACK_ACTION".to_string(), + )) + } + + fn calculate_result(&self, _state: &GameState) -> Result { + Err(GameError::InternalError( + "Blackjack settles through BLACKJACK_ACTION".to_string(), + )) + } +} diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs index 35728a1..07c5772 100644 --- a/src/server/games/mod.rs +++ b/src/server/games/mod.rs @@ -1,3 +1,4 @@ +pub mod blackjack; pub mod registry; pub mod roulette; pub mod session; diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs index 51d446b..7ff8fbf 100644 --- a/src/server/games/registry.rs +++ b/src/server/games/registry.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use super::blackjack::BlackjackGame; use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::Game; @@ -23,6 +24,7 @@ impl GameRegistry { let registry = Self::new(); registry.register_game(SlotsGame::new()); registry.register_game(RouletteGame::new()); + registry.register_game(BlackjackGame::new()); registry } diff --git a/src/server/games/session.rs b/src/server/games/session.rs index a8410a9..916a45d 100644 --- a/src/server/games/session.rs +++ b/src/server/games/session.rs @@ -51,6 +51,7 @@ impl SessionManager { pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> { let mut sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.get_mut(session_id) { + session.bet_amount = new_state.bet_amount; session.state = new_state; Ok(()) } else { diff --git a/tests/blackjack.rs b/tests/blackjack.rs new file mode 100644 index 0000000..79721d3 --- /dev/null +++ b/tests/blackjack.rs @@ -0,0 +1,139 @@ +use zagan_bet::blackjack::{ + card_label, card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, +}; +use zagan_bet::server::games::blackjack::BlackjackGame; + +fn hand(deck: &[&str], bet: u64) -> zagan_bet::server::games::blackjack::BlackjackHand { + let deck = deck.iter().map(|card| card.to_string()).collect(); + BlackjackGame::with_deck(deck).deal(bet) +} + +#[test] +fn card_values_and_soft_totals_are_correct() { + assert_eq!(card_value("AS"), 11); + assert_eq!(card_value("10H"), 10); + assert_eq!(card_value("KD"), 10); + assert_eq!(card_label("AS"), "A♠"); + assert_eq!(card_label("10H"), "10♥"); + + let cards = |values: &[&str]| values.iter().map(|card| card.to_string()).collect::>(); + assert_eq!(hand_total(&cards(&["AS", "10H"])), 21); + assert_eq!(hand_total(&cards(&["AS", "9H"])), 20); + assert_eq!(hand_total(&cards(&["AS", "AS", "9H"])), 21); + assert!(is_blackjack(&cards(&["AS", "KH"]))); + assert!(!is_blackjack(&cards(&["AS", "9H"]))); +} + +#[test] +fn a_six_deck_shoe_has_312_cards() { + let shoe = fresh_shoe(); + assert_eq!(shoe.len(), 312); + assert_eq!(shoe.iter().filter(|card| card.as_str() == "AS").count(), 6); +} + +#[test] +fn a_natural_blackjack_settles_immediately() { + let hand = hand(&["AS", "9D", "KH", "8C"], 10); + + assert!(hand.is_done()); + assert_eq!(hand.result, "blackjack"); + assert_eq!(hand.winnings, 25); // 10 + 15 + assert_eq!(hand.player_total(), 21); +} + +#[test] +fn a_player_hand_can_hit_to_21() { + let mut hand = hand(&["10S", "9D", "6H", "8C", "5D"], 10); + assert_eq!(hand.player_total(), 16); + assert_eq!(hand.dealer_total(), 17); + + hand.hit().unwrap(); + + assert!(hand.is_done()); + assert_eq!(hand.player_total(), 21); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn hitting_over_21_busts() { + let mut hand = hand(&["10S", "9D", "6H", "8C", "KD"], 10); + hand.hit().unwrap(); + + assert!(hand.is_done()); + assert_eq!(hand.result, "bust"); + assert_eq!(hand.winnings, 0); +} + +#[test] +fn doubling_adds_the_base_bet_and_draws_one_card() { + let mut hand = hand(&["5S", "9D", "5H", "8C", "10D"], 10); + assert_eq!(hand.player_total(), 10); + + hand.double().unwrap(); + + assert_eq!(hand.player.len(), 3); + assert_eq!(hand.bet, 20); + assert_eq!(hand.player_total(), 20); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 40); +} + +#[test] +fn the_dealer_busts_and_the_player_wins() { + let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10); + assert_eq!(hand.player_total(), 18); + assert_eq!(hand.dealer_total(), 16); + + hand.stand(); + + assert_eq!(hand.dealer_total(), 26); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn the_dealer_stands_on_soft_17() { + let mut hand = hand(&["10S", "AD", "8H", "6C"], 10); + assert_eq!(hand.dealer_total(), 17); + + hand.stand(); + + assert_eq!(hand.dealer.len(), 2, "dealer must stand on soft 17"); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn equal_totals_push() { + let mut hand = hand(&["10S", "10D", "8H", "8C"], 10); + hand.stand(); + + assert_eq!(hand.result, "push"); + assert_eq!(hand.winnings, 10); +} + +#[test] +fn dealer_blackjack_wins_unless_player_also_has_it() { + let dealer_blackjack = hand(&["10S", "AS", "8H", "KD"], 10); + assert_eq!(dealer_blackjack.result, "lose"); + assert_eq!(dealer_blackjack.winnings, 0); + + let both = hand(&["AS", "AD", "KH", "KC"], 10); + assert_eq!(both.result, "push"); + assert_eq!(both.winnings, 10); +} + +#[test] +fn the_player_state_hides_the_hole_card() { + let hand = hand(&["10S", "9D", "6H", "8C"], 10); + let state: BlackjackState = hand.state("session-1", 990); + + assert_eq!(state.phase, "player"); + assert_eq!(state.dealer, vec!["9D".to_string(), "?".to_string()]); + assert_eq!(state.dealer_total, 9); + assert_eq!(state.player_total, 16); + assert_eq!(state.actions, vec!["hit", "stand", "double"]); + assert_eq!(state.bet, 10); + assert_eq!(state.balance, 990); +} diff --git a/tests/client_core.rs b/tests/client_core.rs index 7fb7e29..cfcf608 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -4,8 +4,9 @@ //! logical `Effect`s that `ClientCore` emits, so assertions are about what a //! player would see, not about ANSI sequences. +use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; -use zagan_bet::client::games::response::parse_spin_response; +use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; use zagan_bet::client::ui::command_names; const WIN3: &str = "CCCCLLCLODGSDGS"; @@ -139,6 +140,11 @@ impl Harness { self.line("play roulette"); self.reply("INFO alice 1 1000"); } + + fn enter_blackjack(&mut self) { + self.line("play blackjack"); + self.reply("INFO alice 1 1000"); + } } #[test] @@ -647,3 +653,196 @@ fn roulette_rejects_an_out_of_range_number() { assert!(h.board()[4].contains("ERROR: Number must be 0-36")); assert!(h.board()[2].contains("Bets: (none)")); } + +fn bj_state( + session: &str, + phase: &str, + player: &[&str], + dealer: &[&str], + player_total: u8, + dealer_total: u8, + bet: u64, + base_bet: u64, + balance: i64, + actions: &[&str], + result: &str, + winnings: u64, +) -> BlackjackState { + BlackjackState { + session_id: session.to_string(), + phase: phase.to_string(), + player: player.iter().map(|card| card.to_string()).collect(), + dealer: dealer.iter().map(|card| card.to_string()).collect(), + player_total, + dealer_total, + bet, + base_bet, + balance, + actions: actions.iter().map(|action| action.to_string()).collect(), + result: result.to_string(), + winnings, + } +} + +#[test] +fn blackjack_deals_hits_and_settles() { + let mut h = Harness::new(); + h.enter_blackjack(); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[4].contains("Press [SPACE] to deal.")); + + h.key(Key::Char(' ')); + assert!(h + .sent + .iter() + .any(|line| line == "PLAY_START blackjack 10")); + + let player = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand", "double"], + "playing", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&player).unwrap() + )); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[2].contains("Dealer: [9♦] [ ? ] 9")); + 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")); + + h.key(Key::Char('h')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("BLACKJACK_ACTION bj1 hit") + ); + + let done = bj_state( + "bj1", + "done", + &["10S", "6H", "5D"], + &["9D", "8C"], + 21, + 17, + 10, + 10, + 1010, + &[], + "win", + 20, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&done).unwrap() + )); + + assert!(h.board()[4].contains("Win! Won 20 PLN")); + assert!(h.board()[5].contains("[SPACE] New hand")); + + h.key(Key::Char('q')); + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); +} + +#[test] +fn blackjack_cancel_sends_a_cancel_action() { + let mut h = Harness::new(); + h.enter_blackjack(); + h.key(Key::Char(' ')); + + let player = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand", "double"], + "playing", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&player).unwrap() + )); + + h.key(Key::Char('q')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("BLACKJACK_ACTION bj1 cancel") + ); + + let cancelled = bj_state( + "bj1", + "done", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 1000, + &[], + "cancelled", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&cancelled).unwrap() + )); + + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); +} + +#[test] +fn blackjack_ready_board_can_change_the_next_bet() { + let mut h = Harness::new(); + h.enter_blackjack(); + + h.key(Key::Char('=')); + assert!(h.board()[1].contains("Bet: 20 PLN")); + + h.key(Key::Char(' ')); + assert!(h + .sent + .iter() + .any(|line| line == "PLAY_START blackjack 20")); +} + +#[test] +fn parses_a_blackjack_state() { + let state = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand"], + "playing", + 0, + ); + let response = format!("BLACKJACK_STATE {}", serde_json::to_string(&state).unwrap()); + + assert_eq!(parse_blackjack_response(&response).unwrap(), state); + assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); +} diff --git a/tests/server_core.rs b/tests/server_core.rs index cf05102..3ad277a 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use rusqlite::Connection; +use zagan_bet::blackjack::BlackjackState; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; @@ -47,6 +48,31 @@ fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) { (ServerCore::with_registry(conn, registry, 4), path) } +fn setup_blackjack(name: &str, deck: &[&str]) -> (ServerCore, String) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let deck = deck.iter().map(|card| card.to_string()).collect(); + ( + ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) + .with_blackjack_deck(deck), + path, + ) +} + +fn blackjack_state(response: &str) -> BlackjackState { + let raw = response + .strip_prefix("BLACKJACK_STATE ") + .expect("blackjack state response"); + serde_json::from_str(raw).expect("valid blackjack state") +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -337,3 +363,136 @@ fn unknown_games_are_rejected_before_a_session_is_created() { ); assert_eq!(core.session_count(), 0); } + +#[test] +fn blackjack_reserves_the_bet_and_settles_a_hit_hand() { + let (mut core, path) = setup_blackjack( + "blackjack-hit", + &["10S", "9D", "6H", "8C", "5D"], + ); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let response = core.handle_message(&mut state, "PLAY_START blackjack 10"); + let current = blackjack_state(&response); + + assert_eq!(current.phase, "player"); + assert_eq!(current.player_total, 16); + assert_eq!(current.dealer, vec!["9D".to_string(), "?".to_string()]); + assert_eq!(current.balance, 990); + assert_eq!(current.actions, vec!["hit", "stand", "double"]); + assert_eq!(core.session_count(), 1); + + let response = core.handle_message( + &mut state, + &format!("BLACKJACK_ACTION {} hit", current.session_id), + ); + let done = blackjack_state(&response); + + assert_eq!(done.phase, "done"); + assert_eq!(done.result, "win"); + assert_eq!(done.winnings, 20); + assert_eq!(done.balance, 1010); + assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); + + let conn = Connection::open(&path).unwrap(); + let (game_type, details): (String, String) = conn + .query_row( + "SELECT game_type, details FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(game_type, "blackjack"); + let details: BlackjackState = serde_json::from_str(&details).unwrap(); + assert_eq!(details.result, "win"); + assert_eq!(details.winnings, 20); +} + +#[test] +fn blackjack_double_reserves_an_extra_bet() { + let (mut core, _path) = setup_blackjack( + "blackjack-double", + &["5S", "9D", "5H", "8C", "10D"], + ); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + assert_eq!(current.balance, 990); + + let done = blackjack_state(&core.handle_message( + &mut state, + &format!("BLACKJACK_ACTION {} double", current.session_id), + )); + + assert_eq!(done.phase, "done"); + assert_eq!(done.bet, 20); + assert_eq!(done.winnings, 40); + assert_eq!(done.balance, 1020); + assert_eq!(core.balance("alice"), Some(1020)); + assert_eq!(core.play_count("alice"), 1); +} + +#[test] +fn blackjack_natural_settles_immediately() { + let (mut core, _path) = + setup_blackjack("blackjack-natural", &["AS", "9D", "KH", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let done = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + + assert_eq!(done.phase, "done"); + assert_eq!(done.result, "blackjack"); + assert_eq!(done.winnings, 25); + assert_eq!(done.balance, 1015); + assert_eq!(core.session_count(), 0); + assert_eq!(core.play_count("alice"), 1); +} + +#[test] +fn blackjack_cancel_refunds_the_reserved_bet() { + let (mut core, _path) = + setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + 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( + &mut state, + &format!("BLACKJACK_ACTION {} cancel", 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!(core.session_count(), 0); +} + +#[test] +fn blackjack_disconnect_refunds_the_reserved_bet() { + let (mut core, _path) = + setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + assert_eq!(current.balance, 990); + + core.disconnect(&state); + + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.session_count(), 0); +} -- cgit v1.2.3