summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
commitb5a52fc0312afdf5d5fbe53275dc88d6d881c2ff (patch)
treed12e3c7f6c23ae1698199d16ee763551edfd8c12
parentv2.1.2: ignore ANSI when measuring board height (diff)
downloadzagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.tar.gz
zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.zip
v2.2.0: add blackjack with reserved bets and hit/stand/double
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md42
-rw-r--r--docs/TESTING.md13
-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
-rw-r--r--tests/blackjack.rs139
-rw-r--r--tests/client_core.rs201
-rw-r--r--tests/server_core.rs159
19 files changed, 1549 insertions, 18 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 1725705..04e866b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "2.1.2" 3version = "2.2.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[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 @@
6| --- | --- | --- | 6| --- | --- | --- |
7| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | 7| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP |
8| Roulette | Done | European wheel, split bets, 97.3% RTP | 8| Roulette | Done | European wheel, split bets, 97.3% RTP |
9| Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double |
9 10
10## Planned 11## Planned
11 12
12| Game | Shape | Main open decisions | 13| Game | Shape | Main open decisions |
13| --- | --- | --- | 14| --- | --- | --- |
14| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence |
15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | 15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness |
16 16
17Blackjack needs server-side hand state and player actions. Crash needs a timed 17Crash needs a timed round loop and a cashout action, so it should be its own
18round loop and a cashout action, so it should come after blackjack unless we 18protocol/loop change.
19redesign the loop first. 19
20## Blackjack
21
22- Six-deck shoe, shuffled fresh for every hand and discarded afterwards.
23- Dealer stands on all 17s (including soft 17).
24- Blackjack pays 3:2, rounded down to whole PLN.
25- Hit, stand, and double; no split, insurance, or surrender.
26- The bet is reserved when the hand starts. Cancel or disconnect refunds it.
27- A natural or dealer blackjack settles immediately.
28
29Client controls:
30
31```
32H hit
33S stand
34D double (on the first two cards)
35SPACE deal the next hand
36+ or = / - change the next hand's bet
37Q leave the table (active hand is cancelled and refunded)
38```
20 39
21## Roulette 40## Roulette
22 41
@@ -68,6 +87,9 @@ plays (
68 87
69// roulette 88// roulette
70{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"} 89{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"}
90
91// blackjack
92{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20}
71``` 93```
72 94
73The balance update and the `plays` insert happen in one transaction 95The balance update and the `plays` insert happen in one transaction
@@ -89,12 +111,14 @@ PLAY_START roulette <total> <bets-json>
89ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json> 111ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json>
90``` 112```
91 113
92Blackjack and crash still need a generic state/action exchange, for example: 114Blackjack is a multi-step session:
93 115
94``` 116```
95GAME_START <game> <bet> 117PLAY_START blackjack <bet>
96GAME_ACTION <action> [json] 118BLACKJACK_STATE <json>
97GAME_STATE <json> # cards, bets, multiplier, legal actions, result 119BLACKJACK_ACTION <session> hit|stand|double|cancel
120BLACKJACK_STATE <json>
98``` 121```
99 122
100That will be its own protocol change. 123`BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance,
124result 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.
13| --- | --- | 13| --- | --- |
14| `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. | 14| `tests/client_core.rs` | Client command/game flows via a headless screen applying logical effects. |
15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | 15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. |
16| `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. |
16| `tests/protocol.rs` | Newline buffering and timeout/error classification. | 17| `tests/protocol.rs` | Newline buffering and timeout/error classification. |
17| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | 18| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. |
18| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | 19| `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.
796. The result board shows the winning number/color/parity, the payout, and the 806. The result board shows the winning number/color/parity, the payout, and the
80 new balance. The next spin reuses the placed bets. 81 new balance. The next spin reuses the placed bets.
81 82
83## Manual blackjack checklist
84
851. `play blackjack` shows the ready board; Space deals.
862. The dealer's hole card is hidden while it is the player's turn. H/S/D work;
87 D is only offered on the first two cards and reserves the extra bet.
883. Stand and check the dealer stands on all 17s. Check a dealer bust pays the
89 player, and a natural blackjack pays 3:2.
904. Q during a hand sends cancel and refunds the reserved bet; Q after a hand
91 just leaves. Disconnecting mid-hand also refunds it.
925. Check the audit row: `game_type = blackjack`, the total bet (including a
93 double), winnings, and the final cards/result in `details`.
94
82## Manual server checks 95## Manual server checks
83 96
84- Register, log out, log in again (bcrypt path). 97- 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 @@
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 {
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 @@
1use zagan_bet::blackjack::{
2 card_label, card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState,
3};
4use zagan_bet::server::games::blackjack::BlackjackGame;
5
6fn hand(deck: &[&str], bet: u64) -> zagan_bet::server::games::blackjack::BlackjackHand {
7 let deck = deck.iter().map(|card| card.to_string()).collect();
8 BlackjackGame::with_deck(deck).deal(bet)
9}
10
11#[test]
12fn card_values_and_soft_totals_are_correct() {
13 assert_eq!(card_value("AS"), 11);
14 assert_eq!(card_value("10H"), 10);
15 assert_eq!(card_value("KD"), 10);
16 assert_eq!(card_label("AS"), "A♠");
17 assert_eq!(card_label("10H"), "10♥");
18
19 let cards = |values: &[&str]| values.iter().map(|card| card.to_string()).collect::<Vec<_>>();
20 assert_eq!(hand_total(&cards(&["AS", "10H"])), 21);
21 assert_eq!(hand_total(&cards(&["AS", "9H"])), 20);
22 assert_eq!(hand_total(&cards(&["AS", "AS", "9H"])), 21);
23 assert!(is_blackjack(&cards(&["AS", "KH"])));
24 assert!(!is_blackjack(&cards(&["AS", "9H"])));
25}
26
27#[test]
28fn a_six_deck_shoe_has_312_cards() {
29 let shoe = fresh_shoe();
30 assert_eq!(shoe.len(), 312);
31 assert_eq!(shoe.iter().filter(|card| card.as_str() == "AS").count(), 6);
32}
33
34#[test]
35fn a_natural_blackjack_settles_immediately() {
36 let hand = hand(&["AS", "9D", "KH", "8C"], 10);
37
38 assert!(hand.is_done());
39 assert_eq!(hand.result, "blackjack");
40 assert_eq!(hand.winnings, 25); // 10 + 15
41 assert_eq!(hand.player_total(), 21);
42}
43
44#[test]
45fn a_player_hand_can_hit_to_21() {
46 let mut hand = hand(&["10S", "9D", "6H", "8C", "5D"], 10);
47 assert_eq!(hand.player_total(), 16);
48 assert_eq!(hand.dealer_total(), 17);
49
50 hand.hit().unwrap();
51
52 assert!(hand.is_done());
53 assert_eq!(hand.player_total(), 21);
54 assert_eq!(hand.result, "win");
55 assert_eq!(hand.winnings, 20);
56}
57
58#[test]
59fn hitting_over_21_busts() {
60 let mut hand = hand(&["10S", "9D", "6H", "8C", "KD"], 10);
61 hand.hit().unwrap();
62
63 assert!(hand.is_done());
64 assert_eq!(hand.result, "bust");
65 assert_eq!(hand.winnings, 0);
66}
67
68#[test]
69fn doubling_adds_the_base_bet_and_draws_one_card() {
70 let mut hand = hand(&["5S", "9D", "5H", "8C", "10D"], 10);
71 assert_eq!(hand.player_total(), 10);
72
73 hand.double().unwrap();
74
75 assert_eq!(hand.player.len(), 3);
76 assert_eq!(hand.bet, 20);
77 assert_eq!(hand.player_total(), 20);
78 assert_eq!(hand.result, "win");
79 assert_eq!(hand.winnings, 40);
80}
81
82#[test]
83fn the_dealer_busts_and_the_player_wins() {
84 let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10);
85 assert_eq!(hand.player_total(), 18);
86 assert_eq!(hand.dealer_total(), 16);
87
88 hand.stand();
89
90 assert_eq!(hand.dealer_total(), 26);
91 assert_eq!(hand.result, "win");
92 assert_eq!(hand.winnings, 20);
93}
94
95#[test]
96fn the_dealer_stands_on_soft_17() {
97 let mut hand = hand(&["10S", "AD", "8H", "6C"], 10);
98 assert_eq!(hand.dealer_total(), 17);
99
100 hand.stand();
101
102 assert_eq!(hand.dealer.len(), 2, "dealer must stand on soft 17");
103 assert_eq!(hand.result, "win");
104 assert_eq!(hand.winnings, 20);
105}
106
107#[test]
108fn equal_totals_push() {
109 let mut hand = hand(&["10S", "10D", "8H", "8C"], 10);
110 hand.stand();
111
112 assert_eq!(hand.result, "push");
113 assert_eq!(hand.winnings, 10);
114}
115
116#[test]
117fn dealer_blackjack_wins_unless_player_also_has_it() {
118 let dealer_blackjack = hand(&["10S", "AS", "8H", "KD"], 10);
119 assert_eq!(dealer_blackjack.result, "lose");
120 assert_eq!(dealer_blackjack.winnings, 0);
121
122 let both = hand(&["AS", "AD", "KH", "KC"], 10);
123 assert_eq!(both.result, "push");
124 assert_eq!(both.winnings, 10);
125}
126
127#[test]
128fn the_player_state_hides_the_hole_card() {
129 let hand = hand(&["10S", "9D", "6H", "8C"], 10);
130 let state: BlackjackState = hand.state("session-1", 990);
131
132 assert_eq!(state.phase, "player");
133 assert_eq!(state.dealer, vec!["9D".to_string(), "?".to_string()]);
134 assert_eq!(state.dealer_total, 9);
135 assert_eq!(state.player_total, 16);
136 assert_eq!(state.actions, vec!["hit", "stand", "double"]);
137 assert_eq!(state.bet, 10);
138 assert_eq!(state.balance, 990);
139}
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 @@
4//! logical `Effect`s that `ClientCore` emits, so assertions are about what a 4//! logical `Effect`s that `ClientCore` emits, so assertions are about what a
5//! player would see, not about ANSI sequences. 5//! player would see, not about ANSI sequences.
6 6
7use zagan_bet::blackjack::BlackjackState;
7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 8use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
8use zagan_bet::client::games::response::parse_spin_response; 9use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response};
9use zagan_bet::client::ui::command_names; 10use zagan_bet::client::ui::command_names;
10 11
11const WIN3: &str = "CCCCLLCLODGSDGS"; 12const WIN3: &str = "CCCCLLCLODGSDGS";
@@ -139,6 +140,11 @@ impl Harness {
139 self.line("play roulette"); 140 self.line("play roulette");
140 self.reply("INFO alice 1 1000"); 141 self.reply("INFO alice 1 1000");
141 } 142 }
143
144 fn enter_blackjack(&mut self) {
145 self.line("play blackjack");
146 self.reply("INFO alice 1 1000");
147 }
142} 148}
143 149
144#[test] 150#[test]
@@ -647,3 +653,196 @@ fn roulette_rejects_an_out_of_range_number() {
647 assert!(h.board()[4].contains("ERROR: Number must be 0-36")); 653 assert!(h.board()[4].contains("ERROR: Number must be 0-36"));
648 assert!(h.board()[2].contains("Bets: (none)")); 654 assert!(h.board()[2].contains("Bets: (none)"));
649} 655}
656
657fn bj_state(
658 session: &str,
659 phase: &str,
660 player: &[&str],
661 dealer: &[&str],
662 player_total: u8,
663 dealer_total: u8,
664 bet: u64,
665 base_bet: u64,
666 balance: i64,
667 actions: &[&str],
668 result: &str,
669 winnings: u64,
670) -> BlackjackState {
671 BlackjackState {
672 session_id: session.to_string(),
673 phase: phase.to_string(),
674 player: player.iter().map(|card| card.to_string()).collect(),
675 dealer: dealer.iter().map(|card| card.to_string()).collect(),
676 player_total,
677 dealer_total,
678 bet,
679 base_bet,
680 balance,
681 actions: actions.iter().map(|action| action.to_string()).collect(),
682 result: result.to_string(),
683 winnings,
684 }
685}
686
687#[test]
688fn blackjack_deals_hits_and_settles() {
689 let mut h = Harness::new();
690 h.enter_blackjack();
691
692 assert_eq!(h.board().len(), 6);
693 assert!(h.board()[4].contains("Press [SPACE] to deal."));
694
695 h.key(Key::Char(' '));
696 assert!(h
697 .sent
698 .iter()
699 .any(|line| line == "PLAY_START blackjack 10"));
700
701 let player = bj_state(
702 "bj1",
703 "player",
704 &["10S", "6H"],
705 &["9D", "?"],
706 16,
707 9,
708 10,
709 10,
710 990,
711 &["hit", "stand", "double"],
712 "playing",
713 0,
714 );
715 h.reply(&format!(
716 "BLACKJACK_STATE {}",
717 serde_json::to_string(&player).unwrap()
718 ));
719
720 assert_eq!(h.board().len(), 6);
721 assert!(h.board()[2].contains("Dealer: [9♦] [ ? ] 9"));
722 assert!(h.board()[3].contains("You: [10♠] [6♥] 16"));
723 assert!(h.board()[4].contains("Hit, stand or double."));
724 assert!(h.board()[5].contains("[D]ouble"));
725
726 h.key(Key::Char('h'));
727 assert_eq!(
728 h.sent.last().map(String::as_str),
729 Some("BLACKJACK_ACTION bj1 hit")
730 );
731
732 let done = bj_state(
733 "bj1",
734 "done",
735 &["10S", "6H", "5D"],
736 &["9D", "8C"],
737 21,
738 17,
739 10,
740 10,
741 1010,
742 &[],
743 "win",
744 20,
745 );
746 h.reply(&format!(
747 "BLACKJACK_STATE {}",
748 serde_json::to_string(&done).unwrap()
749 ));
750
751 assert!(h.board()[4].contains("Win! Won 20 PLN"));
752 assert!(h.board()[5].contains("[SPACE] New hand"));
753
754 h.key(Key::Char('q'));
755 assert!(h.board().is_empty());
756 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
757}
758
759#[test]
760fn blackjack_cancel_sends_a_cancel_action() {
761 let mut h = Harness::new();
762 h.enter_blackjack();
763 h.key(Key::Char(' '));
764
765 let player = bj_state(
766 "bj1",
767 "player",
768 &["10S", "6H"],
769 &["9D", "?"],
770 16,
771 9,
772 10,
773 10,
774 990,
775 &["hit", "stand", "double"],
776 "playing",
777 0,
778 );
779 h.reply(&format!(
780 "BLACKJACK_STATE {}",
781 serde_json::to_string(&player).unwrap()
782 ));
783
784 h.key(Key::Char('q'));
785 assert_eq!(
786 h.sent.last().map(String::as_str),
787 Some("BLACKJACK_ACTION bj1 cancel")
788 );
789
790 let cancelled = bj_state(
791 "bj1",
792 "done",
793 &["10S", "6H"],
794 &["9D", "?"],
795 16,
796 9,
797 10,
798 10,
799 1000,
800 &[],
801 "cancelled",
802 0,
803 );
804 h.reply(&format!(
805 "BLACKJACK_STATE {}",
806 serde_json::to_string(&cancelled).unwrap()
807 ));
808
809 assert!(h.board().is_empty());
810 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
811}
812
813#[test]
814fn blackjack_ready_board_can_change_the_next_bet() {
815 let mut h = Harness::new();
816 h.enter_blackjack();
817
818 h.key(Key::Char('='));
819 assert!(h.board()[1].contains("Bet: 20 PLN"));
820
821 h.key(Key::Char(' '));
822 assert!(h
823 .sent
824 .iter()
825 .any(|line| line == "PLAY_START blackjack 20"));
826}
827
828#[test]
829fn parses_a_blackjack_state() {
830 let state = bj_state(
831 "bj1",
832 "player",
833 &["10S", "6H"],
834 &["9D", "?"],
835 16,
836 9,
837 10,
838 10,
839 990,
840 &["hit", "stand"],
841 "playing",
842 0,
843 );
844 let response = format!("BLACKJACK_STATE {}", serde_json::to_string(&state).unwrap());
845
846 assert_eq!(parse_blackjack_response(&response).unwrap(), state);
847 assert!(parse_blackjack_response("ERROR Insufficient balance").is_err());
848}
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 @@
1use std::path::PathBuf; 1use std::path::PathBuf;
2 2
3use rusqlite::Connection; 3use rusqlite::Connection;
4use zagan_bet::blackjack::BlackjackState;
4use zagan_bet::server::core::{ConnectionState, ServerCore}; 5use zagan_bet::server::core::{ConnectionState, ServerCore};
5use zagan_bet::server::database; 6use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry; 7use zagan_bet::server::games::registry::GameRegistry;
@@ -47,6 +48,31 @@ fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) {
47 (ServerCore::with_registry(conn, registry, 4), path) 48 (ServerCore::with_registry(conn, registry, 4), path)
48} 49}
49 50
51fn setup_blackjack(name: &str, deck: &[&str]) -> (ServerCore, String) {
52 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
53 .join(format!("server-core-{name}.db"))
54 .to_string_lossy()
55 .into_owned();
56 let _ = std::fs::remove_file(&path);
57
58 let conn = Connection::open(&path).unwrap();
59 database::init_db(&conn);
60
61 let deck = deck.iter().map(|card| card.to_string()).collect();
62 (
63 ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4)
64 .with_blackjack_deck(deck),
65 path,
66 )
67}
68
69fn blackjack_state(response: &str) -> BlackjackState {
70 let raw = response
71 .strip_prefix("BLACKJACK_STATE ")
72 .expect("blackjack state response");
73 serde_json::from_str(raw).expect("valid blackjack state")
74}
75
50fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 76fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
51 assert_eq!( 77 assert_eq!(
52 core.handle_message(state, &format!("REGISTER {user} credential")), 78 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -337,3 +363,136 @@ fn unknown_games_are_rejected_before_a_session_is_created() {
337 ); 363 );
338 assert_eq!(core.session_count(), 0); 364 assert_eq!(core.session_count(), 0);
339} 365}
366
367#[test]
368fn blackjack_reserves_the_bet_and_settles_a_hit_hand() {
369 let (mut core, path) = setup_blackjack(
370 "blackjack-hit",
371 &["10S", "9D", "6H", "8C", "5D"],
372 );
373 let mut state = ConnectionState::default();
374 register(&mut core, &mut state, "alice");
375 login(&mut core, &mut state, "alice");
376
377 let response = core.handle_message(&mut state, "PLAY_START blackjack 10");
378 let current = blackjack_state(&response);
379
380 assert_eq!(current.phase, "player");
381 assert_eq!(current.player_total, 16);
382 assert_eq!(current.dealer, vec!["9D".to_string(), "?".to_string()]);
383 assert_eq!(current.balance, 990);
384 assert_eq!(current.actions, vec!["hit", "stand", "double"]);
385 assert_eq!(core.session_count(), 1);
386
387 let response = core.handle_message(
388 &mut state,
389 &format!("BLACKJACK_ACTION {} hit", current.session_id),
390 );
391 let done = blackjack_state(&response);
392
393 assert_eq!(done.phase, "done");
394 assert_eq!(done.result, "win");
395 assert_eq!(done.winnings, 20);
396 assert_eq!(done.balance, 1010);
397 assert_eq!(core.balance("alice"), Some(1010));
398 assert_eq!(core.play_count("alice"), 1);
399 assert_eq!(core.session_count(), 0);
400
401 let conn = Connection::open(&path).unwrap();
402 let (game_type, details): (String, String) = conn
403 .query_row(
404 "SELECT game_type, details FROM plays WHERE username = 'alice'",
405 [],
406 |row| Ok((row.get(0)?, row.get(1)?)),
407 )
408 .unwrap();
409 assert_eq!(game_type, "blackjack");
410 let details: BlackjackState = serde_json::from_str(&details).unwrap();
411 assert_eq!(details.result, "win");
412 assert_eq!(details.winnings, 20);
413}
414
415#[test]
416fn blackjack_double_reserves_an_extra_bet() {
417 let (mut core, _path) = setup_blackjack(
418 "blackjack-double",
419 &["5S", "9D", "5H", "8C", "10D"],
420 );
421 let mut state = ConnectionState::default();
422 register(&mut core, &mut state, "alice");
423 login(&mut core, &mut state, "alice");
424
425 let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10"));
426 assert_eq!(current.balance, 990);
427
428 let done = blackjack_state(&core.handle_message(
429 &mut state,
430 &format!("BLACKJACK_ACTION {} double", current.session_id),
431 ));
432
433 assert_eq!(done.phase, "done");
434 assert_eq!(done.bet, 20);
435 assert_eq!(done.winnings, 40);
436 assert_eq!(done.balance, 1020);
437 assert_eq!(core.balance("alice"), Some(1020));
438 assert_eq!(core.play_count("alice"), 1);
439}
440
441#[test]
442fn blackjack_natural_settles_immediately() {
443 let (mut core, _path) =
444 setup_blackjack("blackjack-natural", &["AS", "9D", "KH", "8C"]);
445 let mut state = ConnectionState::default();
446 register(&mut core, &mut state, "alice");
447 login(&mut core, &mut state, "alice");
448
449 let done = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10"));
450
451 assert_eq!(done.phase, "done");
452 assert_eq!(done.result, "blackjack");
453 assert_eq!(done.winnings, 25);
454 assert_eq!(done.balance, 1015);
455 assert_eq!(core.session_count(), 0);
456 assert_eq!(core.play_count("alice"), 1);
457}
458
459#[test]
460fn blackjack_cancel_refunds_the_reserved_bet() {
461 let (mut core, _path) =
462 setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]);
463 let mut state = ConnectionState::default();
464 register(&mut core, &mut state, "alice");
465 login(&mut core, &mut state, "alice");
466
467 let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10"));
468 assert_eq!(current.balance, 990);
469
470 let cancelled = blackjack_state(&core.handle_message(
471 &mut state,
472 &format!("BLACKJACK_ACTION {} cancel", current.session_id),
473 ));
474
475 assert_eq!(cancelled.result, "cancelled");
476 assert_eq!(cancelled.balance, 1000);
477 assert_eq!(core.balance("alice"), Some(1000));
478 assert_eq!(core.play_count("alice"), 0);
479 assert_eq!(core.session_count(), 0);
480}
481
482#[test]
483fn blackjack_disconnect_refunds_the_reserved_bet() {
484 let (mut core, _path) =
485 setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]);
486 let mut state = ConnectionState::default();
487 register(&mut core, &mut state, "alice");
488 login(&mut core, &mut state, "alice");
489
490 let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10"));
491 assert_eq!(current.balance, 990);
492
493 core.disconnect(&state);
494
495 assert_eq!(core.balance("alice"), Some(1000));
496 assert_eq!(core.play_count("alice"), 0);
497 assert_eq!(core.session_count(), 0);
498}