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 --- 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 + 6 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 src/server/games/blackjack.rs (limited to 'src/server') 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 { -- cgit v1.2.3