From b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 18:22:40 +0100 Subject: v2.2.0: add blackjack with reserved bets and hit/stand/double --- tests/blackjack.rs | 139 +++++++++++++++++++++++++++++++++++ tests/client_core.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/server_core.rs | 159 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 tests/blackjack.rs (limited to 'tests') 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 @@ +use zagan_bet::blackjack::{ + card_label, card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState, +}; +use zagan_bet::server::games::blackjack::BlackjackGame; + +fn hand(deck: &[&str], bet: u64) -> zagan_bet::server::games::blackjack::BlackjackHand { + let deck = deck.iter().map(|card| card.to_string()).collect(); + BlackjackGame::with_deck(deck).deal(bet) +} + +#[test] +fn card_values_and_soft_totals_are_correct() { + assert_eq!(card_value("AS"), 11); + assert_eq!(card_value("10H"), 10); + assert_eq!(card_value("KD"), 10); + assert_eq!(card_label("AS"), "A♠"); + assert_eq!(card_label("10H"), "10♥"); + + let cards = |values: &[&str]| values.iter().map(|card| card.to_string()).collect::>(); + assert_eq!(hand_total(&cards(&["AS", "10H"])), 21); + assert_eq!(hand_total(&cards(&["AS", "9H"])), 20); + assert_eq!(hand_total(&cards(&["AS", "AS", "9H"])), 21); + assert!(is_blackjack(&cards(&["AS", "KH"]))); + assert!(!is_blackjack(&cards(&["AS", "9H"]))); +} + +#[test] +fn a_six_deck_shoe_has_312_cards() { + let shoe = fresh_shoe(); + assert_eq!(shoe.len(), 312); + assert_eq!(shoe.iter().filter(|card| card.as_str() == "AS").count(), 6); +} + +#[test] +fn a_natural_blackjack_settles_immediately() { + let hand = hand(&["AS", "9D", "KH", "8C"], 10); + + assert!(hand.is_done()); + assert_eq!(hand.result, "blackjack"); + assert_eq!(hand.winnings, 25); // 10 + 15 + assert_eq!(hand.player_total(), 21); +} + +#[test] +fn a_player_hand_can_hit_to_21() { + let mut hand = hand(&["10S", "9D", "6H", "8C", "5D"], 10); + assert_eq!(hand.player_total(), 16); + assert_eq!(hand.dealer_total(), 17); + + hand.hit().unwrap(); + + assert!(hand.is_done()); + assert_eq!(hand.player_total(), 21); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn hitting_over_21_busts() { + let mut hand = hand(&["10S", "9D", "6H", "8C", "KD"], 10); + hand.hit().unwrap(); + + assert!(hand.is_done()); + assert_eq!(hand.result, "bust"); + assert_eq!(hand.winnings, 0); +} + +#[test] +fn doubling_adds_the_base_bet_and_draws_one_card() { + let mut hand = hand(&["5S", "9D", "5H", "8C", "10D"], 10); + assert_eq!(hand.player_total(), 10); + + hand.double().unwrap(); + + assert_eq!(hand.player.len(), 3); + assert_eq!(hand.bet, 20); + assert_eq!(hand.player_total(), 20); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 40); +} + +#[test] +fn the_dealer_busts_and_the_player_wins() { + let mut hand = hand(&["10S", "7H", "8H", "9C", "10H"], 10); + assert_eq!(hand.player_total(), 18); + assert_eq!(hand.dealer_total(), 16); + + hand.stand(); + + assert_eq!(hand.dealer_total(), 26); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn the_dealer_stands_on_soft_17() { + let mut hand = hand(&["10S", "AD", "8H", "6C"], 10); + assert_eq!(hand.dealer_total(), 17); + + hand.stand(); + + assert_eq!(hand.dealer.len(), 2, "dealer must stand on soft 17"); + assert_eq!(hand.result, "win"); + assert_eq!(hand.winnings, 20); +} + +#[test] +fn equal_totals_push() { + let mut hand = hand(&["10S", "10D", "8H", "8C"], 10); + hand.stand(); + + assert_eq!(hand.result, "push"); + assert_eq!(hand.winnings, 10); +} + +#[test] +fn dealer_blackjack_wins_unless_player_also_has_it() { + let dealer_blackjack = hand(&["10S", "AS", "8H", "KD"], 10); + assert_eq!(dealer_blackjack.result, "lose"); + assert_eq!(dealer_blackjack.winnings, 0); + + let both = hand(&["AS", "AD", "KH", "KC"], 10); + assert_eq!(both.result, "push"); + assert_eq!(both.winnings, 10); +} + +#[test] +fn the_player_state_hides_the_hole_card() { + let hand = hand(&["10S", "9D", "6H", "8C"], 10); + let state: BlackjackState = hand.state("session-1", 990); + + assert_eq!(state.phase, "player"); + assert_eq!(state.dealer, vec!["9D".to_string(), "?".to_string()]); + assert_eq!(state.dealer_total, 9); + assert_eq!(state.player_total, 16); + assert_eq!(state.actions, vec!["hit", "stand", "double"]); + assert_eq!(state.bet, 10); + assert_eq!(state.balance, 990); +} 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 @@ //! logical `Effect`s that `ClientCore` emits, so assertions are about what a //! player would see, not about ANSI sequences. +use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; -use zagan_bet::client::games::response::parse_spin_response; +use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; use zagan_bet::client::ui::command_names; const WIN3: &str = "CCCCLLCLODGSDGS"; @@ -139,6 +140,11 @@ impl Harness { self.line("play roulette"); self.reply("INFO alice 1 1000"); } + + fn enter_blackjack(&mut self) { + self.line("play blackjack"); + self.reply("INFO alice 1 1000"); + } } #[test] @@ -647,3 +653,196 @@ fn roulette_rejects_an_out_of_range_number() { assert!(h.board()[4].contains("ERROR: Number must be 0-36")); assert!(h.board()[2].contains("Bets: (none)")); } + +fn bj_state( + session: &str, + phase: &str, + player: &[&str], + dealer: &[&str], + player_total: u8, + dealer_total: u8, + bet: u64, + base_bet: u64, + balance: i64, + actions: &[&str], + result: &str, + winnings: u64, +) -> BlackjackState { + BlackjackState { + session_id: session.to_string(), + phase: phase.to_string(), + player: player.iter().map(|card| card.to_string()).collect(), + dealer: dealer.iter().map(|card| card.to_string()).collect(), + player_total, + dealer_total, + bet, + base_bet, + balance, + actions: actions.iter().map(|action| action.to_string()).collect(), + result: result.to_string(), + winnings, + } +} + +#[test] +fn blackjack_deals_hits_and_settles() { + let mut h = Harness::new(); + h.enter_blackjack(); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[4].contains("Press [SPACE] to deal.")); + + h.key(Key::Char(' ')); + assert!(h + .sent + .iter() + .any(|line| line == "PLAY_START blackjack 10")); + + let player = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand", "double"], + "playing", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&player).unwrap() + )); + + assert_eq!(h.board().len(), 6); + assert!(h.board()[2].contains("Dealer: [9♦] [ ? ] 9")); + assert!(h.board()[3].contains("You: [10♠] [6♥] 16")); + assert!(h.board()[4].contains("Hit, stand or double.")); + assert!(h.board()[5].contains("[D]ouble")); + + h.key(Key::Char('h')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("BLACKJACK_ACTION bj1 hit") + ); + + let done = bj_state( + "bj1", + "done", + &["10S", "6H", "5D"], + &["9D", "8C"], + 21, + 17, + 10, + 10, + 1010, + &[], + "win", + 20, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&done).unwrap() + )); + + assert!(h.board()[4].contains("Win! Won 20 PLN")); + assert!(h.board()[5].contains("[SPACE] New hand")); + + h.key(Key::Char('q')); + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); +} + +#[test] +fn blackjack_cancel_sends_a_cancel_action() { + let mut h = Harness::new(); + h.enter_blackjack(); + h.key(Key::Char(' ')); + + let player = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand", "double"], + "playing", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&player).unwrap() + )); + + h.key(Key::Char('q')); + assert_eq!( + h.sent.last().map(String::as_str), + Some("BLACKJACK_ACTION bj1 cancel") + ); + + let cancelled = bj_state( + "bj1", + "done", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 1000, + &[], + "cancelled", + 0, + ); + h.reply(&format!( + "BLACKJACK_STATE {}", + serde_json::to_string(&cancelled).unwrap() + )); + + assert!(h.board().is_empty()); + assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); +} + +#[test] +fn blackjack_ready_board_can_change_the_next_bet() { + let mut h = Harness::new(); + h.enter_blackjack(); + + h.key(Key::Char('=')); + assert!(h.board()[1].contains("Bet: 20 PLN")); + + h.key(Key::Char(' ')); + assert!(h + .sent + .iter() + .any(|line| line == "PLAY_START blackjack 20")); +} + +#[test] +fn parses_a_blackjack_state() { + let state = bj_state( + "bj1", + "player", + &["10S", "6H"], + &["9D", "?"], + 16, + 9, + 10, + 10, + 990, + &["hit", "stand"], + "playing", + 0, + ); + let response = format!("BLACKJACK_STATE {}", serde_json::to_string(&state).unwrap()); + + assert_eq!(parse_blackjack_response(&response).unwrap(), state); + assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); +} 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 @@ use std::path::PathBuf; use rusqlite::Connection; +use zagan_bet::blackjack::BlackjackState; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::registry::GameRegistry; @@ -47,6 +48,31 @@ fn setup_roulette(name: &str, number: u8) -> (ServerCore, String) { (ServerCore::with_registry(conn, registry, 4), path) } +fn setup_blackjack(name: &str, deck: &[&str]) -> (ServerCore, String) { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(format!("server-core-{name}.db")) + .to_string_lossy() + .into_owned(); + let _ = std::fs::remove_file(&path); + + let conn = Connection::open(&path).unwrap(); + database::init_db(&conn); + + let deck = deck.iter().map(|card| card.to_string()).collect(); + ( + ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4) + .with_blackjack_deck(deck), + path, + ) +} + +fn blackjack_state(response: &str) -> BlackjackState { + let raw = response + .strip_prefix("BLACKJACK_STATE ") + .expect("blackjack state response"); + serde_json::from_str(raw).expect("valid blackjack state") +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), @@ -337,3 +363,136 @@ fn unknown_games_are_rejected_before_a_session_is_created() { ); assert_eq!(core.session_count(), 0); } + +#[test] +fn blackjack_reserves_the_bet_and_settles_a_hit_hand() { + let (mut core, path) = setup_blackjack( + "blackjack-hit", + &["10S", "9D", "6H", "8C", "5D"], + ); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let response = core.handle_message(&mut state, "PLAY_START blackjack 10"); + let current = blackjack_state(&response); + + assert_eq!(current.phase, "player"); + assert_eq!(current.player_total, 16); + assert_eq!(current.dealer, vec!["9D".to_string(), "?".to_string()]); + assert_eq!(current.balance, 990); + assert_eq!(current.actions, vec!["hit", "stand", "double"]); + assert_eq!(core.session_count(), 1); + + let response = core.handle_message( + &mut state, + &format!("BLACKJACK_ACTION {} hit", current.session_id), + ); + let done = blackjack_state(&response); + + assert_eq!(done.phase, "done"); + assert_eq!(done.result, "win"); + assert_eq!(done.winnings, 20); + assert_eq!(done.balance, 1010); + assert_eq!(core.balance("alice"), Some(1010)); + assert_eq!(core.play_count("alice"), 1); + assert_eq!(core.session_count(), 0); + + let conn = Connection::open(&path).unwrap(); + let (game_type, details): (String, String) = conn + .query_row( + "SELECT game_type, details FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(game_type, "blackjack"); + let details: BlackjackState = serde_json::from_str(&details).unwrap(); + assert_eq!(details.result, "win"); + assert_eq!(details.winnings, 20); +} + +#[test] +fn blackjack_double_reserves_an_extra_bet() { + let (mut core, _path) = setup_blackjack( + "blackjack-double", + &["5S", "9D", "5H", "8C", "10D"], + ); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + assert_eq!(current.balance, 990); + + let done = blackjack_state(&core.handle_message( + &mut state, + &format!("BLACKJACK_ACTION {} double", current.session_id), + )); + + assert_eq!(done.phase, "done"); + assert_eq!(done.bet, 20); + assert_eq!(done.winnings, 40); + assert_eq!(done.balance, 1020); + assert_eq!(core.balance("alice"), Some(1020)); + assert_eq!(core.play_count("alice"), 1); +} + +#[test] +fn blackjack_natural_settles_immediately() { + let (mut core, _path) = + setup_blackjack("blackjack-natural", &["AS", "9D", "KH", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let done = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + + assert_eq!(done.phase, "done"); + assert_eq!(done.result, "blackjack"); + assert_eq!(done.winnings, 25); + assert_eq!(done.balance, 1015); + assert_eq!(core.session_count(), 0); + assert_eq!(core.play_count("alice"), 1); +} + +#[test] +fn blackjack_cancel_refunds_the_reserved_bet() { + let (mut core, _path) = + setup_blackjack("blackjack-cancel", &["10S", "9D", "6H", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + assert_eq!(current.balance, 990); + + let cancelled = blackjack_state(&core.handle_message( + &mut state, + &format!("BLACKJACK_ACTION {} cancel", current.session_id), + )); + + assert_eq!(cancelled.result, "cancelled"); + assert_eq!(cancelled.balance, 1000); + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.session_count(), 0); +} + +#[test] +fn blackjack_disconnect_refunds_the_reserved_bet() { + let (mut core, _path) = + setup_blackjack("blackjack-disconnect", &["10S", "9D", "6H", "8C"]); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let current = blackjack_state(&core.handle_message(&mut state, "PLAY_START blackjack 10")); + assert_eq!(current.balance, 990); + + core.disconnect(&state); + + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 0); + assert_eq!(core.session_count(), 0); +} -- cgit v1.2.3