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/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 ++++++ 5 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 src/client/games/blackjack.rs (limited to 'src/client') 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}")) +} -- cgit v1.2.3