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)
This commit is contained in:
parent
cb70077f5a
commit
69a4239f90
8 changed files with 820 additions and 334 deletions
399
src/main.rs
399
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<u32> = 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<Option<PlayerTurn>> {
|
||||
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<Option<PlayerTurn>> {
|
||||
let mut other_turn: Option<PlayerTurn> = 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<u32> {
|
||||
fn load_bank() -> anyhow::Result<Option<u32>> {
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue