diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-29 18:22:40 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-29 18:22:40 +0100 |
| commit | b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff (patch) | |
| tree | d12e3c7f6c23ae1698199d16ee763551edfd8c12 | |
| parent | v2.1.2: ignore ANSI when measuring board height (diff) | |
| download | zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.tar.gz zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.zip | |
v2.2.0: add blackjack with reserved bets and hit/stand/double
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GAMES.md | 42 | ||||
| -rw-r--r-- | docs/TESTING.md | 13 | ||||
| -rw-r--r-- | src/blackjack.rs | 90 | ||||
| -rw-r--r-- | src/client/core.rs | 154 | ||||
| -rw-r--r-- | src/client/games/blackjack.rs | 148 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 49 | ||||
| -rw-r--r-- | src/client/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/games/response.rs | 22 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/server/core.rs | 187 | ||||
| -rw-r--r-- | src/server/database.rs | 81 | ||||
| -rw-r--r-- | src/server/games/blackjack.rs | 274 | ||||
| -rw-r--r-- | src/server/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/games/registry.rs | 2 | ||||
| -rw-r--r-- | src/server/games/session.rs | 1 | ||||
| -rw-r--r-- | tests/blackjack.rs | 139 | ||||
| -rw-r--r-- | tests/client_core.rs | 201 | ||||
| -rw-r--r-- | tests/server_core.rs | 159 |
19 files changed, 1549 insertions, 18 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "2.1.2" | 3 | version = "2.2.0" |
| 4 | edition = "2024" | 4 | edition = "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 | ||
| 17 | Blackjack needs server-side hand state and player actions. Crash needs a timed | 17 | Crash needs a timed round loop and a cashout action, so it should be its own |
| 18 | round loop and a cashout action, so it should come after blackjack unless we | 18 | protocol/loop change. |
| 19 | redesign 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 | |||
| 29 | Client controls: | ||
| 30 | |||
| 31 | ``` | ||
| 32 | H hit | ||
| 33 | S stand | ||
| 34 | D double (on the first two cards) | ||
| 35 | SPACE deal the next hand | ||
| 36 | + or = / - change the next hand's bet | ||
| 37 | Q 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 | ||
| 73 | The balance update and the `plays` insert happen in one transaction | 95 | The balance update and the `plays` insert happen in one transaction |
| @@ -89,12 +111,14 @@ PLAY_START roulette <total> <bets-json> | |||
| 89 | ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json> | 111 | ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json> |
| 90 | ``` | 112 | ``` |
| 91 | 113 | ||
| 92 | Blackjack and crash still need a generic state/action exchange, for example: | 114 | Blackjack is a multi-step session: |
| 93 | 115 | ||
| 94 | ``` | 116 | ``` |
| 95 | GAME_START <game> <bet> | 117 | PLAY_START blackjack <bet> |
| 96 | GAME_ACTION <action> [json] | 118 | BLACKJACK_STATE <json> |
| 97 | GAME_STATE <json> # cards, bets, multiplier, legal actions, result | 119 | BLACKJACK_ACTION <session> hit|stand|double|cancel |
| 120 | BLACKJACK_STATE <json> | ||
| 98 | ``` | 121 | ``` |
| 99 | 122 | ||
| 100 | That will be its own protocol change. | 123 | `BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance, |
| 124 | result 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. | |||
| 79 | 6. The result board shows the winning number/color/parity, the payout, and the | 80 | 6. 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 | |||
| 85 | 1. `play blackjack` shows the ready board; Space deals. | ||
| 86 | 2. 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. | ||
| 88 | 3. Stand and check the dealer stands on all 17s. Check a dealer bust pays the | ||
| 89 | player, and a natural blackjack pays 3:2. | ||
| 90 | 4. Q during a hand sends cancel and refunds the reserved bet; Q after a hand | ||
| 91 | just leaves. Disconnecting mid-hand also refunds it. | ||
| 92 | 5. 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 | |||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | |||
| 5 | pub const DECKS: usize = 6; | ||
| 6 | pub const SUITS: [char; 4] = ['C', 'D', 'H', 'S']; | ||
| 7 | pub 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. | ||
| 12 | pub 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 | |||
| 24 | pub 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. | ||
| 34 | pub 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 | |||
| 53 | pub fn is_blackjack(cards: &[String]) -> bool { | ||
| 54 | cards.len() == 2 && hand_total(cards) == 21 | ||
| 55 | } | ||
| 56 | |||
| 57 | pub fn is_red_suit(card: &str) -> bool { | ||
| 58 | matches!(card.chars().last(), Some('D') | Some('H')) | ||
| 59 | } | ||
| 60 | |||
| 61 | pub 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)] | ||
| 77 | pub 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 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::{parse_roulette_response, parse_spin_response}; | 2 | use crate::client::games::response::{ |
| 3 | parse_blackjack_response, parse_roulette_response, parse_spin_response, | ||
| 4 | }; | ||
| 3 | use crate::client::ui::{ | 5 | use 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 { | |||
| 61 | enum Pending { | 63 | enum 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | ||
| 2 | use crate::blackjack::{card_label, BlackjackState}; | ||
| 3 | |||
| 4 | pub const BLACKJACK_BOARD_LINES: usize = 6; | ||
| 5 | |||
| 6 | pub struct BlackjackGame; | ||
| 7 | |||
| 8 | impl 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 | |||
| 47 | impl 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 @@ | |||
| 1 | use super::blackjack::BlackjackGame; | ||
| 1 | use super::response::{RouletteResult, SpinResult}; | 2 | use super::response::{RouletteResult, SpinResult}; |
| 2 | use super::roulette::RouletteGame; | 3 | use super::roulette::RouletteGame; |
| 3 | use super::slots::SlotsGame; | 4 | use super::slots::SlotsGame; |
| 4 | use super::traits::{Game, GameError, GamePhase, GameState}; | 5 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 6 | use crate::blackjack::BlackjackState; | ||
| 5 | use crate::roulette::Bets; | 7 | use crate::roulette::Bets; |
| 6 | use std::collections::HashMap; | 8 | use 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 @@ | |||
| 1 | pub mod blackjack; | ||
| 1 | pub mod manager; | 2 | pub mod manager; |
| 2 | pub mod response; | 3 | pub mod response; |
| 3 | pub mod roulette; | 4 | pub mod roulette; |
diff --git a/src/client/games/response.rs b/src/client/games/response.rs index 73080dd..d9883f7 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs | |||
| @@ -141,3 +141,25 @@ pub fn parse_roulette_response(response: &str) -> Result<RouletteResult, String> | |||
| 141 | bets, | 141 | bets, |
| 142 | }) | 142 | }) |
| 143 | } | 143 | } |
| 144 | |||
| 145 | pub 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 | } | ||
| @@ -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 | ||
| 8 | pub mod blackjack; | ||
| 8 | pub mod client; | 9 | pub mod client; |
| 9 | pub mod protocol; | 10 | pub mod protocol; |
| 10 | pub mod roulette; | 11 | pub 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 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | 1 | use bcrypt::{hash, verify, DEFAULT_COST}; |
| 2 | use rusqlite::Connection; | 2 | use rusqlite::Connection; |
| 3 | use std::collections::HashMap; | ||
| 3 | 4 | ||
| 5 | use crate::blackjack::BlackjackState; | ||
| 4 | use crate::server::database; | 6 | use crate::server::database; |
| 7 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; | ||
| 5 | use crate::server::games::registry::GameRegistry; | 8 | use crate::server::games::registry::GameRegistry; |
| 6 | use crate::server::games::session::SessionManager; | 9 | use crate::server::games::session::SessionManager; |
| 7 | use crate::server::games::traits::GameError; | 10 | use crate::server::games::traits::{GameError, GamePhase, GameState}; |
| 8 | 11 | ||
| 9 | #[derive(Debug, Default)] | 12 | #[derive(Debug, Default)] |
| 10 | pub struct ConnectionState { | 13 | pub 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 | ||
| 27 | impl ServerCore { | 31 | impl 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 | |||
| 519 | fn 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. | ||
| 182 | pub 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). | ||
| 202 | pub 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)] | ||
| 220 | pub 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 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | ||
| 2 | use crate::blackjack::{ | ||
| 3 | card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, | ||
| 4 | }; | ||
| 5 | use rand::seq::SliceRandom; | ||
| 6 | use rand::thread_rng; | ||
| 7 | use serde::{Deserialize, Serialize}; | ||
| 8 | use std::collections::HashMap; | ||
| 9 | |||
| 10 | pub struct BlackjackGame { | ||
| 11 | fixed_deck: Option<Vec<String>>, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl BlackjackGame { | ||
| 15 | pub fn new() -> Self { | ||
| 16 | Self { fixed_deck: None } | ||
| 17 | } | ||
| 18 | |||
| 19 | pub fn with_deck(deck: Vec<String>) -> Self { | ||
| 20 | Self { | ||
| 21 | fixed_deck: Some(deck), | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | fn shoe(&self) -> Vec<String> { | ||
| 26 | if let Some(deck) = &self.fixed_deck { | ||
| 27 | return deck.clone(); | ||
| 28 | } | ||
| 29 | let mut shoe = fresh_shoe(); | ||
| 30 | shoe.shuffle(&mut thread_rng()); | ||
| 31 | shoe | ||
| 32 | } | ||
| 33 | |||
| 34 | pub fn deal(&self, bet: u64) -> BlackjackHand { | ||
| 35 | let mut hand = BlackjackHand::new(self.shoe(), bet); | ||
| 36 | if is_blackjack(&hand.player) || is_blackjack(&hand.dealer) { | ||
| 37 | hand.settle(); | ||
| 38 | } | ||
| 39 | hand | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 44 | pub struct BlackjackHand { | ||
| 45 | pub shoe: Vec<String>, | ||
| 46 | pub player: Vec<String>, | ||
| 47 | pub dealer: Vec<String>, | ||
| 48 | pub phase: String, | ||
| 49 | pub base_bet: u64, | ||
| 50 | pub bet: u64, | ||
| 51 | pub result: String, | ||
| 52 | pub winnings: u64, | ||
| 53 | } | ||
| 54 | |||
| 55 | impl BlackjackHand { | ||
| 56 | fn new(shoe: Vec<String>, bet: u64) -> Self { | ||
| 57 | let mut hand = Self { | ||
| 58 | shoe, | ||
| 59 | player: Vec::new(), | ||
| 60 | dealer: Vec::new(), | ||
| 61 | phase: "player".to_string(), | ||
| 62 | base_bet: bet, | ||
| 63 | bet, | ||
| 64 | result: "playing".to_string(), | ||
| 65 | winnings: 0, | ||
| 66 | }; | ||
| 67 | |||
| 68 | let card = hand.draw(); | ||
| 69 | hand.player.push(card); | ||
| 70 | let card = hand.draw(); | ||
| 71 | hand.dealer.push(card); | ||
| 72 | let card = hand.draw(); | ||
| 73 | hand.player.push(card); | ||
| 74 | let card = hand.draw(); | ||
| 75 | hand.dealer.push(card); | ||
| 76 | |||
| 77 | hand | ||
| 78 | } | ||
| 79 | |||
| 80 | fn draw(&mut self) -> String { | ||
| 81 | self.shoe.remove(0) | ||
| 82 | } | ||
| 83 | |||
| 84 | pub fn player_total(&self) -> u8 { | ||
| 85 | hand_total(&self.player) | ||
| 86 | } | ||
| 87 | |||
| 88 | pub fn dealer_total(&self) -> u8 { | ||
| 89 | hand_total(&self.dealer) | ||
| 90 | } | ||
| 91 | |||
| 92 | pub fn is_done(&self) -> bool { | ||
| 93 | self.phase == "done" | ||
| 94 | } | ||
| 95 | |||
| 96 | pub fn hit(&mut self) -> Result<(), String> { | ||
| 97 | if self.is_done() { | ||
| 98 | return Err("Hand is over".to_string()); | ||
| 99 | } | ||
| 100 | let card = self.draw(); | ||
| 101 | self.player.push(card); | ||
| 102 | let total = self.player_total(); | ||
| 103 | if total > 21 { | ||
| 104 | self.settle(); | ||
| 105 | } else if total == 21 { | ||
| 106 | self.dealer_play(); | ||
| 107 | self.settle(); | ||
| 108 | } | ||
| 109 | Ok(()) | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn stand(&mut self) { | ||
| 113 | if self.is_done() { | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | self.dealer_play(); | ||
| 117 | self.settle(); | ||
| 118 | } | ||
| 119 | |||
| 120 | /// Caller must debit the extra `base_bet` before calling this. | ||
| 121 | pub fn double(&mut self) -> Result<(), String> { | ||
| 122 | if self.is_done() { | ||
| 123 | return Err("Hand is over".to_string()); | ||
| 124 | } | ||
| 125 | if self.player.len() != 2 { | ||
| 126 | return Err("Double is only allowed on the first two cards".to_string()); | ||
| 127 | } | ||
| 128 | self.bet = self.bet.saturating_add(self.base_bet); | ||
| 129 | let card = self.draw(); | ||
| 130 | self.player.push(card); | ||
| 131 | self.dealer_play(); | ||
| 132 | self.settle(); | ||
| 133 | Ok(()) | ||
| 134 | } | ||
| 135 | |||
| 136 | pub fn cancel(&mut self) { | ||
| 137 | self.phase = "done".to_string(); | ||
| 138 | self.result = "cancelled".to_string(); | ||
| 139 | self.winnings = 0; | ||
| 140 | } | ||
| 141 | |||
| 142 | fn dealer_play(&mut self) { | ||
| 143 | while self.dealer_total() < 17 { | ||
| 144 | let card = self.draw(); | ||
| 145 | self.dealer.push(card); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | fn settle(&mut self) { | ||
| 150 | if self.is_done() { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | self.phase = "done".to_string(); | ||
| 154 | |||
| 155 | let player = self.player_total(); | ||
| 156 | let dealer = self.dealer_total(); | ||
| 157 | let player_blackjack = is_blackjack(&self.player); | ||
| 158 | let dealer_blackjack = is_blackjack(&self.dealer); | ||
| 159 | |||
| 160 | self.result = if player > 21 { | ||
| 161 | "bust".to_string() | ||
| 162 | } else if player_blackjack && !dealer_blackjack { | ||
| 163 | "blackjack".to_string() | ||
| 164 | } else if dealer_blackjack && !player_blackjack { | ||
| 165 | "lose".to_string() | ||
| 166 | } else if dealer > 21 { | ||
| 167 | "win".to_string() | ||
| 168 | } else if player == dealer { | ||
| 169 | "push".to_string() | ||
| 170 | } else if player > dealer { | ||
| 171 | "win".to_string() | ||
| 172 | } else { | ||
| 173 | "lose".to_string() | ||
| 174 | }; | ||
| 175 | |||
| 176 | self.winnings = match self.result.as_str() { | ||
| 177 | "push" => self.bet, | ||
| 178 | "blackjack" => self.bet + (self.bet * 3) / 2, | ||
| 179 | "win" => self.bet * 2, | ||
| 180 | _ => 0, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 184 | pub fn state(&self, session_id: &str, balance: i64) -> BlackjackState { | ||
| 185 | let player_total = self.player_total(); | ||
| 186 | let (dealer, dealer_total, actions) = if self.is_done() { | ||
| 187 | (self.dealer.clone(), self.dealer_total(), Vec::new()) | ||
| 188 | } else { | ||
| 189 | let visible = self | ||
| 190 | .dealer | ||
| 191 | .first() | ||
| 192 | .map(|card| card_value(card)) | ||
| 193 | .unwrap_or(0); | ||
| 194 | let mut actions = vec!["hit".to_string(), "stand".to_string()]; | ||
| 195 | if self.player.len() == 2 { | ||
| 196 | actions.push("double".to_string()); | ||
| 197 | } | ||
| 198 | ( | ||
| 199 | vec![ | ||
| 200 | self.dealer.first().cloned().unwrap_or_default(), | ||
| 201 | "?".to_string(), | ||
| 202 | ], | ||
| 203 | visible, | ||
| 204 | actions, | ||
| 205 | ) | ||
| 206 | }; | ||
| 207 | |||
| 208 | BlackjackState { | ||
| 209 | session_id: session_id.to_string(), | ||
| 210 | phase: self.phase.clone(), | ||
| 211 | player: self.player.clone(), | ||
| 212 | dealer, | ||
| 213 | player_total, | ||
| 214 | dealer_total, | ||
| 215 | bet: self.bet, | ||
| 216 | base_bet: self.base_bet, | ||
| 217 | balance, | ||
| 218 | actions, | ||
| 219 | result: self.result.clone(), | ||
| 220 | winnings: self.winnings, | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | pub fn to_json(&self) -> String { | ||
| 225 | serde_json::to_string(self).unwrap_or_default() | ||
| 226 | } | ||
| 227 | |||
| 228 | pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> { | ||
| 229 | serde_json::from_str(raw) | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | impl Game for BlackjackGame { | ||
| 234 | fn game_type(&self) -> &'static str { | ||
| 235 | "blackjack" | ||
| 236 | } | ||
| 237 | |||
| 238 | fn get_name(&self) -> &'static str { | ||
| 239 | "Blackjack" | ||
| 240 | } | ||
| 241 | |||
| 242 | fn get_description(&self) -> &'static str { | ||
| 243 | "Blackjack: hit, stand or double. Dealer stands on all 17s." | ||
| 244 | } | ||
| 245 | |||
| 246 | fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState { | ||
| 247 | let mut data = HashMap::new(); | ||
| 248 | data.insert("status".to_string(), "ready".to_string()); | ||
| 249 | GameState { | ||
| 250 | session_id: String::new(), | ||
| 251 | game_type: "blackjack".to_string(), | ||
| 252 | bet_amount, | ||
| 253 | phase: GamePhase::WaitingToStart, | ||
| 254 | data, | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | fn process_action( | ||
| 259 | &self, | ||
| 260 | _state: &GameState, | ||
| 261 | _action: &str, | ||
| 262 | _params: &[&str], | ||
| 263 | ) -> Result<GameState, GameError> { | ||
| 264 | Err(GameError::InvalidAction( | ||
| 265 | "Blackjack uses BLACKJACK_ACTION".to_string(), | ||
| 266 | )) | ||
| 267 | } | ||
| 268 | |||
| 269 | fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> { | ||
| 270 | Err(GameError::InternalError( | ||
| 271 | "Blackjack settles through BLACKJACK_ACTION".to_string(), | ||
| 272 | )) | ||
| 273 | } | ||
| 274 | } | ||
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 @@ | |||
| 1 | pub mod blackjack; | ||
| 1 | pub mod registry; | 2 | pub mod registry; |
| 2 | pub mod roulette; | 3 | pub mod roulette; |
| 3 | pub mod session; | 4 | pub mod session; |
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs index 51d446b..7ff8fbf 100644 --- a/src/server/games/registry.rs +++ b/src/server/games/registry.rs | |||
| @@ -1,5 +1,6 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | use super::blackjack::BlackjackGame; | ||
| 3 | use super::roulette::RouletteGame; | 4 | use super::roulette::RouletteGame; |
| 4 | use super::slots::SlotsGame; | 5 | use super::slots::SlotsGame; |
| 5 | use super::traits::Game; | 6 | use 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 @@ | |||
| 1 | use zagan_bet::blackjack::{ | ||
| 2 | card_label, card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, | ||
| 3 | }; | ||
| 4 | use zagan_bet::server::games::blackjack::BlackjackGame; | ||
| 5 | |||
| 6 | fn 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] | ||
| 12 | fn 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] | ||
| 28 | fn 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] | ||
| 35 | fn 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] | ||
| 45 | fn 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] | ||
| 59 | fn 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] | ||
| 69 | fn 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] | ||
| 83 | fn 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] | ||
| 96 | fn 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] | ||
| 108 | fn 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] | ||
| 117 | fn 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] | ||
| 128 | fn 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 | ||
| 7 | use zagan_bet::blackjack::BlackjackState; | ||
| 7 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 8 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 8 | use zagan_bet::client::games::response::parse_spin_response; | 9 | use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; |
| 9 | use zagan_bet::client::ui::command_names; | 10 | use zagan_bet::client::ui::command_names; |
| 10 | 11 | ||
| 11 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 12 | const 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 | |||
| 657 | fn 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] | ||
| 688 | fn 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] | ||
| 760 | fn 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] | ||
| 814 | fn 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] | ||
| 829 | fn 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 @@ | |||
| 1 | use std::path::PathBuf; | 1 | use std::path::PathBuf; |
| 2 | 2 | ||
| 3 | use rusqlite::Connection; | 3 | use rusqlite::Connection; |
| 4 | use zagan_bet::blackjack::BlackjackState; | ||
| 4 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 5 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 5 | use zagan_bet::server::database; | 6 | use zagan_bet::server::database; |
| 6 | use zagan_bet::server::games::registry::GameRegistry; | 7 | use 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 | ||
| 51 | fn 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 | |||
| 69 | fn 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 | |||
| 50 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 76 | fn 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] | ||
| 368 | fn 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] | ||
| 416 | fn 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] | ||
| 442 | fn 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] | ||
| 460 | fn 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] | ||
| 483 | fn 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 | } | ||
