diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 18:22:40 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 18:22:40 +0100 |
| commit | b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff (patch) | |
| tree | d12e3c7f6c23ae1698199d16ee763551edfd8c12 /src/server/games/blackjack.rs | |
| parent | v2.1.2: ignore ANSI when measuring board height (diff) | |
| download | zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.tar.gz zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.zip | |
v2.2.0: add blackjack with reserved bets and hit/stand/double
Diffstat (limited to '')
| -rw-r--r-- | src/server/games/blackjack.rs | 274 |
1 files changed, 274 insertions, 0 deletions
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 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | ||
| 2 | use crate::blackjack::{ | ||
| 3 | card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, | ||
| 4 | }; | ||
| 5 | use rand::seq::SliceRandom; | ||
| 6 | use rand::thread_rng; | ||
| 7 | use serde::{Deserialize, Serialize}; | ||
| 8 | use std::collections::HashMap; | ||
| 9 | |||
| 10 | pub struct BlackjackGame { | ||
| 11 | fixed_deck: Option<Vec<String>>, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl BlackjackGame { | ||
| 15 | pub fn new() -> Self { | ||
| 16 | Self { fixed_deck: None } | ||
| 17 | } | ||
| 18 | |||
| 19 | pub fn with_deck(deck: Vec<String>) -> Self { | ||
| 20 | Self { | ||
| 21 | fixed_deck: Some(deck), | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | fn shoe(&self) -> Vec<String> { | ||
| 26 | if let Some(deck) = &self.fixed_deck { | ||
| 27 | return deck.clone(); | ||
| 28 | } | ||
| 29 | let mut shoe = fresh_shoe(); | ||
| 30 | shoe.shuffle(&mut thread_rng()); | ||
| 31 | shoe | ||
| 32 | } | ||
| 33 | |||
| 34 | pub fn deal(&self, bet: u64) -> BlackjackHand { | ||
| 35 | let mut hand = BlackjackHand::new(self.shoe(), bet); | ||
| 36 | if is_blackjack(&hand.player) || is_blackjack(&hand.dealer) { | ||
| 37 | hand.settle(); | ||
| 38 | } | ||
| 39 | hand | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 44 | pub struct BlackjackHand { | ||
| 45 | pub shoe: Vec<String>, | ||
| 46 | pub player: Vec<String>, | ||
| 47 | pub dealer: Vec<String>, | ||
| 48 | pub phase: String, | ||
| 49 | pub base_bet: u64, | ||
| 50 | pub bet: u64, | ||
| 51 | pub result: String, | ||
| 52 | pub winnings: u64, | ||
| 53 | } | ||
| 54 | |||
| 55 | impl BlackjackHand { | ||
| 56 | fn new(shoe: Vec<String>, bet: u64) -> Self { | ||
| 57 | let mut hand = Self { | ||
| 58 | shoe, | ||
| 59 | player: Vec::new(), | ||
| 60 | dealer: Vec::new(), | ||
| 61 | phase: "player".to_string(), | ||
| 62 | base_bet: bet, | ||
| 63 | bet, | ||
| 64 | result: "playing".to_string(), | ||
| 65 | winnings: 0, | ||
| 66 | }; | ||
| 67 | |||
| 68 | let card = hand.draw(); | ||
| 69 | hand.player.push(card); | ||
| 70 | let card = hand.draw(); | ||
| 71 | hand.dealer.push(card); | ||
| 72 | let card = hand.draw(); | ||
| 73 | hand.player.push(card); | ||
| 74 | let card = hand.draw(); | ||
| 75 | hand.dealer.push(card); | ||
| 76 | |||
| 77 | hand | ||
| 78 | } | ||
| 79 | |||
| 80 | fn draw(&mut self) -> String { | ||
| 81 | self.shoe.remove(0) | ||
| 82 | } | ||
| 83 | |||
| 84 | pub fn player_total(&self) -> u8 { | ||
| 85 | hand_total(&self.player) | ||
| 86 | } | ||
| 87 | |||
| 88 | pub fn dealer_total(&self) -> u8 { | ||
| 89 | hand_total(&self.dealer) | ||
| 90 | } | ||
| 91 | |||
| 92 | pub fn is_done(&self) -> bool { | ||
| 93 | self.phase == "done" | ||
| 94 | } | ||
| 95 | |||
| 96 | pub fn hit(&mut self) -> Result<(), String> { | ||
| 97 | if self.is_done() { | ||
| 98 | return Err("Hand is over".to_string()); | ||
| 99 | } | ||
| 100 | let card = self.draw(); | ||
| 101 | self.player.push(card); | ||
| 102 | let total = self.player_total(); | ||
| 103 | if total > 21 { | ||
| 104 | self.settle(); | ||
| 105 | } else if total == 21 { | ||
| 106 | self.dealer_play(); | ||
| 107 | self.settle(); | ||
| 108 | } | ||
| 109 | Ok(()) | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn stand(&mut self) { | ||
| 113 | if self.is_done() { | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | self.dealer_play(); | ||
| 117 | self.settle(); | ||
| 118 | } | ||
| 119 | |||
| 120 | /// Caller must debit the extra `base_bet` before calling this. | ||
| 121 | pub fn double(&mut self) -> Result<(), String> { | ||
| 122 | if self.is_done() { | ||
| 123 | return Err("Hand is over".to_string()); | ||
| 124 | } | ||
| 125 | if self.player.len() != 2 { | ||
| 126 | return Err("Double is only allowed on the first two cards".to_string()); | ||
| 127 | } | ||
| 128 | self.bet = self.bet.saturating_add(self.base_bet); | ||
| 129 | let card = self.draw(); | ||
| 130 | self.player.push(card); | ||
| 131 | self.dealer_play(); | ||
| 132 | self.settle(); | ||
| 133 | Ok(()) | ||
| 134 | } | ||
| 135 | |||
| 136 | pub fn cancel(&mut self) { | ||
| 137 | self.phase = "done".to_string(); | ||
| 138 | self.result = "cancelled".to_string(); | ||
| 139 | self.winnings = 0; | ||
| 140 | } | ||
| 141 | |||
| 142 | fn dealer_play(&mut self) { | ||
| 143 | while self.dealer_total() < 17 { | ||
| 144 | let card = self.draw(); | ||
| 145 | self.dealer.push(card); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | fn settle(&mut self) { | ||
| 150 | if self.is_done() { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | self.phase = "done".to_string(); | ||
| 154 | |||
| 155 | let player = self.player_total(); | ||
| 156 | let dealer = self.dealer_total(); | ||
| 157 | let player_blackjack = is_blackjack(&self.player); | ||
| 158 | let dealer_blackjack = is_blackjack(&self.dealer); | ||
| 159 | |||
| 160 | self.result = if player > 21 { | ||
| 161 | "bust".to_string() | ||
| 162 | } else if player_blackjack && !dealer_blackjack { | ||
| 163 | "blackjack".to_string() | ||
| 164 | } else if dealer_blackjack && !player_blackjack { | ||
| 165 | "lose".to_string() | ||
| 166 | } else if dealer > 21 { | ||
| 167 | "win".to_string() | ||
| 168 | } else if player == dealer { | ||
| 169 | "push".to_string() | ||
| 170 | } else if player > dealer { | ||
| 171 | "win".to_string() | ||
| 172 | } else { | ||
| 173 | "lose".to_string() | ||
| 174 | }; | ||
| 175 | |||
| 176 | self.winnings = match self.result.as_str() { | ||
| 177 | "push" => self.bet, | ||
| 178 | "blackjack" => self.bet + (self.bet * 3) / 2, | ||
| 179 | "win" => self.bet * 2, | ||
| 180 | _ => 0, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 184 | pub fn state(&self, session_id: &str, balance: i64) -> BlackjackState { | ||
| 185 | let player_total = self.player_total(); | ||
| 186 | let (dealer, dealer_total, actions) = if self.is_done() { | ||
| 187 | (self.dealer.clone(), self.dealer_total(), Vec::new()) | ||
| 188 | } else { | ||
| 189 | let visible = self | ||
| 190 | .dealer | ||
| 191 | .first() | ||
| 192 | .map(|card| card_value(card)) | ||
| 193 | .unwrap_or(0); | ||
| 194 | let mut actions = vec!["hit".to_string(), "stand".to_string()]; | ||
| 195 | if self.player.len() == 2 { | ||
| 196 | actions.push("double".to_string()); | ||
| 197 | } | ||
| 198 | ( | ||
| 199 | vec![ | ||
| 200 | self.dealer.first().cloned().unwrap_or_default(), | ||
| 201 | "?".to_string(), | ||
| 202 | ], | ||
| 203 | visible, | ||
| 204 | actions, | ||
| 205 | ) | ||
| 206 | }; | ||
| 207 | |||
| 208 | BlackjackState { | ||
| 209 | session_id: session_id.to_string(), | ||
| 210 | phase: self.phase.clone(), | ||
| 211 | player: self.player.clone(), | ||
| 212 | dealer, | ||
| 213 | player_total, | ||
| 214 | dealer_total, | ||
| 215 | bet: self.bet, | ||
| 216 | base_bet: self.base_bet, | ||
| 217 | balance, | ||
| 218 | actions, | ||
| 219 | result: self.result.clone(), | ||
| 220 | winnings: self.winnings, | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | pub fn to_json(&self) -> String { | ||
| 225 | serde_json::to_string(self).unwrap_or_default() | ||
| 226 | } | ||
| 227 | |||
| 228 | pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> { | ||
| 229 | serde_json::from_str(raw) | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | impl Game for BlackjackGame { | ||
| 234 | fn game_type(&self) -> &'static str { | ||
| 235 | "blackjack" | ||
| 236 | } | ||
| 237 | |||
| 238 | fn get_name(&self) -> &'static str { | ||
| 239 | "Blackjack" | ||
| 240 | } | ||
| 241 | |||
| 242 | fn get_description(&self) -> &'static str { | ||
| 243 | "Blackjack: hit, stand or double. Dealer stands on all 17s." | ||
| 244 | } | ||
| 245 | |||
| 246 | fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { | ||
| 247 | let mut data = HashMap::new(); | ||
| 248 | data.insert("status".to_string(), "ready".to_string()); | ||
| 249 | GameState { | ||
| 250 | session_id: String::new(), | ||
| 251 | game_type: "blackjack".to_string(), | ||
| 252 | bet_amount, | ||
| 253 | phase: GamePhase::WaitingToStart, | ||
| 254 | data, | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | fn process_action( | ||
| 259 | &self, | ||
| 260 | _state: &GameState, | ||
| 261 | _action: &str, | ||
| 262 | _params: &[&str], | ||
| 263 | ) -> Result<GameState, GameError> { | ||
| 264 | Err(GameError::InvalidAction( | ||
| 265 | "Blackjack uses BLACKJACK_ACTION".to_string(), | ||
| 266 | )) | ||
| 267 | } | ||
| 268 | |||
| 269 | fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> { | ||
| 270 | Err(GameError::InternalError( | ||
| 271 | "Blackjack settles through BLACKJACK_ACTION".to_string(), | ||
| 272 | )) | ||
| 273 | } | ||
| 274 | } | ||
