From dfdbf72188fb9be3dda8ba51b387be42996ffaf2 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sat, 5 Jul 2025 20:07:49 -0700 Subject: [PATCH 1/4] add card count --- src/game.rs | 19 ++++++++++++++++--- src/main.rs | 8 ++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/game.rs b/src/game.rs index bd6ecb9..b4995b3 100644 --- a/src/game.rs +++ b/src/game.rs @@ -52,6 +52,9 @@ pub struct Game { shoe: Vec, /// Discard pile. When the shoe runs out, this gets mixed with the shoe and reshuffled. discard: Arc>>, + /// The current card count, see: https://en.wikipedia.org/wiki/Blackjack#Card_counting + count: i16, + hit_on_soft_17: bool, /// Returns when hitting blackjack @@ -69,6 +72,7 @@ impl Game { rng: rand::rng(), shoe: Vec::new(), discard: Arc::new(RwLock::new(Vec::new())), + count: 0, } } @@ -108,7 +112,7 @@ impl Game { player_decision: F, ) -> EndState where - F: Fn(&Hand, &Card) -> PlayerChoice, + F: Fn(&Hand, &Card, i16) -> PlayerChoice, { // Shuffle the deck if we've played over 75% of cards let discard_size = self.discard.read().unwrap().len(); @@ -148,7 +152,7 @@ impl Game { // Player turn loop { - let decision = player_decision(&player_hand, &dealer_hand.cards()[0]); + let decision = player_decision(&player_hand, &dealer_hand.cards()[0], self.count); match decision { PlayerChoice::Stand => break, @@ -226,7 +230,15 @@ impl Game { /// Deal a single card from the shoe fn deal(self: &mut Self) -> Card { - self.shoe.pop().unwrap() + let card = self.shoe.pop().unwrap(); + + if u8::from(&card) < 6 { + self.count += 1; + } else if u8::from(&card) >= 10 { + self.count -= 1; + } + + card } /// Reset the shoe - combine shoe and discard and shuffle @@ -235,5 +247,6 @@ impl Game { self.shoe.append(&mut discard); assert_eq!(discard.len(), 0); self.shoe.shuffle(&mut self.rng); + self.count = 0; } } diff --git a/src/main.rs b/src/main.rs index 3aeb43b..6e9f777 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,6 @@ fn main() { } fn interactive_play() { - // TODO: Persist bank between plays // TODO: Make a way to reset bank // let term = Term::stdout(); @@ -93,12 +92,13 @@ fn interactive_play() { } } -fn interactive_decision(hand: &Hand, dealer_showing: &Card) -> PlayerChoice { +fn interactive_decision(hand: &Hand, dealer_showing: &Card, _count: i16) -> PlayerChoice { let term = Term::stdout(); term.clear_screen().unwrap(); println!("Dealer showing: {dealer_showing}"); - println!("Your hand: {hand}\n"); + println!("Your hand: {hand}"); + println!(); if hand.value() == 21 { println!("You have 21, standing."); @@ -127,7 +127,7 @@ fn interactive_decision(hand: &Hand, dealer_showing: &Card) -> PlayerChoice { choice } -fn basic_strategy(hand: &Hand, dealer_showing: &Card) -> PlayerChoice { +fn basic_strategy(hand: &Hand, dealer_showing: &Card, _count: i16) -> PlayerChoice { // Source: https://en.wikipedia.org/wiki/Blackjack#Basic_strategy let dv = u8::from(dealer_showing); let can_dd = hand.cards().len() == 2; From 6aeda0a22f5cb24070ac87577f8c424537ee837c Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 6 Jul 2025 12:36:48 -0700 Subject: [PATCH 2/4] CLI args to pick mode --- Cargo.lock | 220 +++++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + src/main.rs | 23 +++++- 3 files changed, 231 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c699fcc..1ff39ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + [[package]] name = "bitflags" version = "2.9.1" @@ -12,6 +62,7 @@ checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" name = "blackjack" version = "0.1.0" dependencies = [ + "clap", "console", "itertools", "rand", @@ -23,6 +74,52 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "clap" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "console" version = "0.16.0" @@ -33,7 +130,7 @@ dependencies = [ "libc", "once_cell", "unicode-width", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -60,6 +157,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" version = "0.14.0" @@ -81,6 +190,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -143,6 +258,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.104" @@ -166,6 +287,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" @@ -175,13 +302,38 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -190,58 +342,106 @@ version = "0.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.0" diff --git a/Cargo.toml b/Cargo.toml index d682a76..1744a41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.5.40", features = ["derive"] } console = "0.16.0" itertools = "0.14.0" rand = "0.9.1" diff --git a/src/main.rs b/src/main.rs index 6e9f777..7a1977d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use blackjack::card::Card; use blackjack::game::{Game, PlayResult, PlayerChoice}; use blackjack::hand::Hand; +use clap::{Parser, ValueEnum}; use console::Term; use std::env; use std::fs; @@ -10,9 +11,25 @@ use std::path::{Path, PathBuf}; fn main() { // TODO: Use anyhow for error handling, get rid of unwraps - // TODO: CLI flags to choose how to play - interactive_play(); - // old_man(); + let args = Args::parse(); + println!("{:?}", args); + match args.mode { + Mode::Interactive => interactive_play(), + Mode::OldMan => old_man(), + } +} + +#[derive(ValueEnum, Clone, Debug)] +enum Mode { + Interactive, + OldMan, +} + +#[derive(Parser, Debug)] +#[command(version, about)] +struct Args { + #[arg(long, default_value = "interactive")] + mode: Mode, } fn interactive_play() { From cb70077f5a1d65e93cb2e9a2d56331674caa14e0 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 6 Jul 2025 12:40:48 -0700 Subject: [PATCH 3/4] fix tests --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 7a1977d..309cd81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,7 +275,8 @@ mod tests { ] .to_vec() ), - &Card::new(Suit::Heart, "J") + &Card::new(Suit::Heart, "J"), + 0, ), PlayerChoice::Stand, ) From 69a4239f9016facb72405f3e7b17717588ddb8c3 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Wed, 9 Jul 2025 12:30:37 -0700 Subject: [PATCH 4/4] Refactor the game flow and support splits Behaves more like a client/server, rather than just taking a decision function. The CLI logic is more complex now, but the game is more flexible and and support splits (basically branching the hand off) --- Cargo.lock | 28 +++ Cargo.toml | 2 + src/card.rs | 27 ++- src/error.rs | 17 ++ src/game.rs | 607 ++++++++++++++++++++++++++++++++++++--------------- src/hand.rs | 73 ++----- src/lib.rs | 1 + src/main.rs | 399 +++++++++++++++++++++++---------- 8 files changed, 820 insertions(+), 334 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 1ff39ab..9107bf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + [[package]] name = "bitflags" version = "2.9.1" @@ -62,10 +68,12 @@ checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" name = "blackjack" version = "0.1.0" dependencies = [ + "anyhow", "clap", "console", "itertools", "rand", + "thiserror", ] [[package]] @@ -275,6 +283,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 1744a41..4b739aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.98" clap = { version = "4.5.40", features = ["derive"] } console = "0.16.0" itertools = "0.14.0" rand = "0.9.1" +thiserror = "2.0.12" diff --git a/src/card.rs b/src/card.rs index 4b9e215..3a7e858 100644 --- a/src/card.rs +++ b/src/card.rs @@ -1,7 +1,8 @@ use std::fmt; -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum Suit { + Unknown, Club, Diamond, Heart, @@ -15,12 +16,13 @@ impl fmt::Display for Suit { Suit::Diamond => "♦", Suit::Heart => "♥", Suit::Spade => "♠", + Suit::Unknown => "", }; write!(f, "{c}") } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Card { pub suit: Suit, pub value: String, @@ -35,6 +37,27 @@ impl Card { } } +impl From<(&str, &str)> for Card { + fn from(str_card: (&str, &str)) -> Self { + let suit = match str_card.0 { + "♣" => Suit::Club, + "♦" => Suit::Diamond, + "♥" => Suit::Heart, + "♠" => Suit::Spade, + "c" => Suit::Club, + "d" => Suit::Diamond, + "h" => Suit::Heart, + "s" => Suit::Spade, + _ => Suit::Unknown, + }; + + Self { + suit, + value: str_card.1.to_string(), + } + } +} + impl From<&Card> for u8 { fn from(card: &Card) -> u8 { match card.value.as_ref() { diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fabf18d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,17 @@ +use crate::game::Phase; +use crate::hand::Hand; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum BlackjackError { + #[error("Incorrect action for current game phase: {0}")] + IncorrectAction(Phase), + #[error("Cannot double down with this hand: {0}")] + Double(Hand), + #[error("Cannot split with this hand: {0}")] + Split(Hand), + #[error("This turn was already split")] + AlreadySplit, + #[error("Not enough chips on table to place bet")] + InsufficientChips, +} diff --git a/src/game.rs b/src/game.rs index b4995b3..a86cc9b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,19 +1,36 @@ use crate::card::{deck_without_jokers, Card}; +use crate::error::BlackjackError; use crate::hand::Hand; use rand::prelude::*; -use std::sync::{Arc, RwLock}; +use std::fmt; #[derive(Debug, PartialEq)] pub enum PlayerChoice { Hit, Stand, DoubleDown, - // TODO - // Split, + Split, // TODO // Surrender, } +/// Phase that the game is in. Used in verifying that actions are legal +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Phase { + New, + PlayerTurn, + DealerTurn, + Results, + Ended, +} + +impl fmt::Display for Phase { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +#[derive(Debug, PartialEq)] 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 @@ -23,67 +40,75 @@ pub enum PlayResult { Bust, // Player lost because they busted } +/// The game state while in the middle, which does not reveal the dealer's full hand +pub struct MidState { + pub shuffled: bool, + pub player_hand: Hand, + pub dealer_showing: Card, + pub count: i16, +} + /// The state at the end of a round, useful for printing out +#[derive(Debug)] pub struct EndState { pub result: PlayResult, - pub dealer_cards: Vec, - pub player_cards: Vec, - pub shuffled: bool, + pub dealer_hand: Hand, + pub player_hand: Hand, + pub returns: u32, } -impl EndState { - fn new( - result: PlayResult, - dealer_cards: Vec, - player_cards: Vec, - shuffled: bool, - ) -> Self { - Self { - result, - dealer_cards, - player_cards, - shuffled, - } - } -} - -pub struct Game { +pub struct Table { rng: rand::rngs::ThreadRng, shoe: Vec, /// Discard pile. When the shoe runs out, this gets mixed with the shoe and reshuffled. - discard: Arc>>, + discard: Vec, /// The current card count, see: https://en.wikipedia.org/wiki/Blackjack#Card_counting count: i16, + player_chips: u32, + + // Mid-game state + /// Phase of the current game + phase: Phase, + dealer_hand: Hand, + /// How many player hands need to be resolved + pending_turns: u8, + /// How many results need to be resolved + pending_results: u8, + + // Settings + /// If the dealer has a 17 with an Ace, they will hit. Gives advantage to dealer hit_on_soft_17: bool, /// Returns when hitting blackjack blackjack_returns: f32, } -impl Game { - pub fn new() -> Self { - Self { +impl Table { + pub fn new(decks: u8, player_chips: u32) -> Self { + let mut table = Self { // Game settings hit_on_soft_17: false, blackjack_returns: 3.0 / 2.0, // Game state rng: rand::rng(), + phase: Phase::New, shoe: Vec::new(), - discard: Arc::new(RwLock::new(Vec::new())), + discard: Vec::new(), + player_chips, + dealer_hand: Hand::new(), + pending_turns: 0, + pending_results: 0, count: 0, - } - } + }; - /// 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()) + for _ in 0..decks { + table.shoe.append(&mut deck_without_jokers()) } - self.shoe.shuffle(&mut self.rng); + table.shoe.shuffle(&mut table.rng); - self + table } /// Sets the dealer to hit on a soft 17 (one involving an Ace) @@ -98,138 +123,20 @@ impl Game { 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( - &mut self, - money_on_table: &mut u32, - bet_amount: u32, - player_decision: F, - ) -> EndState - where - F: Fn(&Hand, &Card, i16) -> PlayerChoice, - { - // Shuffle the deck if we've played over 75% of cards - let discard_size = self.discard.read().unwrap().len(); - let mut shuffled = false; - if self.shoe.len() < (self.shoe.len() + discard_size) * 1 / 4 { - self.shuffle(); - shuffled = true; - } + pub fn player_chips(&self) -> u32 { + self.player_chips + } - let mut bet_amount = bet_amount; - *money_on_table -= bet_amount; - - // 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, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ); - } else if player_hand.is_blackjack() { - let returns = bet_amount as f32 * self.blackjack_returns; - *money_on_table += returns as u32; - return EndState::new( - PlayResult::Blackjack, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ); - } - - // Player turn - loop { - let decision = player_decision(&player_hand, &dealer_hand.cards()[0], self.count); - - match decision { - PlayerChoice::Stand => break, - PlayerChoice::Hit => { - player_hand.push(self.deal()); - } - PlayerChoice::DoubleDown => { - if player_hand.cards().len() >= 3 { - // Can only double-down as first move - // TODO: Provide feedback that this move is invalid - continue; - } - if *money_on_table >= bet_amount { - *money_on_table -= bet_amount; - bet_amount *= 2; - } - player_hand.push(self.deal()); - // Player can only take one additional card - break; - } - } - - // if player busts, immediately lose bet - if player_hand.value() > 21 { - break; - } - } - if player_hand.value() > 21 { - return EndState::new( - PlayResult::Bust, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ); - } - - // Dealer turn - while dealer_hand.value() < 17 { - dealer_hand.push(self.deal()); - } - - if dealer_hand.value() > 21 { - *money_on_table += bet_amount * 2; - EndState::new( - PlayResult::Win, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ) - } else if dealer_hand.value() < player_hand.value() { - *money_on_table += bet_amount * 2; - EndState::new( - PlayResult::Win, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ) - } else if dealer_hand.value() == player_hand.value() { - *money_on_table += bet_amount; - EndState::new( - PlayResult::Push, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ) - } else { - EndState::new( - PlayResult::Lose, - dealer_hand.cards(), - player_hand.cards(), - shuffled, - ) - } + /// Reset the shoe - combine shoe and discard and shuffle + fn shuffle(self: &mut Self) { + self.shoe.append(&mut self.discard); + assert_eq!(self.discard.len(), 0); + self.shoe.shuffle(&mut self.rng); + self.count = 0; } /// Deal a single card from the shoe - fn deal(self: &mut Self) -> Card { + fn deal_card(&mut self) -> Card { let card = self.shoe.pop().unwrap(); if u8::from(&card) < 6 { @@ -241,12 +148,370 @@ impl Game { card } - /// 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); - self.count = 0; + /// Deal out a hand and start a game + pub fn deal_hand(&mut self, bet: u32) -> PlayerTurn { + self.dealer_hand = Hand::new(); + let mut player = PlayerTurn::new(bet); + + // Shuffle the deck if we've played over 75% of cards + let discard_size = self.discard.len(); + if self.shoe.len() < (self.shoe.len() + discard_size) * 1 / 4 { + self.shuffle(); + player.shuffled = true; + } + + // Sanity check to make sure we don't accidentally clone cards into the discard + let total_cards = self.shoe.len() + self.discard.len(); + if total_cards % 52 != 0 { + panic!("Wrong number of cards in shoe + discard: {total_cards}"); + } + + self.player_chips -= bet; + + // Deal cards in a circle, player(s) then dealer + for _ in 0..2 { + player.hand.push(self.deal_card()); + let dealer_card = self.deal_card(); + self.dealer_hand.push(dealer_card); + } + + self.phase = Phase::PlayerTurn; + self.pending_turns += 1; + self.pending_results += 1; + + player + } + + /// Returns the card that the dealer is showing + pub fn dealer_showing(&self) -> Card { + self.dealer_hand.cards()[0].clone() + } + + /// Run the dealer's turn and finish the game + pub fn dealers_turn(&mut self) -> Result<(), BlackjackError> { + if self.phase != Phase::DealerTurn { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + + while self.dealer_hand.value() < 17 { + let dealer_card = self.deal_card(); + self.dealer_hand.push(dealer_card); + } + self.phase = Phase::Results; + Ok(()) + } + + /// Get the results + pub fn results(&mut self, turn: PlayerTurn) -> Result { + if self.phase != Phase::Results { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + + let mut end_state = EndState { + result: PlayResult::Lose, + dealer_hand: self.dealer_hand.clone(), + player_hand: turn.hand.clone(), + returns: 0, + }; + if self.dealer_hand.is_blackjack() { + end_state.result = PlayResult::DealerBlackjack; + } else if turn.hand.is_blackjack() { + end_state.result = PlayResult::Blackjack; + let bj_winnings = (turn.bet as f32 * self.blackjack_returns) as u32; + end_state.returns = turn.bet + bj_winnings; + } else if turn.hand.value() > 21 { + end_state.result = PlayResult::Bust; + } else if self.dealer_hand.value() > 21 { + end_state.result = PlayResult::Win; + end_state.returns = turn.bet * 2; + } else if self.dealer_hand.value() < turn.hand.value() { + end_state.result = PlayResult::Win; + end_state.returns = turn.bet * 2; + } else if self.dealer_hand.value() == turn.hand.value() { + end_state.result = PlayResult::Push; + end_state.returns = turn.bet; + } + self.player_chips += end_state.returns; + self.pending_results -= 1; + + // Since we're turning the hand in here (taking ownership of the PlayerTurn), we can + // discard the cards now + self.discard_hand(turn.hand); + + if self.pending_results == 0 { + self.phase = Phase::Ended; + } + + Ok(end_state) + } + + pub fn discard_hand(&mut self, hand: Hand) { + self.discard.append(&mut hand.cards()); + } + + /// End the game and reset table state + pub fn end_game(&mut self) -> Result<(), BlackjackError> { + if self.phase != Phase::Ended { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + self.discard.append(&mut self.dealer_hand.cards()); + self.dealer_hand = Hand::new(); + self.phase = Phase::New; + Ok(()) + } + + // -- player functions -- + /// Stand, ending the player's turn + pub fn stand(&mut self, turn: &mut PlayerTurn) -> Result<(), BlackjackError> { + if self.phase != Phase::PlayerTurn { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + if turn.stood { + return Ok(()); + } + turn.stood = true; + self.pending_turns -= 1; + + if self.pending_turns == 0 { + self.phase = Phase::DealerTurn; + } + Ok(()) + } + + pub fn hit(&mut self, turn: &mut PlayerTurn) -> Result<(), BlackjackError> { + if self.phase != Phase::PlayerTurn { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + turn.hand.push(self.deal_card()); + Ok(()) + } + + /// Double the bet and take one additional card, standing afterward + pub fn double_down(&mut self, turn: &mut PlayerTurn) -> Result<(), BlackjackError> { + if self.phase != Phase::PlayerTurn { + return Err(BlackjackError::IncorrectAction(self.phase)); + } else if turn.hand.cards().len() != 2 { + return Err(BlackjackError::Double(turn.hand.clone())); + } else if self.player_chips < turn.bet { + return Err(BlackjackError::InsufficientChips); + } + + self.player_chips -= turn.bet; + turn.bet *= 2; + turn.hand.push(self.deal_card()); + self.stand(turn) + } + + /// Split the hand. Returns a second Round which was split off of this one + pub fn split(&mut self, turn: &mut PlayerTurn) -> Result { + if self.phase != Phase::PlayerTurn { + return Err(BlackjackError::IncorrectAction(self.phase)); + } + // Make sure the user isn't trying to split again, and that the hand is valid to split + if turn.was_split { + return Err(BlackjackError::AlreadySplit); + } else if !turn.hand.is_pair() { + return Err(BlackjackError::Split(turn.hand.clone())); + } else if self.player_chips < turn.bet { + return Err(BlackjackError::InsufficientChips); + } + + self.player_chips -= turn.bet; + + let mut other_hand = turn.hand.split(); + turn.hand.push(self.deal_card()); + other_hand.push(self.deal_card()); + + self.pending_turns += 1; + self.pending_results += 1; + + turn.was_split = true; + Ok(PlayerTurn { + hand: other_hand, + bet: turn.bet, + shuffled: turn.shuffled, + stood: false, + was_split: true, + }) + } +} + +/// Represents one round (or hand) of Blackjack. Becomes duplicated on a split. +pub struct PlayerTurn { + hand: Hand, + pub bet: u32, + pub stood: bool, + pub was_split: bool, + + /// Indicates if the deck was shuffled when dealing cards for this turn + pub shuffled: bool, +} + +impl PlayerTurn { + pub fn new(bet: u32) -> Self { + Self { + hand: Hand::new(), + bet, + shuffled: false, + stood: false, + was_split: false, + } + } + + /// Returns a read-only copy of the player's hand + pub fn player_hand(&self) -> Hand { + Hand::from(self.hand.cards()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Set up a game with the specified hands + fn setup_hands( + bet: u32, + dealer_cards: Vec<(&str, &str)>, + player_cards: Vec<(&str, &str)>, + ) -> (Table, PlayerTurn) { + let mut table = Table::new(6, bet); + table.pending_turns = 1; + table.pending_results = 1; + table.dealer_hand = Hand::from(dealer_cards); + table.phase = Phase::PlayerTurn; + + let mut turn = PlayerTurn::new(10); + turn.hand = Hand::from(player_cards); + + (table, turn) + } + + /// Play a game with the specified hands + fn play_hands( + bet: u32, + dealer_cards: Vec<(&str, &str)>, + player_cards: Vec<(&str, &str)>, + ) -> anyhow::Result { + let (mut table, mut turn) = setup_hands(bet, dealer_cards, player_cards); + table.stand(&mut turn)?; + table.dealers_turn()?; + + Ok(table.results(turn)?) + } + + #[test] + fn player_lose() { + let result = play_hands( + 10, + Vec::from([("♣", "9"), ("♣", "10")]), + Vec::from([("♣", "5"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::Lose); + assert_eq!(result.returns, 0); + } + + #[test] + fn player_win() { + let result = play_hands( + 10, + Vec::from([("♣", "5"), ("♣", "10")]), + Vec::from([("♣", "9"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::Win); + assert_eq!(result.returns, 20); + } + + #[test] + fn tie() { + let result = play_hands( + 10, + Vec::from([("♣", "9"), ("♣", "10")]), + Vec::from([("♣", "9"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::Push); + assert_eq!(result.returns, 10); + } + + #[test] + fn player_blackjack() { + let result = play_hands( + 10, + Vec::from([("♣", "5"), ("♣", "10")]), + Vec::from([("♣", "A"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::Blackjack); + assert_eq!(result.returns, 10 + 15); + } + + #[test] + fn player_blackjack_dealer_21() { + let result = play_hands( + 10, + Vec::from([("♣", "5"), ("♣", "10"), ("♣", "6")]), + Vec::from([("♣", "A"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::Blackjack); + assert_eq!(result.returns, 10 + 15); + } + + #[test] + fn dealer_blackjack() { + let result = play_hands( + 10, + Vec::from([("♣", "A"), ("♣", "10")]), + Vec::from([("♣", "9"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::DealerBlackjack); + assert_eq!(result.returns, 0); + } + + #[test] + fn both_blackjack() { + let result = play_hands( + 10, + Vec::from([("♣", "A"), ("♣", "10")]), + Vec::from([("♣", "A"), ("♣", "10")]), + ) + .unwrap(); + assert_eq!(result.result, PlayResult::DealerBlackjack); + assert_eq!(result.returns, 0); + } + + #[test] + /// A shuffle should leave us with the same number of cards + fn shuffle_same_cards() { + let mut table = Table::new(6, 0); + + // Initial state sanity check + let discard_size = table.discard.len(); + let pre = table.shoe.len() + discard_size; + println!("Pre shuffle: {}, {}", table.shoe.len(), discard_size); + + table.shuffle(); + + let discard_size = table.discard.len(); + let post = table.shoe.len() + table.discard.len(); + println!("Post shuffle: {}, {}", table.shoe.len(), discard_size); + assert_eq!(pre, post); + + // Deal out some cards and discard them, then shuffle to make sure we're good + let hand1 = Hand::from([table.deal_card(), table.deal_card()].to_vec()); + let hand2 = Hand::from([table.deal_card(), table.deal_card()].to_vec()); + table.discard_hand(hand1); + table.discard_hand(hand2); + assert_eq!(table.discard.len(), 4); + + table.shuffle(); + + let discard_size = table.discard.len(); + let post = table.shoe.len() + table.discard.len(); + println!("Post shuffle: {}, {}", table.shoe.len(), discard_size); + assert_eq!(pre, post); } } diff --git a/src/hand.rs b/src/hand.rs index b8596f0..b0ae954 100644 --- a/src/hand.rs +++ b/src/hand.rs @@ -1,32 +1,20 @@ use crate::card::Card; use itertools::Itertools; use std::fmt; -use std::sync::{Arc, RwLock}; +#[derive(Clone, Debug)] 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 + Self { cards: Vec::new() } } /// 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; @@ -80,7 +68,12 @@ impl Hand { false } - /// Returns a copy of the cards in the hand + /// Returns true if the hand has a pair (can be split) + pub fn is_pair(&self) -> bool { + self.cards.len() == 2 && u8::from(&self.cards[0]) == u8::from(&self.cards[1]) + } + + /// Returns a read-only copy of the cards in the hand pub fn cards(&self) -> Vec { self.cards.clone() } @@ -89,13 +82,25 @@ impl Hand { pub fn push(&mut self, card: Card) { self.cards.push(card) } + + /// Split the hand into two + pub fn split(&mut self) -> Hand { + let other_cards: Vec = Vec::from(self.cards.pop().as_slice()); + + Hand { cards: other_cards } + } } impl From> for Hand { fn from(cards: Vec) -> Self { + Self { cards } + } +} + +impl From> for Hand { + fn from(str_cards: Vec<(&str, &str)>) -> Self { Self { - cards, - discard: None, + cards: str_cards.into_iter().map(|c| (c).into()).collect(), } } } @@ -112,18 +117,6 @@ impl fmt::Display for Hand { } } -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::*; @@ -154,26 +147,4 @@ mod tests { 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 index 5633981..936edf8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod card; +pub mod error; pub mod game; pub mod hand; diff --git a/src/main.rs b/src/main.rs index 309cd81..e4aa70e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use blackjack::card::Card; -use blackjack::game::{Game, PlayResult, PlayerChoice}; +use blackjack::game::{EndState, PlayResult, PlayerChoice, PlayerTurn, Table}; use blackjack::hand::Hand; use clap::{Parser, ValueEnum}; use console::Term; @@ -7,12 +7,11 @@ use std::env; use std::fs; use std::io; use std::io::prelude::*; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -fn main() { +fn main() -> anyhow::Result<()> { // TODO: Use anyhow for error handling, get rid of unwraps let args = Args::parse(); - println!("{:?}", args); match args.mode { Mode::Interactive => interactive_play(), Mode::OldMan => old_man(), @@ -32,16 +31,15 @@ struct Args { mode: Mode, } -fn interactive_play() { +fn interactive_play() -> anyhow::Result<()> { // TODO: Make a way to reset bank - // let term = Term::stdout(); - let mut bank: u32 = match load_bank() { + let mut bank: u32 = match load_bank()? { Some(b) => b, None => 1_000, }; - let mut game = Game::new().with_decks(6).with_hit_on_soft_17(); + let mut table = Table::new(6, bank).with_hit_on_soft_17(); let mut last_bet: Option = None; loop { @@ -51,11 +49,12 @@ fn interactive_play() { let mut bet: u32; loop { if last_bet.is_some() { - print!("Your bet [{}]? ", last_bet.unwrap()); + let last_bet = last_bet.unwrap(); + print!("Your bet [{}]? ", last_bet); io::stdout().flush().unwrap(); - let input = term.read_line().unwrap(); + let input = term.read_line()?; if input == "" { - bet = last_bet.unwrap(); + bet = last_bet; } else { match input.parse() { Ok(b) => bet = b, @@ -64,8 +63,8 @@ fn interactive_play() { } } else { print!("Your bet? "); - io::stdout().flush().unwrap(); - match term.read_line().unwrap().parse() { + io::stdout().flush()?; + match term.read_line()?.parse() { Ok(b) => bet = b, Err(_) => continue, } @@ -80,118 +79,274 @@ fn interactive_play() { last_bet = Some(bet); println!(); - let result = game.play(&mut bank, bet, interactive_decision); - let dealer_hand = Hand::from(result.dealer_cards); - let player_hand = Hand::from(result.player_cards); - - term.clear_screen().unwrap(); - 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"), - } - if result.shuffled { - println!("Deck was shuffled at beginning of round"); + let mut turn = table.deal_hand(bet); + if turn.shuffled { + println!("Deck was shuffled"); } - save_bank(bank); + let split_turn = interactive_play_turn(&mut turn, &mut table)?; + if split_turn.is_none() { + table.dealers_turn()?; + let result = table.results(turn)?; + term.clear_screen()?; + print_result(&result); + } else { + let mut split_turn = split_turn.unwrap(); + interactive_play_turn(&mut split_turn, &mut table)?; + table.dealers_turn()?; + let result = table.results(turn)?; + let split_result = table.results(split_turn)?; + + term.clear_screen()?; + print_result(&result); + println!(); + print_result(&split_result); + } + + table.end_game()?; + bank = table.player_chips(); + save_bank(bank)?; if bank == 0 { println!("You're out of money."); - return; + break; } } + + Ok(()) } -fn interactive_decision(hand: &Hand, dealer_showing: &Card, _count: i16) -> PlayerChoice { +/// Play a turn. Returns another turn if this one was split +fn interactive_play_turn( + turn: &mut PlayerTurn, + table: &mut Table, +) -> anyhow::Result> { + let mut initial_play = true && !turn.was_split; + let mut other_turn = None; + let term = Term::stdout(); - term.clear_screen().unwrap(); - - println!("Dealer showing: {dealer_showing}"); - println!("Your hand: {hand}"); - println!(); - - if hand.value() == 21 { - println!("You have 21, standing."); - return PlayerChoice::Stand; - } - - let choice: PlayerChoice; - let can_dd = hand.cards().len() == 2; loop { + term.clear_screen()?; + let hand = turn.player_hand(); + + println!("Your bet: ${}", turn.bet); + println!("Dealer showing: {}", table.dealer_showing()); + println!("Your hand: {hand}"); + println!(); + + if hand.value() == 21 { + println!("You have 21, standing."); + table.stand(turn)?; + break; + } else if hand.value() > 21 { + println!("You busted"); + table.stand(turn)?; + break; + } + let mut msg = String::from("(h)it, (s)tand"); - if can_dd { + if initial_play { msg += ", (d)ouble-down"; + if hand.is_pair() { + msg += ", s(p)lit"; + } } msg += "? "; - io::stdout().write_all(msg.as_bytes()).unwrap(); - io::stdout().flush().unwrap(); - choice = match term.read_char().unwrap() { - 'h' => PlayerChoice::Hit, - 's' => PlayerChoice::Stand, - 'd' if can_dd => PlayerChoice::DoubleDown, - _ => continue, + io::stdout().write_all(msg.as_bytes())?; + io::stdout().flush()?; + + let mut stop = false; + match term.read_char()? { + 'h' => { + table.hit(turn)?; + initial_play = false; + } + 's' => { + table.stand(turn)?; + stop = true; + initial_play = false; + } + 'd' if initial_play => { + table.double_down(turn)?; + stop = true; + initial_play = false; + } + 'p' if initial_play && turn.player_hand().is_pair() => { + other_turn = Some(table.split(turn)?); + initial_play = false; + } + _ => {} }; - println!("\n"); - break; + if stop { + break; + } } - choice + + Ok(other_turn) } -fn basic_strategy(hand: &Hand, dealer_showing: &Card, _count: i16) -> PlayerChoice { +fn print_result(result: &EndState) { + println!( + "Dealer's hand: {} = {}", + result.dealer_hand, + result.dealer_hand.value() + ); + println!( + "Your hand: {} = {}", + result.player_hand, + result.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"), + } + println!("You got: ${}", result.returns); +} + +// TODO: Make this use a lookup table, this if logic is gnarly +fn basic_strategy( + hand: &Hand, + dealer_showing: &Card, + can_split: bool, + can_dd: bool, +) -> PlayerChoice { // Source: https://en.wikipedia.org/wiki/Blackjack#Basic_strategy let dv = u8::from(dealer_showing); - let can_dd = hand.cards().len() == 2; - if hand.has_ace() && hand.cards().len() == 2 { + + if hand.is_pair() && can_split { + // Got a pair, maybe should split + // We check can_split right at the get-go, because if we can't we just follow the Hard + // Total table. + let aces = hand.cards()[0].value == String::from("A"); + match hand.value() { + 12 if aces => PlayerChoice::Split, + 20 => PlayerChoice::Stand, + 18 => match dv { + 2..=6 => PlayerChoice::Split, + 7 => PlayerChoice::Stand, + _ => PlayerChoice::Split, + }, + 16 => PlayerChoice::Split, + 14 => match dv { + 2..=7 => PlayerChoice::Split, + _ => PlayerChoice::Hit, + }, + 12 => match dv { + 2..=6 => PlayerChoice::Split, + _ => PlayerChoice::Hit, + }, + 10 => match dv { + 2..=9 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + 8 => match dv { + 5..=6 => PlayerChoice::Split, + _ => PlayerChoice::Hit, + }, + 4..=6 => match dv { + 2..=7 => PlayerChoice::Split, + _ => PlayerChoice::Hit, + }, + _ => PlayerChoice::Stand, + } + } else if hand.has_ace() && hand.cards().len() == 2 { // Ace and some other card match hand.value() { - v if v >= 19 => PlayerChoice::Stand, - v if v == 18 && (7..=8).contains(&dv) => PlayerChoice::Stand, - v if v == 18 && dv < 7 && can_dd => PlayerChoice::DoubleDown, - v if v == 18 && dv < 7 => PlayerChoice::Stand, - v if v == 17 && (3..=6).contains(&dv) && can_dd => PlayerChoice::DoubleDown, - v if v == 17 && (3..=6).contains(&dv) => PlayerChoice::Hit, - v if (15..=16).contains(&v) && (4..=6).contains(&dv) && can_dd => { - PlayerChoice::DoubleDown - } - v if (15..=16).contains(&v) && (4..=6).contains(&dv) => PlayerChoice::Hit, - v if (13..=14).contains(&v) && (5..=6).contains(&dv) && can_dd => { - PlayerChoice::DoubleDown - } - v if (13..=14).contains(&v) && (5..=6).contains(&dv) => PlayerChoice::Hit, - v if v == 12 && dv == 6 && can_dd => PlayerChoice::DoubleDown, - v if v == 12 && dv == 6 => PlayerChoice::Hit, + 20 => PlayerChoice::Stand, + 19 if dv == 6 && can_dd => PlayerChoice::DoubleDown, + 19 => PlayerChoice::Stand, + 18 => match dv { + 2..=6 if can_dd => PlayerChoice::DoubleDown, + 9..=11 => PlayerChoice::Hit, + _ => PlayerChoice::Stand, + }, + 17 => match dv { + 3..=6 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + 15..=16 => match dv { + 4..=6 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + 13..=14 => match dv { + 5..=6 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + 12 if dv == 6 && can_dd => PlayerChoice::DoubleDown, _ => PlayerChoice::Hit, } } else { match hand.value() { - v if v >= 17 => PlayerChoice::Stand, - v if v > 12 && dv < 7 => PlayerChoice::Stand, - v if v == 12 && (4..=6).contains(&dv) => PlayerChoice::Stand, - v if v == 11 && can_dd => PlayerChoice::DoubleDown, - v if v == 11 => PlayerChoice::Hit, - v if v == 10 && dv < 10 && can_dd => PlayerChoice::DoubleDown, - v if v == 10 && dv < 10 => PlayerChoice::Hit, - v if v == 9 && (3..=6).contains(&dv) && can_dd => PlayerChoice::DoubleDown, - v if v == 9 && (3..=6).contains(&dv) => PlayerChoice::Hit, - _ => PlayerChoice::Hit, + 17..=21 => PlayerChoice::Stand, + 13..=16 if dv >= 7 => PlayerChoice::Hit, + 13..=16 => PlayerChoice::Stand, + 12 => match dv { + 4..=6 => PlayerChoice::Stand, + _ => PlayerChoice::Hit, + }, + 11 if can_dd => PlayerChoice::DoubleDown, + 11 => PlayerChoice::Hit, + 10 => match dv { + 2..=9 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + 9 => match dv { + 3..=6 if can_dd => PlayerChoice::DoubleDown, + _ => PlayerChoice::Hit, + }, + _ => PlayerChoice::Stand, } } } +fn basic_strategy_play_turn( + turn: &mut PlayerTurn, + table: &mut Table, +) -> anyhow::Result> { + let mut other_turn: Option = None; + loop { + // Enough chips to split or double down? + let enough_chips = turn.bet <= table.player_chips(); + + let can_split = !turn.was_split && enough_chips && turn.player_hand().is_pair(); + let can_dd = enough_chips && turn.player_hand().cards().len() == 2; + + match basic_strategy( + &turn.player_hand(), + &table.dealer_showing(), + can_split, + can_dd, + ) { + PlayerChoice::Hit => table.hit(turn)?, + PlayerChoice::Stand => { + table.stand(turn)?; + break; + } + PlayerChoice::DoubleDown => { + table.double_down(turn)?; + break; + } + PlayerChoice::Split => { + other_turn = Some(table.split(turn)?); + } + } + } + + Ok(other_turn) +} + /// An cab driver to the LAS airport was a former dealer and told me about this retired guy who /// would come play Blackjack every day at 7am like clockwork. He'd bring $400 to the table, play /// basic strategy, and would walk away once he was $100 up. He made about $3000/mo off of this, in /// addition to points/comps which he would use to eat brunch for free. /// /// Does this actually work, or was the driver full of it? -fn old_man() { +fn old_man() -> anyhow::Result<()> { const START: u32 = 100_000; const PER_DAY: u32 = 400; const MIN_BET: u32 = 15; @@ -199,7 +354,7 @@ fn old_man() { // Walk away when we're up by this much const MAX_WIN: u32 = 100; // Walk away when we're down by this much - const MAX_LOSS: u32 = 100; + const MAX_LOSS: u32 = MAX_WIN; // Run sim for this many days const DAYS: u16 = 30; @@ -208,53 +363,76 @@ fn old_man() { println!("Starting with bank: ${bank}"); // Let's simulate this for 30 days for day in 1..=DAYS { - let mut in_hand: u32 = PER_DAY; - bank -= in_hand; + bank -= PER_DAY; - let mut game = Game::new().with_decks(6).with_hit_on_soft_17(); + let mut table = Table::new(6, PER_DAY).with_hit_on_soft_17(); let mut rounds = 0; - while in_hand > MIN_BET && in_hand < (PER_DAY + MAX_WIN) && in_hand > (PER_DAY - MAX_LOSS) { - let bet = MIN_BET; - game.play(&mut in_hand, bet, basic_strategy); + while table.player_chips() > MIN_BET + && table.player_chips() < (PER_DAY + MAX_WIN) + && table.player_chips() > (PER_DAY - MAX_LOSS) + { + let mut turn = table.deal_hand(MIN_BET); + let split_turn = basic_strategy_play_turn(&mut turn, &mut table)?; + if split_turn.is_none() { + table.dealers_turn()?; + table.results(turn)?; + // No need to handle result since we take chips off table at end of day + } else { + let mut split_turn = split_turn.unwrap(); + basic_strategy_play_turn(&mut split_turn, &mut table)?; + table.dealers_turn()?; + // No need to handle result since we take chips off table at end of day + table.results(turn)?; + table.results(split_turn)?; + } + + table.end_game()?; rounds += 1; } - println!("Day {day}, after {rounds} rounds, in-hand: ${in_hand}"); - bank += in_hand; + bank += table.player_chips(); + println!( + "Day {day}, after {rounds} rounds, returns: ${}, bank: ${}", + table.player_chips() as i32 - PER_DAY as i32, + bank, + ); } let pl: i64 = bank as i64 - START as i64; println!("Ending with: ${bank}"); println!("Profit/Loss: ${pl}"); + + Ok(()) } fn data_dir() -> PathBuf { env::home_dir().unwrap().join(".local/share/blackjack") } -fn save_bank(bank: u32) { - ensure_data_dir(); +fn save_bank(bank: u32) -> anyhow::Result<()> { + ensure_data_dir()?; let bank_path = data_dir().join("bank.txt"); - fs::write(bank_path, bank.to_string()).unwrap(); + Ok(fs::write(bank_path, bank.to_string())?) } -fn load_bank() -> Option { +fn load_bank() -> anyhow::Result> { let bank_path = data_dir().join("bank.txt"); - if fs::exists(&bank_path).unwrap() { - let bank = fs::read_to_string(&bank_path).unwrap().parse().unwrap(); + if fs::exists(&bank_path)? { + let bank = fs::read_to_string(&bank_path)?.parse()?; if bank > 0 { - Some(bank) + Ok(Some(bank)) } else { - None + Ok(None) } } else { - None + Ok(None) } } -fn ensure_data_dir() { - if !fs::exists(data_dir()).unwrap() { - fs::create_dir_all(data_dir()).unwrap(); +fn ensure_data_dir() -> anyhow::Result<()> { + if !fs::exists(data_dir())? { + fs::create_dir_all(data_dir())?; } + Ok(()) } #[cfg(test)] @@ -276,7 +454,8 @@ mod tests { .to_vec() ), &Card::new(Suit::Heart, "J"), - 0, + true, + true, ), PlayerChoice::Stand, )