cli_blackjack/
cli_blackjack.rs

1//! CLI blackjack example.
2
3#![allow(clippy::missing_docs_in_private_items)]
4
5use std::io::{self, Write};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use bjrs::{Card, DoubleOption, Game, GameOptions, GameState, Hand, HandStatus, Suit};
9
10fn main() {
11    println!("Blackjack CLI example (type 'q' to quit)");
12
13    let seed = SystemTime::now()
14        .duration_since(UNIX_EPOCH)
15        .unwrap_or_default()
16        .as_secs();
17    let options = GameOptions::default();
18    let game = Game::new(options, seed);
19
20    let player_id = game.join(500);
21
22    loop {
23        let money = game.get_money(player_id).unwrap_or(0);
24        if money == 0 {
25            println!("You are out of money. Game over.");
26            break;
27        }
28
29        if game.check_and_reshuffle() == Ok(true) {
30            println!("Shoe reshuffled.");
31        }
32
33        game.start_betting();
34
35        let Some(bet) = prompt_usize(&format!("Bet amount (1-{money}, 0 to quit): ")) else {
36            break;
37        };
38
39        if bet == 0 {
40            println!("Goodbye.");
41            break;
42        }
43
44        if let Err(err) = game.bet(player_id, bet) {
45            println!("Bet error: {err:?}");
46            continue;
47        }
48
49        if let Err(err) = game.deal() {
50            println!("Deal error: {err:?}");
51            game.clear_round();
52            continue;
53        }
54
55        if game.is_insurance_offered() {
56            println!("Dealer shows an Ace. Insurance offered.");
57            match prompt_line("Take insurance? (y/n): ").as_str() {
58                "y" | "yes" => match game.take_insurance(player_id) {
59                    Ok(amount) => println!("Insurance bet placed: {amount}"),
60                    Err(err) => println!("Insurance error: {err:?}"),
61                },
62                _ => {
63                    if let Err(err) = game.decline_insurance(player_id) {
64                        println!("Insurance error: {err:?}");
65                    }
66                }
67            }
68
69            match game.finish_insurance() {
70                Ok(true) => {
71                    println!("Dealer has blackjack.");
72                }
73                Ok(false) => {}
74                Err(err) => {
75                    println!("Insurance finish error: {err:?}");
76                }
77            }
78        }
79
80        // If there is no active player turn (e.g., initial blackjack), move to dealer.
81        if *game.state.lock() == GameState::PlayerTurn && game.current_player().is_none() {
82            *game.state.lock() = GameState::DealerTurn;
83        }
84
85        while *game.state.lock() == GameState::PlayerTurn {
86            print_table(&game, player_id);
87
88            println!("{}", format_actions(&game, player_id));
89            let action = prompt_line("Action: ");
90            let turn = game.current_turn();
91
92            let result = match action.as_str() {
93                "h" | "hit" => game.hit(player_id, turn.hand_index).map(|_| ()),
94                "s" | "stand" => game.stand(player_id, turn.hand_index),
95                "d" | "double" => game.double_down(player_id, turn.hand_index).map(|_| ()),
96                "p" | "split" => game.split(player_id, turn.hand_index),
97                "u" | "surrender" => game.surrender(player_id, turn.hand_index).map(|_| ()),
98                "q" | "quit" => return,
99                _ => {
100                    println!("Unknown action.");
101                    continue;
102                }
103            };
104
105            if let Err(err) = result {
106                println!("Action error: {err:?}");
107            }
108        }
109
110        if *game.state.lock() == GameState::DealerTurn {
111            match game.dealer_play() {
112                Ok(drawn) => {
113                    if !drawn.is_empty() {
114                        println!("Dealer draws {} card(s).", drawn.len());
115                    }
116                }
117                Err(err) => println!("Dealer error: {err:?}"),
118            }
119        }
120
121        if *game.state.lock() == GameState::RoundOver {
122            match game.showdown() {
123                Ok(result) => {
124                    print_table_final(&game, player_id);
125                    println!("Round complete.");
126                    for player in result.players {
127                        if player.player_id == player_id {
128                            println!("Payout: {} (net {})", player.total_payout, player.net);
129                            if player.insurance_bet > 0 {
130                                println!("Insurance payout: {}", player.insurance_payout);
131                            }
132                        }
133                    }
134                }
135                Err(err) => println!("Showdown error: {err:?}"),
136            }
137        }
138
139        game.clear_round();
140
141        // Always continue to the next round without prompting.
142    }
143}
144
145fn prompt_line(prompt: &str) -> String {
146    print!("{prompt}");
147    let _ = io::stdout().flush();
148
149    let mut input = String::new();
150    if io::stdin().read_line(&mut input).is_err() {
151        return String::new();
152    }
153    input.trim().to_lowercase()
154}
155
156fn prompt_usize(prompt: &str) -> Option<usize> {
157    loop {
158        let input = prompt_line(prompt);
159        if input == "q" || input == "quit" {
160            return None;
161        }
162        match input.parse::<usize>() {
163            Ok(value) => return Some(value),
164            Err(_) => println!("Please enter a number."),
165        }
166    }
167}
168
169fn print_table(game: &Game, player_id: u8) {
170    let remaining = game.cards_remaining();
171    println!("\nShoe: {remaining} cards remaining");
172
173    let dealer = game.get_dealer_hand();
174    let dealer_view = format_dealer(&dealer);
175    let dealer_value = if dealer.is_hole_revealed() {
176        dealer.value()
177    } else {
178        dealer.visible_value()
179    };
180    println!("\nDealer: {dealer_view} (value {dealer_value})");
181
182    let hands = game.get_hands(player_id).unwrap_or_default();
183    let turn = game.current_turn();
184    for (index, hand) in hands.iter().enumerate() {
185        let marker = if index == turn.hand_index { "*" } else { " " };
186        println!(
187            "{} Hand {}: {} | value {} | bet {} | {:?}",
188            marker,
189            index,
190            format_hand(hand),
191            hand.value(),
192            hand.bet(),
193            hand.status()
194        );
195    }
196    println!();
197}
198
199fn print_table_final(game: &Game, player_id: u8) {
200    let remaining = game.cards_remaining();
201    println!("\nShoe: {remaining} cards remaining");
202
203    let dealer = game.get_dealer_hand();
204    println!(
205        "\nDealer: {} (value {})",
206        format_dealer(&dealer),
207        dealer.value()
208    );
209
210    let hands = game.get_hands(player_id).unwrap_or_default();
211    for (index, hand) in hands.iter().enumerate() {
212        println!(
213            "Hand {}: {} | value {} | bet {} | {:?}",
214            index,
215            format_hand(hand),
216            hand.value(),
217            hand.bet(),
218            hand.status()
219        );
220    }
221    println!();
222}
223
224fn format_actions(game: &Game, player_id: u8) -> String {
225    let availability = available_actions(game, player_id);
226    let mut parts = Vec::new();
227    parts.push(format_action("hit", "h", availability.hit));
228    parts.push(format_action("stand", "s", availability.stand));
229    parts.push(format_action("double", "d", availability.double));
230    parts.push(format_action("split", "p", availability.split));
231    parts.push(format_action("surrender", "u", availability.surrender));
232    format!("Actions: {}", parts.join(" "))
233}
234
235fn format_action(label: &str, key: &str, allowed: bool) -> String {
236    let text = format!("[{key}]{label}");
237    if allowed {
238        colorize(&text, "32")
239    } else {
240        colorize(&text, "90")
241    }
242}
243
244fn colorize(text: &str, code: &str) -> String {
245    format!("\u{1b}[{code}m{text}\u{1b}[0m")
246}
247
248struct ActionAvailability {
249    hit: bool,
250    stand: bool,
251    double: bool,
252    split: bool,
253    surrender: bool,
254}
255
256fn available_actions(game: &Game, player_id: u8) -> ActionAvailability {
257    if *game.state.lock() != GameState::PlayerTurn {
258        return ActionAvailability {
259            hit: false,
260            stand: false,
261            double: false,
262            split: false,
263            surrender: false,
264        };
265    }
266
267    if game.current_player() != Some(player_id) {
268        return ActionAvailability {
269            hit: false,
270            stand: false,
271            double: false,
272            split: false,
273            surrender: false,
274        };
275    }
276
277    let hands = game.get_hands(player_id).unwrap_or_default();
278    let turn = game.current_turn();
279    let Some(hand) = hands.get(turn.hand_index) else {
280        return ActionAvailability {
281            hit: false,
282            stand: false,
283            double: false,
284            split: false,
285            surrender: false,
286        };
287    };
288
289    if hand.status() != HandStatus::Active {
290        return ActionAvailability {
291            hit: false,
292            stand: false,
293            double: false,
294            split: false,
295            surrender: false,
296        };
297    }
298
299    let money = game.get_money(player_id).unwrap_or(0);
300    let bet = hand.bet();
301    let has_funds_for_double = money >= bet;
302    let has_funds_for_split = money >= bet;
303
304    let can_double_value = match game.options.double {
305        DoubleOption::Any => true,
306        DoubleOption::NineOrTen => hand.value() == 9 || hand.value() == 10,
307        DoubleOption::NineThrough11 => (9..=11).contains(&hand.value()),
308        DoubleOption::NineThrough15 => (9..=15).contains(&hand.value()),
309        DoubleOption::None => false,
310        _ => panic!("unhandled double option"),
311    };
312
313    let can_double = hand.len() == 2
314        && (!hand.is_from_split() || game.options.double_after_split)
315        && can_double_value
316        && has_funds_for_double;
317
318    let is_ace = hand.cards().first().is_some_and(|c| c.rank == 1);
319    let max_splits_reached = hands.len() > game.options.split as usize;
320    let can_split = hand.can_split()
321        && !max_splits_reached
322        && has_funds_for_split
323        && !(is_ace && hand.is_from_split() && game.options.split_aces_only_once);
324
325    let can_surrender = game.options.surrender && hand.len() == 2 && !hand.is_from_split();
326
327    ActionAvailability {
328        hit: true,
329        stand: true,
330        double: can_double,
331        split: can_split,
332        surrender: can_surrender,
333    }
334}
335
336fn format_dealer(dealer: &bjrs::DealerHand) -> String {
337    if dealer.cards().is_empty() {
338        return "(no cards)".to_string();
339    }
340
341    if dealer.is_hole_revealed() {
342        dealer
343            .cards()
344            .iter()
345            .map(format_card)
346            .collect::<Vec<_>>()
347            .join(" ")
348    } else {
349        let mut parts = Vec::new();
350        if let Some(card) = dealer.up_card() {
351            parts.push(format_card(card));
352        }
353        if dealer.len() > 1 {
354            parts.push("??".to_string());
355        }
356        parts.join(" ")
357    }
358}
359
360fn format_hand(hand: &Hand) -> String {
361    if hand.is_empty() {
362        return "(empty)".to_string();
363    }
364    hand.cards()
365        .iter()
366        .map(format_card)
367        .collect::<Vec<_>>()
368        .join(" ")
369}
370
371fn format_card(card: &Card) -> String {
372    let (suit, color_code) = match card.suit {
373        Suit::Hearts => ("H", "31"),
374        Suit::Diamonds => ("D", "31"),
375        Suit::Clubs => ("C", "32"),
376        Suit::Spades => ("S", "34"),
377    };
378
379    let (rank, is_face) = match card.rank {
380        1 => ("A".to_string(), true),
381        11 => ("J".to_string(), true),
382        12 => ("Q".to_string(), true),
383        13 => ("K".to_string(), true),
384        _ => (card.rank.to_string(), false),
385    };
386
387    let colored_rank = if is_face {
388        colorize(&rank, color_code)
389    } else {
390        rank
391    };
392    let colored_suit = colorize(suit, color_code);
393    format!("{colored_rank}{colored_suit}")
394}