summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/blackjack.rs90
-rw-r--r--src/client/core.rs154
-rw-r--r--src/client/games/blackjack.rs148
-rw-r--r--src/client/games/manager.rs49
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs22
-rw-r--r--src/lib.rs1
-rw-r--r--src/server/core.rs187
-rw-r--r--src/server/database.rs81
-rw-r--r--src/server/games/blackjack.rs274
-rw-r--r--src/server/games/mod.rs1
-rw-r--r--src/server/games/registry.rs2
-rw-r--r--src/server/games/session.rs1
13 files changed, 1004 insertions, 7 deletions
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 @@
1//! Shared blackjack rules and wire state.
2
3use serde::{Deserialize, Serialize};
4
5pub const DECKS: usize = 6;
6pub const SUITS: [char; 4] = ['C', 'D', 'H', 'S'];
7pub const RANKS: [&str; 13] = [
8 "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A",
9];
10
11/// A fresh, unshuffled six-deck shoe.
12pub fn fresh_shoe() -> Vec<String> {
13 let mut shoe = Vec::with_capacity(DECKS * 52);
14 for _ in 0..DECKS {
15 for suit in SUITS {
16 for rank in RANKS {
17 shoe.push(format!("{rank}{suit}"));
18 }
19 }
20 }
21 shoe
22}
23
24pub fn card_value(card: &str) -> u8 {
25 let rank = &card[..card.len().saturating_sub(1)];
26 match rank {
27 "A" => 11,
28 "10" | "J" | "Q" | "K" => 10,
29 _ => rank.parse().unwrap_or(0),
30 }
31}
32
33/// Best total: aces count as 11 until that would bust the hand.
34pub fn hand_total(cards: &[String]) -> u8 {
35 let mut total = 0u16;
36 let mut aces = 0u16;
37 for card in cards {
38 if card_value(card) == 11 {
39 aces += 1;
40 total += 1;
41 } else {
42 total += card_value(card) as u16;
43 }
44 }
45 for _ in 0..aces {
46 if total + 10 <= 21 {
47 total += 10;
48 }
49 }
50 total.min(255) as u8
51}
52
53pub fn is_blackjack(cards: &[String]) -> bool {
54 cards.len() == 2 && hand_total(cards) == 21
55}
56
57pub fn is_red_suit(card: &str) -> bool {
58 matches!(card.chars().last(), Some('D') | Some('H'))
59}
60
61pub fn card_label(card: &str) -> String {
62 let Some(suit) = card.chars().last() else {
63 return card.to_string();
64 };
65 let rank = &card[..card.len() - 1];
66 let suit = match suit {
67 'C' => '♣',
68 'D' => '♦',
69 'H' => '♥',
70 'S' => '♠',
71 other => other,
72 };
73 format!("{rank}{suit}")
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct BlackjackState {
78 pub session_id: String,
79 pub phase: String,
80 pub player: Vec<String>,
81 pub dealer: Vec<String>,
82 pub player_total: u8,
83 pub dealer_total: u8,
84 pub bet: u64,
85 pub base_bet: u64,
86 pub balance: i64,
87 pub actions: Vec<String>,
88 pub result: String,
89 pub winnings: u64,
90}
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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{parse_roulette_response, parse_spin_response}; 2use crate::client::games::response::{
3 parse_blackjack_response, parse_roulette_response, parse_spin_response,
4};
3use crate::client::ui::{ 5use crate::client::ui::{
4 command_names, completion_lines, display_width, help_lines, input_cursor_column, 6 command_names, completion_lines, display_width, help_lines, input_cursor_column,
5 parse_bet_arg, record_history, 7 parse_bet_arg, record_history,
@@ -61,6 +63,9 @@ pub enum Effect {
61enum Pending { 63enum Pending {
62 Spin, 64 Spin,
63 RouletteSpin, 65 RouletteSpin,
66 BlackjackStart,
67 BlackjackAction,
68 BlackjackCancel,
64 Ping, 69 Ping,
65 Echo, 70 Echo,
66 Info, 71 Info,
@@ -191,6 +196,32 @@ impl ClientCore {
191 self.board_effects() 196 self.board_effects()
192 } 197 }
193 }, 198 },
199 Some(Pending::BlackjackStart) | Some(Pending::BlackjackAction) => {
200 match parse_blackjack_response(reply) {
201 Ok(state) => {
202 self.balance = Some(state.balance);
203 self.game.apply_blackjack_state(state);
204 self.board_effects()
205 }
206 Err(message) => {
207 self.game.set_error(message);
208 self.board_effects()
209 }
210 }
211 }
212 Some(Pending::BlackjackCancel) => match parse_blackjack_response(reply) {
213 Ok(_) => {
214 self.game.end_game();
215 vec![
216 Effect::ClearRegion,
217 Effect::Print(vec!["Game exited.".to_string()]),
218 ]
219 }
220 Err(message) => {
221 self.game.set_error(message);
222 self.board_effects()
223 }
224 },
194 Some(Pending::Ping) => { 225 Some(Pending::Ping) => {
195 if reply.trim() == "PONG" { 226 if reply.trim() == "PONG" {
196 print_line("Pong") 227 print_line("Pong")
@@ -225,14 +256,16 @@ impl ClientCore {
225 } 256 }
226 257
227 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> { 258 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> {
228 if self 259 let game_type = self
229 .game 260 .game
230 .get_current_state() 261 .get_current_state()
231 .map(|state| state.game_type.as_str()) 262 .map(|state| state.game_type.as_str());
232 == Some("roulette") 263 if game_type == Some("roulette") {
233 {
234 return self.handle_roulette_key(key); 264 return self.handle_roulette_key(key);
235 } 265 }
266 if game_type == Some("blackjack") {
267 return self.handle_blackjack_key(key);
268 }
236 269
237 match key { 270 match key {
238 Key::CtrlC => vec![Effect::Quit], 271 Key::CtrlC => vec![Effect::Quit],
@@ -411,6 +444,117 @@ impl ClientCore {
411 .unwrap_or_default() 444 .unwrap_or_default()
412 } 445 }
413 446
447 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> {
448 let status = self
449 .game
450 .get_current_state()
451 .and_then(|state| state.data.get("status"))
452 .cloned()
453 .unwrap_or_else(|| "ready".to_string());
454 let hand = self.game.blackjack_state();
455
456 match key {
457 Key::CtrlC => vec![Effect::Quit],
458 Key::Char('q') => {
459 if let Some(hand) = hand.filter(|hand| hand.phase == "player") {
460 self.game.set_blackjack_status("cancelling");
461 self.pending = Some(Pending::BlackjackCancel);
462 vec![Effect::SendLine(format!(
463 "BLACKJACK_ACTION {} cancel",
464 hand.session_id
465 ))]
466 } else {
467 self.game.end_game();
468 vec![
469 Effect::ClearRegion,
470 Effect::Print(vec!["Game exited.".to_string()]),
471 ]
472 }
473 }
474 Key::Char(' ') => self.start_blackjack(),
475 Key::Char('h')
476 if hand.as_ref().is_some_and(|hand| hand.phase == "player") =>
477 {
478 self.blackjack_action("hit")
479 }
480 Key::Char('s')
481 if hand.as_ref().is_some_and(|hand| hand.phase == "player") =>
482 {
483 self.blackjack_action("stand")
484 }
485 Key::Char('d')
486 if hand.as_ref().is_some_and(|hand| {
487 hand.phase == "player"
488 && hand.actions.iter().any(|action| action == "double")
489 }) =>
490 {
491 self.blackjack_action("double")
492 }
493 Key::Char('+') | Key::Char('=')
494 if matches!(status.as_str(), "ready" | "done") =>
495 {
496 let _ = self.game.adjust_bet(10);
497 self.board_effects()
498 }
499 Key::Char('-') if matches!(status.as_str(), "ready" | "done") => {
500 let _ = self.game.adjust_bet(-10);
501 self.board_effects()
502 }
503 _ => Vec::new(),
504 }
505 }
506
507 fn start_blackjack(&mut self) -> Vec<Effect> {
508 let status = self
509 .game
510 .get_current_state()
511 .and_then(|state| state.data.get("status"))
512 .cloned()
513 .unwrap_or_else(|| "ready".to_string());
514 if !matches!(status.as_str(), "ready" | "done") {
515 return Vec::new();
516 }
517 let Some(request) = self.game.spin_request() else {
518 return Vec::new();
519 };
520 if let Some(balance) = self.balance {
521 let bet = self
522 .game
523 .get_current_state()
524 .map(|state| state.bet_amount)
525 .unwrap_or(0);
526 if bet as i64 > balance {
527 self.game.set_error("Insufficient balance");
528 return self.board_effects();
529 }
530 }
531
532 self.game.set_blackjack_status("dealing");
533 self.pending = Some(Pending::BlackjackStart);
534 let mut effects = self.board_effects();
535 effects.push(Effect::SendLine(request));
536 effects
537 }
538
539 fn blackjack_action(&mut self, action: &str) -> Vec<Effect> {
540 let Some(hand) = self.game.blackjack_state() else {
541 return Vec::new();
542 };
543 if hand.phase != "player" {
544 return Vec::new();
545 }
546 if action == "double"
547 && !hand.actions.iter().any(|available| available == "double")
548 {
549 return Vec::new();
550 }
551 self.pending = Some(Pending::BlackjackAction);
552 vec![Effect::SendLine(format!(
553 "BLACKJACK_ACTION {} {}",
554 hand.session_id, action
555 ))]
556 }
557
414 fn submit(&mut self) -> Vec<Effect> { 558 fn submit(&mut self) -> Vec<Effect> {
415 let line = self.input.trim().to_string(); 559 let line = self.input.trim().to_string();
416 self.input.clear(); 560 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 @@
1use super::traits::{Game, GameState};
2use crate::blackjack::{card_label, BlackjackState};
3
4pub const BLACKJACK_BOARD_LINES: usize = 6;
5
6pub struct BlackjackGame;
7
8impl BlackjackGame {
9 pub fn new() -> Self {
10 Self
11 }
12
13 fn state(state: &GameState) -> Option<BlackjackState> {
14 state
15 .data
16 .get("blackjack")
17 .and_then(|raw| serde_json::from_str(raw).ok())
18 }
19
20 fn cards(cards: &[String]) -> String {
21 cards
22 .iter()
23 .map(|card| {
24 if card == "?" {
25 "[ ? ]".to_string()
26 } else {
27 format!("[{}]", card_label(card))
28 }
29 })
30 .collect::<Vec<_>>()
31 .join(" ")
32 }
33
34 fn result_text(result: &str, winnings: u64) -> String {
35 match result {
36 "blackjack" => format!("BLACKJACK! Won {winnings} PLN"),
37 "win" => format!("Win! Won {winnings} PLN"),
38 "push" => "Push. Bet returned.".to_string(),
39 "lose" => "Dealer wins.".to_string(),
40 "bust" => "Bust.".to_string(),
41 "cancelled" => "Cancelled.".to_string(),
42 _ => "Hand complete.".to_string(),
43 }
44 }
45}
46
47impl Game for BlackjackGame {
48 fn get_name(&self) -> &'static str {
49 "Blackjack"
50 }
51
52 fn render(&self, state: &GameState) -> String {
53 let status = state
54 .data
55 .get("status")
56 .map(String::as_str)
57 .unwrap_or("ready");
58 let balance = state
59 .data
60 .get("balance")
61 .map(String::as_str)
62 .unwrap_or("?");
63 let hand = Self::state(state);
64
65 if let Some(error) = state.data.get("error") {
66 let bet = hand.as_ref().map(|hand| hand.bet).unwrap_or(state.bet_amount);
67 return format!(
68 "=== BLACKJACK ===\r\nBalance: {balance} PLN Bet: {bet} PLN\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit"
69 );
70 }
71
72 let (dealer, dealer_total, player, player_total, bet, text, controls) =
73 if let Some(hand) = &hand {
74 let bet = if status == "player" {
75 hand.bet
76 } else {
77 state.bet_amount
78 };
79 let text = match status {
80 "player" => {
81 if hand.actions.iter().any(|action| action == "double") {
82 "Hit, stand or double.".to_string()
83 } else {
84 "Hit or stand.".to_string()
85 }
86 }
87 "dealing" => "Dealing...".to_string(),
88 "cancelling" => "Cancelling...".to_string(),
89 "done" => Self::result_text(&hand.result, hand.winnings),
90 _ => "Press [SPACE] to deal.".to_string(),
91 };
92 let controls = if status == "player" {
93 if hand.actions.iter().any(|action| action == "double") {
94 "[H]it [S]tand [D]ouble [Q]uit".to_string()
95 } else {
96 "[H]it [S]tand [Q]uit".to_string()
97 }
98 } else {
99 "[SPACE] New hand [+/-] Bet [Q] Quit".to_string()
100 };
101 (
102 Self::cards(&hand.dealer),
103 hand.dealer_total,
104 Self::cards(&hand.player),
105 hand.player_total,
106 bet,
107 text,
108 controls,
109 )
110 } else {
111 (
112 "—".to_string(),
113 0,
114 "—".to_string(),
115 0,
116 state.bet_amount,
117 "Press [SPACE] to deal.".to_string(),
118 "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(),
119 )
120 };
121
122 format!(
123 "=== BLACKJACK ===\r\nBalance: {} PLN Bet: {} PLN\r\nDealer: {} {}\r\nYou: {} {}\r\n{}\r\n{}",
124 balance, bet, dealer, dealer_total, player, player_total, text, controls
125 )
126 }
127
128 fn render_lines(&self, _state: &GameState) -> usize {
129 BLACKJACK_BOARD_LINES
130 }
131
132 fn get_help(&self) -> String {
133 "BLACKJACK\n\
134 =========\n\
135 Six-deck shoe, fresh for every hand.\n\
136 Dealer stands on all 17s.\n\
137 Blackjack pays 3:2.\n\
138 Double is allowed on the first two cards.\n\
139 \n\
140 H - hit\n\
141 S - stand\n\
142 D - double (when available)\n\
143 SPACE - deal a new hand\n\
144 +/- - change the next hand's bet\n\
145 Q - leave the table"
146 .to_string()
147 }
148}
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 @@
1use super::blackjack::BlackjackGame;
1use super::response::{RouletteResult, SpinResult}; 2use super::response::{RouletteResult, SpinResult};
2use super::roulette::RouletteGame; 3use super::roulette::RouletteGame;
3use super::slots::SlotsGame; 4use super::slots::SlotsGame;
4use super::traits::{Game, GameError, GamePhase, GameState}; 5use super::traits::{Game, GameError, GamePhase, GameState};
6use crate::blackjack::BlackjackState;
5use crate::roulette::Bets; 7use crate::roulette::Bets;
6use std::collections::HashMap; 8use std::collections::HashMap;
7 9
@@ -15,6 +17,7 @@ impl GameManager {
15 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new(); 17 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new();
16 games.insert("slots".to_string(), Box::new(SlotsGame::new())); 18 games.insert("slots".to_string(), Box::new(SlotsGame::new()));
17 games.insert("roulette".to_string(), Box::new(RouletteGame::new())); 19 games.insert("roulette".to_string(), Box::new(RouletteGame::new()));
20 games.insert("blackjack".to_string(), Box::new(BlackjackGame::new()));
18 21
19 Self { 22 Self {
20 current_session: None, 23 current_session: None,
@@ -51,6 +54,9 @@ impl GameManager {
51 data.insert("status".to_string(), "place".to_string()); 54 data.insert("status".to_string(), "place".to_string());
52 data.insert("number_input".to_string(), String::new()); 55 data.insert("number_input".to_string(), String::new());
53 0 56 0
57 } else if game_type == "blackjack" {
58 data.insert("status".to_string(), "ready".to_string());
59 10
54 } else { 60 } else {
55 10 61 10
56 }; 62 };
@@ -260,6 +266,49 @@ impl GameManager {
260 .insert("number_input".to_string(), String::new()); 266 .insert("number_input".to_string(), String::new());
261 } 267 }
262 268
269 pub fn apply_blackjack_state(&mut self, state: BlackjackState) {
270 let Some(session) = self.current_session.as_mut() else {
271 return;
272 };
273 session.phase = if state.phase == "player" {
274 GamePhase::Playing
275 } else {
276 GamePhase::Finished
277 };
278 session.bet_amount = state.base_bet;
279 session.data.remove("error");
280 session
281 .data
282 .insert("balance".to_string(), state.balance.to_string());
283 session.data.insert(
284 "status".to_string(),
285 if state.phase == "player" {
286 "player".to_string()
287 } else {
288 "done".to_string()
289 },
290 );
291 session.data.insert(
292 "blackjack".to_string(),
293 serde_json::to_string(&state).unwrap_or_default(),
294 );
295 }
296
297 pub fn blackjack_state(&self) -> Option<BlackjackState> {
298 self.current_session
299 .as_ref()
300 .and_then(|session| session.data.get("blackjack"))
301 .and_then(|raw| serde_json::from_str(raw).ok())
302 }
303
304 pub fn set_blackjack_status(&mut self, status: &str) {
305 if let Some(session) = self.current_session.as_mut() {
306 session
307 .data
308 .insert("status".to_string(), status.to_string());
309 }
310 }
311
263 pub fn get_current_game(&self) -> Option<&dyn Game> { 312 pub fn get_current_game(&self) -> Option<&dyn Game> {
264 self.current_session 313 self.current_session
265 .as_ref() 314 .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 @@
1pub mod blackjack;
1pub mod manager; 2pub mod manager;
2pub mod response; 3pub mod response;
3pub mod roulette; 4pub 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<RouletteResult, String>
141 bets, 141 bets,
142 }) 142 })
143} 143}
144
145pub fn parse_blackjack_response(
146 response: &str,
147) -> Result<crate::blackjack::BlackjackState, String> {
148 let response = response.trim();
149 if response.is_empty() {
150 return Err("No response from server".to_string());
151 }
152 if let Some(message) = response.strip_prefix("ERROR") {
153 let message = message.trim();
154 return Err(if message.is_empty() {
155 "Server error".to_string()
156 } else {
157 message.to_string()
158 });
159 }
160
161 let raw = response
162 .strip_prefix("BLACKJACK_STATE ")
163 .ok_or_else(|| format!("Unexpected server response: {response}"))?;
164 serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}"))
165}
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 @@
5//! handling, storage, and view logic live here so they can be tested from 5//! handling, storage, and view logic live here so they can be tested from
6//! `tests/` without a network or a terminal. 6//! `tests/` without a network or a terminal.
7 7
8pub mod blackjack;
8pub mod client; 9pub mod client;
9pub mod protocol; 10pub mod protocol;
10pub mod roulette; 11pub 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 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{hash, verify, DEFAULT_COST};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap;
3 4
5use crate::blackjack::BlackjackState;
4use crate::server::database; 6use crate::server::database;
7use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
5use crate::server::games::registry::GameRegistry; 8use crate::server::games::registry::GameRegistry;
6use crate::server::games::session::SessionManager; 9use crate::server::games::session::SessionManager;
7use crate::server::games::traits::GameError; 10use crate::server::games::traits::{GameError, GamePhase, GameState};
8 11
9#[derive(Debug, Default)] 12#[derive(Debug, Default)]
10pub struct ConnectionState { 13pub struct ConnectionState {
@@ -22,6 +25,7 @@ pub struct ServerCore {
22 sessions: SessionManager, 25 sessions: SessionManager,
23 registry: GameRegistry, 26 registry: GameRegistry,
24 bcrypt_cost: u32, 27 bcrypt_cost: u32,
28 blackjack: BlackjackGame,
25} 29}
26 30
27impl ServerCore { 31impl ServerCore {
@@ -39,9 +43,16 @@ impl ServerCore {
39 sessions: SessionManager::new(), 43 sessions: SessionManager::new(),
40 registry, 44 registry,
41 bcrypt_cost, 45 bcrypt_cost,
46 blackjack: BlackjackGame::new(),
42 } 47 }
43 } 48 }
44 49
50 /// Test/utility hook: use a fixed shoe for the next hand(s).
51 pub fn with_blackjack_deck(mut self, deck: Vec<String>) -> Self {
52 self.blackjack = BlackjackGame::with_deck(deck);
53 self
54 }
55
45 pub fn open(path: &str) -> rusqlite::Result<Self> { 56 pub fn open(path: &str) -> rusqlite::Result<Self> {
46 let conn = Connection::open(path)?; 57 let conn = Connection::open(path)?;
47 database::init_db(&conn); 58 database::init_db(&conn);
@@ -85,13 +96,23 @@ impl ServerCore {
85 } 96 }
86 } 97 }
87 "PLAY_START" => { 98 "PLAY_START" => {
88 if parts.len() != 3 && parts.len() != 4 { 99 if parts.len() == 3 && parts[1] == "blackjack" {
100 self.handle_blackjack_start(state, parts[2])
101 } else if parts.len() != 3 && parts.len() != 4 {
89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" 102 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]"
90 .to_string() 103 .to_string()
91 } else { 104 } else {
92 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) 105 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied())
93 } 106 }
94 } 107 }
108 "BLACKJACK_ACTION" => {
109 if parts.len() != 3 {
110 "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>"
111 .to_string()
112 } else {
113 self.handle_blackjack_action(state, parts[1], parts[2])
114 }
115 }
95 "REGISTER" => { 116 "REGISTER" => {
96 if parts.len() != 3 { 117 if parts.len() != 3 {
97 "ERROR Invalid REGISTER format".to_string() 118 "ERROR Invalid REGISTER format".to_string()
@@ -182,6 +203,158 @@ impl ServerCore {
182 } 203 }
183 } 204 }
184 205
206 fn handle_blackjack_start(&mut self, state: &ConnectionState, bet_str: &str) -> String {
207 let Some(user) = state.user.as_deref() else {
208 return "ERROR Not logged in".to_string();
209 };
210 if self
211 .sessions
212 .get_user_sessions(user)
213 .iter()
214 .any(|session| session.game_type == "blackjack")
215 {
216 return "ERROR Blackjack hand already in progress".to_string();
217 }
218 let bet: i64 = match bet_str.parse() {
219 Ok(bet) => bet,
220 Err(_) => return "ERROR Invalid bet amount".to_string(),
221 };
222 if bet <= 0 {
223 return "ERROR Invalid bet amount".to_string();
224 }
225 let Some(balance) = database::get_balance(&self.conn, user) else {
226 return "ERROR Could not retrieve balance".to_string();
227 };
228 if balance < bet {
229 return "ERROR Insufficient balance".to_string();
230 }
231 let balance = match database::debit(&mut self.conn, user, bet) {
232 Ok(balance) => balance,
233 Err(_) => return "ERROR Insufficient balance".to_string(),
234 };
235
236 let hand = self.blackjack.deal(bet as u64);
237 let session_id = self.create_blackjack_session(user, &hand);
238 self.blackjack_response(user, &session_id, hand, balance)
239 }
240
241 fn create_blackjack_session(&mut self, user: &str, hand: &BlackjackHand) -> String {
242 let mut data = HashMap::new();
243 data.insert("hand".to_string(), hand.to_json());
244 let game_state = GameState {
245 session_id: String::new(),
246 game_type: "blackjack".to_string(),
247 bet_amount: hand.bet,
248 phase: GamePhase::Playing,
249 data,
250 };
251 self.sessions
252 .create_session(user, "blackjack".to_string(), game_state)
253 }
254
255 fn handle_blackjack_action(
256 &mut self,
257 state: &ConnectionState,
258 session_id: &str,
259 action: &str,
260 ) -> String {
261 let Some(user) = state.user.as_deref() else {
262 return "ERROR Not logged in".to_string();
263 };
264 let Ok(session) = self.sessions.get_session(session_id) else {
265 return "ERROR Session not found".to_string();
266 };
267 if session.username != user || session.game_type != "blackjack" {
268 return "ERROR Invalid session".to_string();
269 }
270 let Some(raw) = session.state.data.get("hand") else {
271 return "ERROR Missing hand".to_string();
272 };
273 let Ok(mut hand) = BlackjackHand::from_json(raw) else {
274 return "ERROR Invalid hand".to_string();
275 };
276 if hand.is_done() {
277 return "ERROR Hand is over".to_string();
278 }
279
280 let balance = database::get_balance(&self.conn, user).unwrap_or(0);
281 match action {
282 "hit" => {
283 if let Err(error) = hand.hit() {
284 return format!("ERROR {error}");
285 }
286 }
287 "stand" => hand.stand(),
288 "double" => {
289 if hand.player.len() != 2 {
290 return "ERROR Double is only allowed on the first two cards".to_string();
291 }
292 if database::debit(&mut self.conn, user, hand.base_bet as i64).is_err() {
293 return "ERROR Insufficient balance".to_string();
294 }
295 if let Err(error) = hand.double() {
296 let _ = database::credit(&mut self.conn, user, hand.base_bet as i64);
297 return format!("ERROR {error}");
298 }
299 }
300 "cancel" => {
301 let refund = session.bet_amount as i64;
302 let balance = database::credit(&mut self.conn, user, refund).unwrap_or(balance);
303 let _ = self.sessions.remove_session(session_id);
304 hand.cancel();
305 let state = hand.state(session_id, balance);
306 return blackjack_wire(&state);
307 }
308 _ => return "ERROR Unknown action".to_string(),
309 }
310
311 let balance = database::get_balance(&self.conn, user).unwrap_or(balance);
312 self.blackjack_response(user, session_id, hand, balance)
313 }
314
315 fn blackjack_response(
316 &mut self,
317 user: &str,
318 session_id: &str,
319 hand: BlackjackHand,
320 balance: i64,
321 ) -> String {
322 if hand.is_done() {
323 let state = hand.state(session_id, balance);
324 let details = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string());
325 let balance = match database::settle_play(
326 &mut self.conn,
327 session_id,
328 user,
329 "blackjack",
330 hand.bet as i64,
331 hand.winnings as i64,
332 &details,
333 ) {
334 Ok(balance) => balance,
335 Err(error) => {
336 eprintln!("Blackjack settlement failed for {user}: {error}");
337 let _ = self.sessions.remove_session(session_id);
338 return "ERROR Failed to update balance".to_string();
339 }
340 };
341 let _ = self.sessions.remove_session(session_id);
342 let state = hand.state(session_id, balance);
343 blackjack_wire(&state)
344 } else {
345 if let Ok(mut session) = self.sessions.get_session(session_id) {
346 session.state.bet_amount = hand.bet;
347 session
348 .state
349 .data
350 .insert("hand".to_string(), hand.to_json());
351 let _ = self.sessions.update_session(session_id, session.state);
352 }
353 let state = hand.state(session_id, balance);
354 blackjack_wire(&state)
355 }
356 }
357
185 fn handle_play_start( 358 fn handle_play_start(
186 &mut self, 359 &mut self,
187 state: &ConnectionState, 360 state: &ConnectionState,
@@ -311,6 +484,9 @@ impl ServerCore {
311 pub fn disconnect(&mut self, state: &ConnectionState) { 484 pub fn disconnect(&mut self, state: &ConnectionState) {
312 if let Some(user) = state.user.as_deref() { 485 if let Some(user) = state.user.as_deref() {
313 for session in self.sessions.get_user_sessions(user) { 486 for session in self.sessions.get_user_sessions(user) {
487 if session.game_type == "blackjack" {
488 let _ = database::credit(&mut self.conn, user, session.bet_amount as i64);
489 }
314 let _ = self.sessions.remove_session(&session.session_id); 490 let _ = self.sessions.remove_session(&session.session_id);
315 } 491 }
316 } 492 }
@@ -339,3 +515,10 @@ fn game_error(error: GameError) -> String {
339 GameError::InternalError(message) => format!("ERROR {message}"), 515 GameError::InternalError(message) => format!("ERROR {message}"),
340 } 516 }
341} 517}
518
519fn blackjack_wire(state: &BlackjackState) -> String {
520 format!(
521 "BLACKJACK_STATE {}",
522 serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string())
523 )
524}
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 {
177 ) 177 )
178 .unwrap_or(0) 178 .unwrap_or(0)
179} 179}
180
181/// Reserve a bet before a multi-step hand starts.
182pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> {
183 let tx = conn.transaction()?;
184 let updated = tx.execute(
185 "UPDATE users SET balance = balance - ?1
186 WHERE username = ?2 AND balance >= ?1",
187 params![amount, username],
188 )?;
189 if updated != 1 {
190 return Err(rusqlite::Error::QueryReturnedNoRows);
191 }
192 let balance = tx.query_row(
193 "SELECT balance FROM users WHERE username = ?1",
194 [username],
195 |row| row.get(0),
196 )?;
197 tx.commit()?;
198 Ok(balance)
199}
200
201/// Put a reserved bet back (disconnect or cancel).
202pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> {
203 let tx = conn.transaction()?;
204 tx.execute(
205 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
206 params![amount, username],
207 )?;
208 let balance = tx.query_row(
209 "SELECT balance FROM users WHERE username = ?1",
210 [username],
211 |row| row.get(0),
212 )?;
213 tx.commit()?;
214 Ok(balance)
215}
216
217/// Settle a hand whose bet was already reserved: credit winnings, write the
218/// audit row, and return the new balance.
219#[allow(clippy::too_many_arguments)]
220pub fn settle_play(
221 conn: &mut Connection,
222 session_id: &str,
223 username: &str,
224 game_type: &str,
225 bet_amount: i64,
226 winnings: i64,
227 details: &str,
228) -> rusqlite::Result<i64> {
229 let tx = conn.transaction()?;
230
231 tx.execute(
232 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
233 params![winnings, username],
234 )?;
235
236 let balance_after: i64 = tx.query_row(
237 "SELECT balance FROM users WHERE username = ?1",
238 [username],
239 |row| row.get(0),
240 )?;
241
242 tx.execute(
243 "INSERT INTO plays
244 (session_id, username, game_type, bet_amount, winnings,
245 balance_after, details)
246 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
247 params![
248 session_id,
249 username,
250 game_type,
251 bet_amount,
252 winnings,
253 balance_after,
254 details
255 ],
256 )?;
257
258 tx.commit()?;
259 Ok(balance_after)
260}
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 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::blackjack::{
3 card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState,
4};
5use rand::seq::SliceRandom;
6use rand::thread_rng;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10pub struct BlackjackGame {
11 fixed_deck: Option<Vec<String>>,
12}
13
14impl 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)]
44pub 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
55impl 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
233impl 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}
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 @@
1pub mod blackjack;
1pub mod registry; 2pub mod registry;
2pub mod roulette; 3pub mod roulette;
3pub mod session; 4pub 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 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use super::blackjack::BlackjackGame;
3use super::roulette::RouletteGame; 4use super::roulette::RouletteGame;
4use super::slots::SlotsGame; 5use super::slots::SlotsGame;
5use super::traits::Game; 6use super::traits::Game;
@@ -23,6 +24,7 @@ impl GameRegistry {
23 let registry = Self::new(); 24 let registry = Self::new();
24 registry.register_game(SlotsGame::new()); 25 registry.register_game(SlotsGame::new());
25 registry.register_game(RouletteGame::new()); 26 registry.register_game(RouletteGame::new());
27 registry.register_game(BlackjackGame::new());
26 registry 28 registry
27 } 29 }
28 30
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 {
51 pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> { 51 pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> {
52 let mut sessions = self.sessions.lock().unwrap(); 52 let mut sessions = self.sessions.lock().unwrap();
53 if let Some(session) = sessions.get_mut(session_id) { 53 if let Some(session) = sessions.get_mut(session_id) {
54 session.bet_amount = new_state.bet_amount;
54 session.state = new_state; 55 session.state = new_state;
55 Ok(()) 56 Ok(())
56 } else { 57 } else {