From 0a72da768ea71eeba6a72c55c9162c6e295520d9 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Fri, 4 Jul 2025 14:15:56 -0700 Subject: [PATCH] initial commit - basic TUI blackjack game --- .gitignore | 1 + Cargo.lock | 173 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 ++ README.md | 3 + src/card.rs | 70 ++++++++++++++++++ src/game.rs | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hand.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/main.rs | 93 +++++++++++++++++++++++ 9 files changed, 738 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/card.rs create mode 100644 src/game.rs create mode 100644 src/hand.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f88cafa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,173 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "blackjack" +version = "0.1.0" +dependencies = [ + "itertools", + "rand", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..02b258d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "blackjack" +version = "0.1.0" +edition = "2024" + +[dependencies] +itertools = "0.14.0" +rand = "0.9.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9809786 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +Blackjack CLI tool and simulator + +I built this just for fun, and because I wanted to test some blackjack strategies diff --git a/src/card.rs b/src/card.rs new file mode 100644 index 0000000..4b9e215 --- /dev/null +++ b/src/card.rs @@ -0,0 +1,70 @@ +use std::fmt; + +#[derive(Clone)] +pub enum Suit { + Club, + Diamond, + Heart, + Spade, +} + +impl fmt::Display for Suit { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let c = match self { + Suit::Club => "♣", + Suit::Diamond => "♦", + Suit::Heart => "♥", + Suit::Spade => "♠", + }; + write!(f, "{c}") + } +} + +#[derive(Clone)] +pub struct Card { + pub suit: Suit, + pub value: String, +} + +impl Card { + pub fn new(suit: Suit, value: &str) -> Self { + Self { + suit, + value: value.to_owned(), + } + } +} + +impl From<&Card> for u8 { + fn from(card: &Card) -> u8 { + match card.value.as_ref() { + "A" => 11, + "K" => 10, + "Q" => 10, + "J" => 10, + x => x.parse().unwrap(), + } + } +} + +impl fmt::Display for Card { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}{}", self.suit, self.value) + } +} + +/// Create a new deck of cards, without jokers +pub(crate) fn deck_without_jokers() -> Vec { + let mut deck = Vec::new(); + + for suit in [Suit::Club, Suit::Diamond, Suit::Heart, Suit::Spade] { + for num_card in 2..=10 { + deck.push(Card::new(suit.clone(), &(num_card.to_string()))) + } + for face_card in ["J", "Q", "K", "A"] { + deck.push(Card::new(suit.clone(), face_card)) + } + } + + deck +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..4d3721b --- /dev/null +++ b/src/game.rs @@ -0,0 +1,208 @@ +use crate::card::{deck_without_jokers, Card}; +use crate::hand::Hand; +use rand::prelude::*; +use std::sync::{Arc, RwLock}; + +pub enum PlayerChoice { + Hit, + Stand, + // TODO + // DoubleDown, + // TODO + // Split, + // TODO + // Surrender, +} + +pub enum PlayResult { + Win, // Player didn't bust, and either has higher value or dealer busted + Blackjack, // Player won with a face card and ace + Lose, // Player busts or dealer had higher without busting + Push, // Dealer and player have same value + DealerBlackjack, // Player lost because dealer had blackjack + Bust, // Player lost because they busted +} + +/// The state at the end of a round, useful for printing out +pub struct EndState { + pub result: PlayResult, + pub player_winnings: u64, + pub dealer_cards: Vec, + pub player_cards: Vec, +} + +impl EndState { + fn new( + result: PlayResult, + player_winnings: u64, + dealer_cards: Vec, + player_cards: Vec, + ) -> Self { + Self { + result, + player_winnings, + dealer_cards, + player_cards, + } + } +} + +pub struct Game { + rng: rand::rngs::ThreadRng, + shoe: Vec, + /// Discard pile. When the shoe runs out, this gets mixed with the shoe and reshuffled. + discard: Arc>>, + hit_on_soft_17: bool, + + /// Returns when hitting blackjack + blackjack_returns: f32, +} + +impl Game { + pub fn new() -> Self { + Self { + // Game settings + hit_on_soft_17: false, + blackjack_returns: 3.0 / 2.0, + + // Game state + rng: rand::rng(), + shoe: Vec::new(), + discard: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Add some number of decks to the shoe, and shuffle it + pub fn with_decks(mut self, count: u8) -> Self { + for _ in 0..count { + self.shoe.append(&mut deck_without_jokers()) + } + self.shoe.shuffle(&mut self.rng); + + self + } + + /// Sets the dealer to hit on a soft 17 (one involving an Ace) + pub fn with_hit_on_soft_17(mut self) -> Self { + self.hit_on_soft_17 = true; + self + } + + /// Sets the return when getting a blackjack + pub fn with_blackjack_returns(mut self, returns: f32) -> Self { + self.blackjack_returns = returns; + self + } + + /// Play one round of Blackjack + /// + /// Takes a bet and a function, which is what the player will do during their turn. This + /// function must take the player's hand (Vec) and the card the dealer is showing (Card), + /// and returns a choice (hit, stand, etc.). + /// + /// Returns the winnings of the round + pub fn play(self: &mut Self, bet: u32, player_decision: F) -> EndState + where + F: Fn(&Hand, &Card) -> PlayerChoice, + { + // Shuffle the deck if we've played over 75% of cards + let discard_size = self.discard.read().unwrap().len(); + if self.shoe.len() < (self.shoe.len() + discard_size) * 1 / 4 { + self.shuffle(); + } + + // Deal cards + let mut dealer_hand = + Hand::from([self.deal(), self.deal()].to_vec()).with_discard(self.discard.clone()); + let mut player_hand = + Hand::from([self.deal(), self.deal()].to_vec()).with_discard(self.discard.clone()); + + // If dealer has blackjack, immediately lose. If player has blackjack, they win + if dealer_hand.is_blackjack() { + return EndState::new( + PlayResult::DealerBlackjack, + 0, + dealer_hand.cards(), + player_hand.cards(), + ); + } else if player_hand.is_blackjack() { + let returns = bet as f32 * self.blackjack_returns; + return EndState::new( + PlayResult::Blackjack, + returns as u64, + dealer_hand.cards(), + player_hand.cards(), + ); + } + + // Player turn + loop { + let decision = player_decision(&player_hand, &dealer_hand.cards()[0]); + + match decision { + PlayerChoice::Stand => break, + PlayerChoice::Hit => { + player_hand.push(self.deal()); + } + } + + // if player busts, immediately lose bet + if player_hand.value() > 21 { + return EndState::new( + PlayResult::Bust, + 0, + dealer_hand.cards(), + player_hand.cards(), + ); + } + } + + // Dealer turn + while dealer_hand.value() < 17 { + dealer_hand.push(self.deal()); + } + + if dealer_hand.value() > 21 { + EndState::new( + PlayResult::Win, + (bet * 2).into(), + dealer_hand.cards(), + player_hand.cards(), + ) + } else if dealer_hand.value() < player_hand.value() { + EndState::new( + PlayResult::Win, + (bet * 2).into(), + dealer_hand.cards(), + player_hand.cards(), + ) + } else if dealer_hand.value() == player_hand.value() { + EndState::new( + PlayResult::Push, + bet.into(), + dealer_hand.cards(), + player_hand.cards(), + ) + } else { + EndState::new( + PlayResult::Lose, + 0, + dealer_hand.cards(), + player_hand.cards(), + ) + } + } + + /// Deal a single card from the shoe + fn deal(self: &mut Self) -> Card { + self.shoe.pop().unwrap() + } + + /// Reset the shoe - combine shoe and discard and shuffle + fn shuffle(self: &mut Self) { + let mut discard = self.discard.write().unwrap(); + self.shoe.append(&mut discard); + assert_eq!(discard.len(), 0); + self.shoe.shuffle(&mut self.rng); + } +} diff --git a/src/hand.rs b/src/hand.rs new file mode 100644 index 0000000..b8596f0 --- /dev/null +++ b/src/hand.rs @@ -0,0 +1,179 @@ +use crate::card::Card; +use itertools::Itertools; +use std::fmt; +use std::sync::{Arc, RwLock}; + +pub struct Hand { + cards: Vec, + discard: Option>>>, +} + +impl Hand { + pub fn new() -> Self { + Self { + cards: Vec::new(), + discard: None, + } + } + + pub fn with_discard(mut self, discard: Arc>>) -> Self { + self.discard = Some(discard); + self + } + + /// Returns the value of the hand. If there are any aces, this is the highest value without + /// busting. + pub fn value(&self) -> u8 { + // TODO: Return the highest value without busting? And maybe let the player function + // determine if they have an ace and want to hit instead. + // Basic Strategy actually says what to do in the case of an ace + let mut num_aces = 0; + let mut sum = 0; + + // count up the value of everything that's not an ace + for card in &self.cards { + if card.value == "A" { + num_aces += 1; + } else { + sum += u8::from(card); + } + } + + if num_aces == 0 { + return sum; + } + + // Figure out the possible ace sums based on how many aces we have + let mut values: Vec = [1, 11] + .into_iter() + .combinations_with_replacement(num_aces) + .map(|a| a.iter().sum()) + .collect(); + + // Find the highest value without busting, adding in the rest of the cards. If they all + // bust, then take the lowest value. + values.sort(); + values.reverse(); + let mut total = 0; + for v in values.iter() { + total = v + sum; + if total <= 21 { + break; + } + } + + total + } + + /// Returns true if the hand is a blackjack, meaning we have an ace and a 10-valued card + pub fn is_blackjack(&self) -> bool { + self.cards.len() == 2 && self.value() == 21 + } + + /// Returns true if hand has an ace + pub fn has_ace(&self) -> bool { + for card in &self.cards { + if card.value == "A" { + return true; + } + } + false + } + + /// Returns a copy of the cards in the hand + pub fn cards(&self) -> Vec { + self.cards.clone() + } + + /// Add a card to the hand + pub fn push(&mut self, card: Card) { + self.cards.push(card) + } +} + +impl From> for Hand { + fn from(cards: Vec) -> Self { + Self { + cards, + discard: None, + } + } +} + +impl fmt::Display for Hand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for (i, c) in self.cards.iter().enumerate() { + if i != 0 { + write!(f, " ")?; + } + write!(f, "{c}")?; + } + Ok(()) + } +} + +impl Drop for Hand { + fn drop(&mut self) { + match &mut self.discard { + Some(x) => { + let mut discard = x.write().unwrap(); + discard.append(&mut self.cards) + } + None => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::card::{Card, Suit}; + + #[test] + fn no_aces() { + let mut h = Hand::new(); + h.cards.push(Card::new(Suit::Club, "K")); + h.cards.push(Card::new(Suit::Club, "7")); + assert_eq!(h.value(), 17); + } + + #[test] + fn one_ace() { + let mut h = Hand::new(); + h.cards.push(Card::new(Suit::Club, "K")); + h.cards.push(Card::new(Suit::Club, "A")); + assert_eq!(h.value(), 21); + } + + #[test] + fn three_aces() { + let mut h = Hand::new(); + h.cards.push(Card::new(Suit::Club, "3")); + h.cards.push(Card::new(Suit::Club, "A")); + h.cards.push(Card::new(Suit::Club, "A")); + h.cards.push(Card::new(Suit::Club, "A")); + assert_eq!(h.value(), 16); + } + + #[test] + /// If we give the hand a discard pile, then it should throw its cards in there when it gets + /// dropped + fn discard_pile() { + let discard = Arc::new(RwLock::new(Vec::new())); + + { + let hand = + Hand::from([Card::new(Suit::Heart, "1"), Card::new(Suit::Heart, "2")].to_vec()) + .with_discard(discard.clone()); + + assert_eq!(hand.value(), 3); + + // Hand still in scope, so discard pile is empty + assert_eq!(discard.read().unwrap().len(), 0); + } + + let d = discard.read().unwrap(); + // Hand went out of scope and got discarded + assert_eq!(d.len(), 2); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5633981 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod card; +pub mod game; +pub mod hand; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1e00aa0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,93 @@ +use blackjack::card::Card; +use blackjack::game::{Game, PlayResult, PlayerChoice}; +use blackjack::hand::Hand; +use std::io; +use std::io::prelude::*; + +fn main() { + interactive_play(); +} + +fn interactive_play() { + let mut bank: u64 = 1_000; + let mut game = Game::new().with_decks(6); + let mut last_bet: Option = None; + + loop { + println!("\nMoney in the bank: {bank}"); + + // Bet checking loop + let mut bet: u32; + loop { + if last_bet.is_some() { + print!("Your bet [{}]? ", last_bet.unwrap()); + io::stdout().flush().unwrap(); + let input = read_input(); + if input == "" { + bet = last_bet.unwrap(); + } else { + bet = input.parse().unwrap(); + } + } else { + print!("Your bet? "); + io::stdout().flush().unwrap(); + bet = read_input().parse().unwrap(); + } + if bet as u64 <= bank { + break; + } else { + println!("You don't have enough money!\n"); + } + } + + bank -= bet as u64; + last_bet = Some(bet); + println!(); + + let result = game.play(bet, interactive_decision); + let dealer_hand = Hand::from(result.dealer_cards); + let player_hand = Hand::from(result.player_cards); + println!("Dealer's hand: {} = {}", dealer_hand, dealer_hand.value()); + println!("Your hand: {} = {}", player_hand, player_hand.value()); + + match result.result { + PlayResult::Win => println!("You won!"), + PlayResult::Blackjack => println!("Blackjack!"), + PlayResult::Lose => println!("You lost"), + PlayResult::Push => println!("Push"), + PlayResult::DealerBlackjack => println!("Dealer got blackjack"), + PlayResult::Bust => println!("You busted"), + } + + bank += result.player_winnings; + } +} + +fn interactive_decision(hand: &Hand, dealer_showing: &Card) -> PlayerChoice { + println!("\nDealer showing: {dealer_showing}"); + println!("Your hand: {hand}\n"); + + if hand.value() == 21 { + println!("You have 21, standing."); + return PlayerChoice::Stand; + } + + let choice: PlayerChoice; + loop { + print!("(h)it or (s)tand? "); + io::stdout().flush().unwrap(); + choice = match read_input().to_lowercase().as_ref() { + "h" => PlayerChoice::Hit, + "s" => PlayerChoice::Stand, + _ => continue, + }; + break; + } + choice +} + +fn read_input() -> String { + let mut buf = String::new(); + io::stdin().read_line(&mut buf).unwrap(); + buf.trim().to_owned() +}