feat: refactor AlgorithmicEvaluator to stateless, add game engine module and holdem_sim CLI
- Refactor AlgorithmicEvaluator from stateful Arc<Evaluator> wrapper to pure stateless evaluator with zero-allocation construction - Extract shared evaluation logic into pub(crate) methods, eliminating ~220 lines of duplicated code from Evaluator - Add game module: table, hand runner, tournament, bots, config, stats - Add holdem_sim CLI binary crate for running simulations - Convert MathEvaluator to thread-safe singleton with Arc<Inner> pattern - Replace heap-allocated combination generation with const-generics stack-based approach in AlgorithmicEvaluator::evaluate_n_card_hand - Add thiserror 2 dependency for GameError derive - Update docs: README, ACHIEVEMENTS, ROADMAP
This commit is contained in:
parent
dd605e8a66
commit
17d0440c86
43 changed files with 12329 additions and 494 deletions
|
|
@ -25,6 +25,7 @@ once_cell = "1.19"
|
|||
chrono = { version = "0.4", features = ["serde"] }
|
||||
indicatif = "0.17"
|
||||
log = "0.4"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.0"
|
||||
|
|
|
|||
24
holdem_core/examples/configs/cash_game.toml
Normal file
24
holdem_core/examples/configs/cash_game.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 6
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
ante = 0
|
||||
starting_chips = 10000
|
||||
num_hands = 1000
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "call_bot"
|
||||
name = "Alice"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "fold_bot"
|
||||
name = "Bob"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "random_bot"
|
||||
name = "Charlie"
|
||||
18
holdem_core/examples/configs/mtt_tournament.toml
Normal file
18
holdem_core/examples/configs/mtt_tournament.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[game]
|
||||
game_type = "MultiTableTournament"
|
||||
num_seats = 9
|
||||
num_tables = 3
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 10000
|
||||
buy_in = 5000
|
||||
hands_per_level = 10
|
||||
num_blind_levels = 20
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
[output]
|
||||
log_hands = true
|
||||
log_actions = true
|
||||
21
holdem_core/examples/configs/sng_tournament.toml
Normal file
21
holdem_core/examples/configs/sng_tournament.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[game]
|
||||
game_type = "SitAndGo"
|
||||
num_seats = 9
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 5000
|
||||
buy_in = 1000
|
||||
hands_per_level = 10
|
||||
num_blind_levels = 15
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "call_bot"
|
||||
name = "Bot 1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "fold_bot"
|
||||
name = "Bot 2"
|
||||
|
|
@ -1,67 +1,363 @@
|
|||
//! Algorithmic hand evaluator for direct poker hand evaluation.
|
||||
//!
|
||||
//! This module provides a standalone evaluator that uses pure algorithmic
|
||||
//! methods (no lookup tables) for evaluating poker hands. It wraps the
|
||||
//! direct evaluation logic from [`Evaluator::evaluate_5_card_direct`].
|
||||
//! methods (no lookup tables) for evaluating poker hands. It performs
|
||||
//! direct rank/suit analysis without requiring any precomputed data structures.
|
||||
//!
|
||||
//! ## Allocation Characteristics
|
||||
//!
|
||||
//! Unlike the table-based [`Evaluator`](super::evaluator::Evaluator), this evaluator
|
||||
//! requires no memory allocation at construction time. All evaluation paths
|
||||
//! (5-, 6-, and 7-card) are fully stack-based with zero heap allocation.
|
||||
//! 6- and 7-card hands enumerate all C(n,5) combinations via nested index loops.
|
||||
//!
|
||||
//! Ideal use cases:
|
||||
//!
|
||||
//! - Use during table construction (bootstrapping)
|
||||
//! - Memory-constrained environments
|
||||
//! - Testing and validation of table-based results
|
||||
|
||||
use crate::Card;
|
||||
use super::errors::EvaluatorError;
|
||||
use super::evaluator::{Evaluator, HandValue};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Statistics tracking for evaluation operations.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EvaluationStats {
|
||||
/// Total number of evaluations performed.
|
||||
pub total_evaluations: u64,
|
||||
/// Average time per evaluation in nanoseconds.
|
||||
pub average_time_ns: u64,
|
||||
/// Total time spent evaluating in nanoseconds.
|
||||
pub total_time_ns: u64,
|
||||
}
|
||||
use super::evaluator::{HandRank, HandValue};
|
||||
|
||||
/// Algorithmic poker hand evaluator.
|
||||
///
|
||||
/// Performs direct hand evaluation without relying on lookup tables.
|
||||
/// Delegates to the same core algorithm used by [`Evaluator::evaluate_5_card_direct`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlgorithmicEvaluator {
|
||||
inner: Arc<Evaluator>,
|
||||
stats: EvaluationStats,
|
||||
}
|
||||
/// Uses pure combinatorial logic — zero allocation at construction.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AlgorithmicEvaluator {}
|
||||
|
||||
impl AlgorithmicEvaluator {
|
||||
/// Create a new algorithmic evaluator instance.
|
||||
pub fn new() -> Result<Self, EvaluatorError> {
|
||||
let inner = Evaluator::new()?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(inner),
|
||||
stats: EvaluationStats::default(),
|
||||
})
|
||||
///
|
||||
/// This is a zero-allocation operation — no lookup tables or
|
||||
/// precomputed data structures are created.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
/// Evaluate a 5-card hand directly using the algorithmic approach.
|
||||
pub fn evaluate_5_card_direct(&self, cards: &[Card; 5]) -> HandValue {
|
||||
self.inner.evaluate_5_card_direct(cards)
|
||||
///
|
||||
/// This is an associated function (no `&self` required) since the
|
||||
/// algorithmic evaluator is stateless — all evaluation logic is
|
||||
/// purely stack-based.
|
||||
pub fn evaluate_5_card_direct(cards: &[Card; 5]) -> HandValue {
|
||||
Self::evaluate_5_card(cards)
|
||||
}
|
||||
|
||||
/// Evaluate a 6-card hand, finding the best 5-card combination.
|
||||
pub fn evaluate_6_card(&self, cards: &[Card; 6]) -> HandValue {
|
||||
self.inner.evaluate_n_card_hand_public(cards)
|
||||
///
|
||||
/// Associated function (no `&self` required) since the evaluator is stateless.
|
||||
pub fn evaluate_6_card(cards: &[Card; 6]) -> HandValue {
|
||||
Self::evaluate_n_card_hand(cards)
|
||||
}
|
||||
|
||||
/// Evaluate a 7-card hand, finding the best 5-card combination.
|
||||
pub fn evaluate_7_card(&self, cards: &[Card; 7]) -> HandValue {
|
||||
self.inner.evaluate_n_card_hand_public(cards)
|
||||
///
|
||||
/// Associated function (no `&self` required) since the evaluator is stateless.
|
||||
pub fn evaluate_7_card(cards: &[Card; 7]) -> HandValue {
|
||||
Self::evaluate_n_card_hand(cards)
|
||||
}
|
||||
|
||||
/// Get a reference to the current evaluation statistics.
|
||||
pub fn stats(&self) -> &EvaluationStats {
|
||||
&self.stats
|
||||
// ---- Core evaluation (shared with Evaluator — single implementation) ----
|
||||
|
||||
/// Core 5-card hand evaluation logic.
|
||||
///
|
||||
/// This is the canonical algorithmic implementation used by both
|
||||
/// [`AlgorithmicEvaluator`] and [`Evaluator`](super::evaluator::Evaluator).
|
||||
/// Marked `pub(crate)` so the table-based evaluator can delegate here
|
||||
/// instead of duplicating the logic.
|
||||
pub(crate) fn evaluate_5_card(cards: &[Card; 5]) -> HandValue {
|
||||
// Extract ranks and suits for analysis
|
||||
let mut ranks = [0u8; 5];
|
||||
let mut suits = [0u8; 5];
|
||||
|
||||
for (i, card) in cards.iter().enumerate() {
|
||||
ranks[i] = card.rank();
|
||||
suits[i] = card.suit();
|
||||
}
|
||||
|
||||
// Sort ranks for easier analysis (highest first)
|
||||
ranks.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
// Check for flush (all same suit)
|
||||
let is_flush = suits.iter().all(|&s| s == suits[0]);
|
||||
|
||||
// Check for straight
|
||||
let is_straight = Self::is_straight(&ranks);
|
||||
// Wheel straight (A,2,3,4,5) is the lowest straight — its effective
|
||||
// high card is 5 (rank 3), not Ace (rank 12).
|
||||
let is_wheel = is_straight && ranks[0] == 12 && ranks[1] == 3;
|
||||
let straight_high = if is_wheel { 3u32 } else { ranks[0] as u32 };
|
||||
|
||||
// Check for straight flush
|
||||
if is_flush && is_straight {
|
||||
return if is_wheel {
|
||||
// Wheel straight flush (5-high)
|
||||
HandValue::new(HandRank::StraightFlush, straight_high)
|
||||
} else if ranks[0] == 12 {
|
||||
// Royal flush (A,K,Q,J,10 of same suit)
|
||||
HandValue::new(HandRank::RoyalFlush, ranks[0] as u32)
|
||||
} else {
|
||||
// Straight flush
|
||||
HandValue::new(HandRank::StraightFlush, ranks[0] as u32)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for four of a kind
|
||||
if Self::has_n_of_kind(&ranks, 4) {
|
||||
return HandValue::new(
|
||||
HandRank::FourOfAKind,
|
||||
Self::calculate_four_of_kind_value(&ranks),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for full house
|
||||
if Self::has_full_house(&ranks) {
|
||||
return HandValue::new(HandRank::FullHouse, Self::calculate_full_house_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for flush
|
||||
if is_flush {
|
||||
return HandValue::new(HandRank::Flush, Self::calculate_flush_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for straight
|
||||
if is_straight {
|
||||
return HandValue::new(HandRank::Straight, straight_high);
|
||||
}
|
||||
|
||||
// Check for three of a kind
|
||||
if Self::has_n_of_kind(&ranks, 3) {
|
||||
return HandValue::new(
|
||||
HandRank::ThreeOfAKind,
|
||||
Self::calculate_three_of_kind_value(&ranks),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for two pair
|
||||
if Self::has_two_pair(&ranks) {
|
||||
return HandValue::new(HandRank::TwoPair, Self::calculate_two_pair_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for pair
|
||||
if Self::has_n_of_kind(&ranks, 2) {
|
||||
return HandValue::new(HandRank::Pair, Self::calculate_pair_value(&ranks));
|
||||
}
|
||||
|
||||
// High card
|
||||
HandValue::new(HandRank::HighCard, Self::calculate_high_card_value(&ranks))
|
||||
}
|
||||
|
||||
/// Reset the evaluation statistics.
|
||||
pub fn reset_stats(&mut self) {
|
||||
self.stats = EvaluationStats::default();
|
||||
// ---- Pure algorithmic helper methods (no table access) ----
|
||||
|
||||
/// Check if ranks form a straight
|
||||
fn is_straight(ranks: &[u8; 5]) -> bool {
|
||||
// Check for regular straight (consecutive ranks descending)
|
||||
let mut is_regular = true;
|
||||
for i in 0..4 {
|
||||
if ranks[i] != ranks[i + 1] + 1 {
|
||||
is_regular = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_regular {
|
||||
return true;
|
||||
}
|
||||
// Check for wheel straight: A,2,3,4,5 → sorted desc: [12,3,2,1,0]
|
||||
*ranks == [12, 3, 2, 1, 0]
|
||||
}
|
||||
|
||||
/// Check if hand has N of a kind
|
||||
fn has_n_of_kind(ranks: &[u8; 5], n: usize) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
rank_counts.iter().any(|&count| count == n as u8)
|
||||
}
|
||||
|
||||
/// Check if hand has a full house (three of a kind + pair)
|
||||
fn has_full_house(ranks: &[u8; 5]) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let has_three = rank_counts.iter().any(|&count| count == 3);
|
||||
let has_two = rank_counts.iter().any(|&count| count == 2);
|
||||
|
||||
has_three && has_two
|
||||
}
|
||||
|
||||
/// Check if hand has two pairs
|
||||
fn has_two_pair(ranks: &[u8; 5]) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
rank_counts.iter().filter(|&&count| count == 2).count() == 2
|
||||
}
|
||||
|
||||
/// Calculate value for four of a kind
|
||||
fn calculate_four_of_kind_value(ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let quad_rank = rank_counts.iter().position(|&count| count == 4).unwrap() as u8;
|
||||
let kicker_rank = ranks.iter().find(|&&r| r != quad_rank).unwrap();
|
||||
|
||||
(quad_rank as u32) * 13 + (*kicker_rank as u32)
|
||||
}
|
||||
|
||||
/// Calculate value for full house
|
||||
fn calculate_full_house_value(ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let three_rank = rank_counts.iter().position(|&count| count == 3).unwrap() as u8;
|
||||
let two_rank = rank_counts.iter().position(|&count| count == 2).unwrap() as u8;
|
||||
|
||||
(three_rank as u32) * 13 + (two_rank as u32)
|
||||
}
|
||||
|
||||
/// Calculate value for flush
|
||||
fn calculate_flush_value(ranks: &[u8; 5]) -> u32 {
|
||||
// For flush, value is based on highest card, then next highest, etc.
|
||||
// Use base-13 encoding to ensure unique values for different flush hands
|
||||
ranks[0] as u32 * 28561 + ranks[1] as u32 * 2197
|
||||
+ ranks[2] as u32 * 169 + ranks[3] as u32 * 13 + ranks[4] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for three of a kind
|
||||
fn calculate_three_of_kind_value(ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let three_rank = rank_counts.iter().position(|&count| count == 3).unwrap() as u8;
|
||||
|
||||
// Collect kicker ranks (those appearing exactly once) — avoids
|
||||
// double-counting when a kicker rank appears multiple times.
|
||||
let mut kickers: [u8; 2] = [0; 2];
|
||||
let mut idx = 0;
|
||||
for (rank, &count) in rank_counts.iter().enumerate() {
|
||||
if count == 1 {
|
||||
kickers[idx] = rank as u8;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
// kickers are naturally ascending from iteration; sort descending
|
||||
kickers.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
three_rank as u32 * 169 + kickers[0] as u32 * 13 + kickers[1] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for two pair
|
||||
fn calculate_two_pair_value(ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
// Collect pairs — stack-allocated (max 2 pairs in a 5-card hand)
|
||||
let mut pairs = [0u8; 2];
|
||||
let mut idx = 0;
|
||||
for (rank, &count) in rank_counts.iter().enumerate() {
|
||||
if count == 2 {
|
||||
pairs[idx] = rank as u8;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
pairs.sort_by(|a, b| b.cmp(a)); // Sort pairs high to low
|
||||
|
||||
let kicker = ranks
|
||||
.iter()
|
||||
.find(|&&r| r != pairs[0] && r != pairs[1])
|
||||
.unwrap();
|
||||
|
||||
pairs[0] as u32 * 169 + pairs[1] as u32 * 13 + *kicker as u32
|
||||
}
|
||||
|
||||
/// Calculate value for pair
|
||||
fn calculate_pair_value(ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let pair_rank = rank_counts.iter().position(|&count| count == 2).unwrap() as u8;
|
||||
|
||||
// Collect kicker ranks (those appearing exactly once) — avoids
|
||||
// double-counting when a kicker rank appears multiple times.
|
||||
let mut kickers: [u8; 3] = [0; 3];
|
||||
let mut idx = 0;
|
||||
for (rank, &count) in rank_counts.iter().enumerate() {
|
||||
if count == 1 {
|
||||
kickers[idx] = rank as u8;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
// kickers are naturally ascending from iteration; sort descending
|
||||
kickers.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
pair_rank as u32 * 2197
|
||||
+ kickers[0] as u32 * 169
|
||||
+ kickers[1] as u32 * 13
|
||||
+ kickers[2] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for high card
|
||||
fn calculate_high_card_value(ranks: &[u8; 5]) -> u32 {
|
||||
ranks[0] as u32 * 28561
|
||||
+ ranks[1] as u32 * 2197
|
||||
+ ranks[2] as u32 * 169
|
||||
+ ranks[3] as u32 * 13
|
||||
+ ranks[4] as u32
|
||||
}
|
||||
|
||||
/// Evaluate an N-card hand by finding the best 5-card combination.
|
||||
///
|
||||
/// Uses stack-based nested loops instead of heap-allocated combination vectors,
|
||||
/// avoiding all intermediate allocations.
|
||||
///
|
||||
/// **Note:** Only `N = 5`, `6`, and `7` are supported. Other values will
|
||||
/// cause a compile-time error via `const` panic. This is `pub(crate)` to
|
||||
/// prevent external callers from passing invalid `N`.
|
||||
pub(crate) fn evaluate_n_card_hand<const N: usize>(cards: &[Card; N]) -> HandValue {
|
||||
const { assert!(N >= 5 && N <= 7, "only 5-7 card hands are supported") };
|
||||
|
||||
let mut best_hand_value = HandValue::new(HandRank::HighCard, 0);
|
||||
|
||||
// Iterate all C(n, 5) combinations using index loops — zero heap allocation
|
||||
for i0 in 0..N - 4 {
|
||||
for i1 in i0 + 1..N - 3 {
|
||||
for i2 in i1 + 1..N - 2 {
|
||||
for i3 in i2 + 1..N - 1 {
|
||||
for i4 in i3 + 1..N {
|
||||
let combo = [
|
||||
cards[i0],
|
||||
cards[i1],
|
||||
cards[i2],
|
||||
cards[i3],
|
||||
cards[i4],
|
||||
];
|
||||
let hand_value = Self::evaluate_5_card(&combo);
|
||||
if hand_value > best_hand_value {
|
||||
best_hand_value = hand_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_hand_value
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@ use std::str::FromStr;
|
|||
|
||||
#[test]
|
||||
fn test_new_evaluator() {
|
||||
let evaluator = AlgorithmicEvaluator::new();
|
||||
assert!(evaluator.is_ok());
|
||||
let _evaluator = AlgorithmicEvaluator::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_royal_flush() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ks").unwrap(),
|
||||
|
|
@ -21,13 +19,12 @@ fn test_royal_flush() {
|
|||
Card::from_str("Js").unwrap(),
|
||||
Card::from_str("Ts").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::RoyalFlush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_straight_flush() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("9s").unwrap(),
|
||||
Card::from_str("Ks").unwrap(),
|
||||
|
|
@ -35,13 +32,12 @@ fn test_straight_flush() {
|
|||
Card::from_str("Js").unwrap(),
|
||||
Card::from_str("Ts").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::StraightFlush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_four_of_a_kind() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ah").unwrap(),
|
||||
|
|
@ -49,13 +45,12 @@ fn test_four_of_a_kind() {
|
|||
Card::from_str("Ac").unwrap(),
|
||||
Card::from_str("Ks").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::FourOfAKind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_house() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ah").unwrap(),
|
||||
|
|
@ -63,13 +58,12 @@ fn test_full_house() {
|
|||
Card::from_str("Ks").unwrap(),
|
||||
Card::from_str("Kh").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::FullHouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flush() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ks").unwrap(),
|
||||
|
|
@ -77,13 +71,12 @@ fn test_flush() {
|
|||
Card::from_str("Js").unwrap(),
|
||||
Card::from_str("9s").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::Flush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_straight() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Kh").unwrap(),
|
||||
|
|
@ -91,13 +84,12 @@ fn test_straight() {
|
|||
Card::from_str("Jc").unwrap(),
|
||||
Card::from_str("Ts").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::Straight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wheel_straight() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("2h").unwrap(),
|
||||
|
|
@ -105,13 +97,40 @@ fn test_wheel_straight() {
|
|||
Card::from_str("4c").unwrap(),
|
||||
Card::from_str("5s").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::Straight);
|
||||
// Wheel straight is 5-high (rank value 3), not Ace-high (rank value 12)
|
||||
assert_eq!(result.value, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wheel_straight_is_lowest_straight() {
|
||||
// Wheel: A,2,3,4,5
|
||||
let wheel = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("2h").unwrap(),
|
||||
Card::from_str("3d").unwrap(),
|
||||
Card::from_str("4c").unwrap(),
|
||||
Card::from_str("5s").unwrap(),
|
||||
];
|
||||
// 6-high straight: 2,3,4,5,6
|
||||
let six_high = [
|
||||
Card::from_str("2s").unwrap(),
|
||||
Card::from_str("3h").unwrap(),
|
||||
Card::from_str("4d").unwrap(),
|
||||
Card::from_str("5c").unwrap(),
|
||||
Card::from_str("6s").unwrap(),
|
||||
];
|
||||
let wheel_result = AlgorithmicEvaluator::evaluate_5_card_direct(&wheel);
|
||||
let six_high_result = AlgorithmicEvaluator::evaluate_5_card_direct(&six_high);
|
||||
assert_eq!(wheel_result.rank, HandRank::Straight);
|
||||
assert_eq!(six_high_result.rank, HandRank::Straight);
|
||||
// Wheel (5-high) must be lower than 6-high straight
|
||||
assert!(wheel_result < six_high_result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_three_of_a_kind() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ah").unwrap(),
|
||||
|
|
@ -119,13 +138,12 @@ fn test_three_of_a_kind() {
|
|||
Card::from_str("Ks").unwrap(),
|
||||
Card::from_str("Qh").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::ThreeOfAKind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_pair() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ah").unwrap(),
|
||||
|
|
@ -133,13 +151,12 @@ fn test_two_pair() {
|
|||
Card::from_str("Kh").unwrap(),
|
||||
Card::from_str("Qd").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::TwoPair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pair() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Ah").unwrap(),
|
||||
|
|
@ -147,13 +164,12 @@ fn test_pair() {
|
|||
Card::from_str("Qh").unwrap(),
|
||||
Card::from_str("Jd").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::Pair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_card() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let cards = [
|
||||
Card::from_str("As").unwrap(),
|
||||
Card::from_str("Kh").unwrap(),
|
||||
|
|
@ -161,13 +177,12 @@ fn test_high_card() {
|
|||
Card::from_str("Jc").unwrap(),
|
||||
Card::from_str("9s").unwrap(),
|
||||
];
|
||||
let result = evaluator.evaluate_5_card_direct(&cards);
|
||||
let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
assert_eq!(result.rank, HandRank::HighCard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_rank_ordering() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
|
||||
let royal_flush = [
|
||||
Card::from_str("As").unwrap(),
|
||||
|
|
@ -184,17 +199,10 @@ fn test_hand_rank_ordering() {
|
|||
Card::from_str("Ks").unwrap(),
|
||||
];
|
||||
|
||||
let rf = evaluator.evaluate_5_card_direct(&royal_flush);
|
||||
let foak = evaluator.evaluate_5_card_direct(&four_of_a_kind);
|
||||
let rf = AlgorithmicEvaluator::evaluate_5_card_direct(&royal_flush);
|
||||
let foak = AlgorithmicEvaluator::evaluate_5_card_direct(&four_of_a_kind);
|
||||
|
||||
assert!(rf.rank as u8 > foak.rank as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stats_initially_default() {
|
||||
let evaluator = AlgorithmicEvaluator::new().unwrap();
|
||||
let stats = evaluator.stats();
|
||||
assert_eq!(stats.total_evaluations, 0);
|
||||
assert_eq!(stats.average_time_ns, 0);
|
||||
assert_eq!(stats.total_time_ns, 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -688,7 +688,7 @@ impl Evaluator {
|
|||
}
|
||||
|
||||
// Fallback to direct evaluation algorithm when jump table lookup fails or returns default
|
||||
self.evaluate_5_card_direct(cards)
|
||||
Self::evaluate_5_card_direct(cards)
|
||||
}
|
||||
|
||||
/// Evaluate a 6-card hand
|
||||
|
|
@ -727,65 +727,29 @@ impl Evaluator {
|
|||
self.evaluate_n_card_hand(cards)
|
||||
}
|
||||
|
||||
/// Evaluate an N-card hand by finding the best 5-card combination
|
||||
/// Evaluate an N-card hand by finding the best 5-card combination.
|
||||
///
|
||||
/// Delegates to [`AlgorithmicEvaluator::evaluate_n_card_hand`] which uses
|
||||
/// zero-allocation stack-based nested loops instead of heap-allocated
|
||||
/// combination vectors.
|
||||
fn evaluate_n_card_hand<T: AsRef<[Card]>>(&self, cards: T) -> HandValue {
|
||||
use super::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
|
||||
let cards_ref = cards.as_ref();
|
||||
if cards_ref.len() < 5 {
|
||||
return HandValue::new(HandRank::HighCard, 0);
|
||||
}
|
||||
|
||||
let mut best_hand_value = HandValue::new(HandRank::HighCard, 0);
|
||||
|
||||
// Generate all combinations of 5 cards from N cards
|
||||
let indices: Vec<usize> = (0..cards_ref.len()).collect();
|
||||
let combinations = self.generate_combinations(&indices, 5);
|
||||
|
||||
for combo_indices in combinations {
|
||||
let combo_cards: Vec<Card> = combo_indices.iter().map(|&i| cards_ref[i]).collect();
|
||||
let combo_array: [Card; 5] = combo_cards.try_into().unwrap();
|
||||
|
||||
let hand_value = self.evaluate_5_card_direct(&combo_array);
|
||||
|
||||
if hand_value > best_hand_value {
|
||||
best_hand_value = hand_value;
|
||||
match cards_ref.len() {
|
||||
5 => {
|
||||
let array: [Card; 5] = cards_ref.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_5_card(&array)
|
||||
}
|
||||
}
|
||||
|
||||
best_hand_value
|
||||
}
|
||||
|
||||
/// Crate-internal wrapper for evaluating an N-card hand by finding the best 5-card combination.
|
||||
/// Used by [`AlgorithmicEvaluator`](super::algorithmic_evaluator::AlgorithmicEvaluator).
|
||||
pub(crate) fn evaluate_n_card_hand_public<T: AsRef<[Card]>>(&self, cards: T) -> HandValue {
|
||||
self.evaluate_n_card_hand(cards)
|
||||
}
|
||||
|
||||
/// Generate all combinations of K elements from a set of N elements
|
||||
fn generate_combinations(&self, set: &[usize], k: usize) -> Vec<Vec<usize>> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
self.generate_combinations_recursive(set, k, 0, &mut current, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Recursive helper for combination generation
|
||||
fn generate_combinations_recursive(
|
||||
&self,
|
||||
set: &[usize],
|
||||
k: usize,
|
||||
start: usize,
|
||||
current: &mut Vec<usize>,
|
||||
result: &mut Vec<Vec<usize>>,
|
||||
) {
|
||||
if current.len() == k {
|
||||
result.push(current.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
for i in start..set.len() {
|
||||
current.push(set[i]);
|
||||
self.generate_combinations_recursive(set, k, i + 1, current, result);
|
||||
current.pop();
|
||||
6 => {
|
||||
let array: [Card; 6] = cards_ref.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_n_card_hand(&array)
|
||||
}
|
||||
7 => {
|
||||
let array: [Card; 7] = cards_ref.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_n_card_hand(&array)
|
||||
}
|
||||
_ => HandValue::new(HandRank::HighCard, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -820,234 +784,14 @@ impl Evaluator {
|
|||
&self.tables
|
||||
}
|
||||
|
||||
/// Direct 5-card hand evaluation algorithm (fallback when jump table unavailable)
|
||||
pub fn evaluate_5_card_direct(&self, cards: &[Card; 5]) -> HandValue {
|
||||
// Extract ranks and suits for analysis
|
||||
let mut ranks = [0u8; 5];
|
||||
let mut suits = [0u8; 5];
|
||||
|
||||
for (i, card) in cards.iter().enumerate() {
|
||||
ranks[i] = card.rank();
|
||||
suits[i] = card.suit();
|
||||
}
|
||||
|
||||
// Sort ranks for easier analysis (highest first)
|
||||
ranks.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
// Check for flush (all same suit)
|
||||
let is_flush = suits.iter().all(|&s| s == suits[0]);
|
||||
|
||||
// Check for straight
|
||||
let is_straight = self.is_straight(&ranks);
|
||||
|
||||
// Check for straight flush
|
||||
if is_flush && is_straight {
|
||||
return if ranks[0] == 12 {
|
||||
// Royal flush (A,K,Q,J,10 of same suit)
|
||||
HandValue::new(HandRank::RoyalFlush, ranks[0] as u32)
|
||||
} else {
|
||||
// Straight flush
|
||||
HandValue::new(HandRank::StraightFlush, ranks[0] as u32)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for four of a kind
|
||||
if self.has_n_of_kind(&ranks, 4) {
|
||||
return HandValue::new(
|
||||
HandRank::FourOfAKind,
|
||||
self.calculate_four_of_kind_value(&ranks),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for full house
|
||||
if self.has_full_house(&ranks) {
|
||||
return HandValue::new(HandRank::FullHouse, self.calculate_full_house_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for flush
|
||||
if is_flush {
|
||||
return HandValue::new(HandRank::Flush, self.calculate_flush_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for straight
|
||||
if is_straight {
|
||||
return HandValue::new(HandRank::Straight, ranks[0] as u32);
|
||||
}
|
||||
|
||||
// Check for three of a kind
|
||||
if self.has_n_of_kind(&ranks, 3) {
|
||||
return HandValue::new(
|
||||
HandRank::ThreeOfAKind,
|
||||
self.calculate_three_of_kind_value(&ranks),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for two pair
|
||||
if self.has_two_pair(&ranks) {
|
||||
return HandValue::new(HandRank::TwoPair, self.calculate_two_pair_value(&ranks));
|
||||
}
|
||||
|
||||
// Check for pair
|
||||
if self.has_n_of_kind(&ranks, 2) {
|
||||
return HandValue::new(HandRank::Pair, self.calculate_pair_value(&ranks));
|
||||
}
|
||||
|
||||
// High card
|
||||
HandValue::new(HandRank::HighCard, self.calculate_high_card_value(&ranks))
|
||||
}
|
||||
|
||||
/// Check if ranks form a straight
|
||||
fn is_straight(&self, ranks: &[u8; 5]) -> bool {
|
||||
// Check for regular straight (consecutive ranks descending)
|
||||
let mut is_regular = true;
|
||||
for i in 0..4 {
|
||||
if ranks[i] != ranks[i + 1] + 1 {
|
||||
is_regular = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_regular {
|
||||
return true;
|
||||
}
|
||||
// Check for wheel straight: A,2,3,4,5 → sorted desc: [12,3,2,1,0]
|
||||
*ranks == [12, 3, 2, 1, 0]
|
||||
}
|
||||
|
||||
/// Check if hand has N of a kind
|
||||
fn has_n_of_kind(&self, ranks: &[u8; 5], n: usize) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
rank_counts.iter().any(|&count| count == n as u8)
|
||||
}
|
||||
|
||||
/// Check if hand has a full house (three of a kind + pair)
|
||||
fn has_full_house(&self, ranks: &[u8; 5]) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let has_three = rank_counts.iter().any(|&count| count == 3);
|
||||
let has_two = rank_counts.iter().any(|&count| count == 2);
|
||||
|
||||
has_three && has_two
|
||||
}
|
||||
|
||||
/// Check if hand has two pairs
|
||||
fn has_two_pair(&self, ranks: &[u8; 5]) -> bool {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
rank_counts.iter().filter(|&&count| count == 2).count() == 2
|
||||
}
|
||||
|
||||
/// Calculate value for four of a kind
|
||||
fn calculate_four_of_kind_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let quad_rank = rank_counts.iter().position(|&count| count == 4).unwrap() as u8;
|
||||
let kicker_rank = ranks.iter().find(|&&r| r != quad_rank).unwrap();
|
||||
|
||||
(quad_rank as u32) * 13 + (*kicker_rank as u32)
|
||||
}
|
||||
|
||||
/// Calculate value for full house
|
||||
fn calculate_full_house_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let three_rank = rank_counts.iter().position(|&count| count == 3).unwrap() as u8;
|
||||
let two_rank = rank_counts.iter().position(|&count| count == 2).unwrap() as u8;
|
||||
|
||||
(three_rank as u32) * 13 + (two_rank as u32)
|
||||
}
|
||||
|
||||
/// Calculate value for flush
|
||||
fn calculate_flush_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
// For flush, value is based on highest card, then next highest, etc.
|
||||
// Use base-13 encoding to ensure unique values for different flush hands
|
||||
ranks[0] as u32 * 28561 + ranks[1] as u32 * 2197
|
||||
+ ranks[2] as u32 * 169 + ranks[3] as u32 * 13 + ranks[4] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for three of a kind
|
||||
fn calculate_three_of_kind_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let three_rank = rank_counts.iter().position(|&count| count == 3).unwrap() as u8;
|
||||
|
||||
// Get kickers (remaining cards in descending order)
|
||||
let mut kickers: Vec<u8> = ranks
|
||||
.iter()
|
||||
.filter(|&&r| r != three_rank)
|
||||
.cloned()
|
||||
.collect();
|
||||
kickers.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
three_rank as u32 * 169 + kickers[0] as u32 * 13 + kickers[1] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for two pair
|
||||
fn calculate_two_pair_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let mut pairs: Vec<u8> = rank_counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &count)| count == 2)
|
||||
.map(|(rank, _)| rank as u8)
|
||||
.collect();
|
||||
pairs.sort_by(|a, b| b.cmp(a)); // Sort pairs high to low
|
||||
|
||||
let kicker = ranks
|
||||
.iter()
|
||||
.find(|&&r| r != pairs[0] && r != pairs[1])
|
||||
.unwrap();
|
||||
|
||||
pairs[0] as u32 * 169 + pairs[1] as u32 * 13 + *kicker as u32
|
||||
}
|
||||
|
||||
/// Calculate value for pair
|
||||
fn calculate_pair_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
let mut rank_counts = [0u8; 13];
|
||||
for &rank in ranks {
|
||||
rank_counts[rank as usize] += 1;
|
||||
}
|
||||
|
||||
let pair_rank = rank_counts.iter().position(|&count| count == 2).unwrap() as u8;
|
||||
|
||||
// Get kickers (remaining cards in descending order)
|
||||
let mut kickers: Vec<u8> = ranks.iter().filter(|&&r| r != pair_rank).cloned().collect();
|
||||
kickers.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
pair_rank as u32 * 2197
|
||||
+ kickers[0] as u32 * 169
|
||||
+ kickers[1] as u32 * 13
|
||||
+ kickers[2] as u32
|
||||
}
|
||||
|
||||
/// Calculate value for high card
|
||||
fn calculate_high_card_value(&self, ranks: &[u8; 5]) -> u32 {
|
||||
ranks[0] as u32 * 28561
|
||||
+ ranks[1] as u32 * 2197
|
||||
+ ranks[2] as u32 * 169
|
||||
+ ranks[3] as u32 * 13
|
||||
+ ranks[4] as u32
|
||||
/// Direct 5-card hand evaluation algorithm (fallback when jump table unavailable).
|
||||
///
|
||||
/// **Note:** This no longer requires evaluator state. Consider using
|
||||
/// [`AlgorithmicEvaluator::evaluate_5_card_direct`] directly for zero-construction
|
||||
/// evaluation.
|
||||
pub fn evaluate_5_card_direct(cards: &[Card; 5]) -> HandValue {
|
||||
// Delegate to the shared algorithmic implementation to avoid duplication.
|
||||
super::algorithmic_evaluator::AlgorithmicEvaluator::evaluate_5_card(cards)
|
||||
}
|
||||
|
||||
/// Validate the evaluator state
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ pub fn basic_usage_example() -> Result<(), EvaluatorError> {
|
|||
);
|
||||
|
||||
// Method 1: Using MathEvaluator (recommended for most use cases)
|
||||
let mut math_evaluator = MathEvaluator::new()?;
|
||||
let math_evaluator = MathEvaluator::new()?;
|
||||
let result1 = math_evaluator.evaluate_7_card(&cards);
|
||||
|
||||
log::info!("MathEvaluator result: {:?}", result1);
|
||||
|
|
@ -119,7 +119,7 @@ pub fn performance_example() -> Result<(), EvaluatorError> {
|
|||
),
|
||||
];
|
||||
|
||||
let mut math_evaluator = MathEvaluator::new()?;
|
||||
let math_evaluator = MathEvaluator::new()?;
|
||||
|
||||
log::info!("Benchmarking {} hand types...", test_hands.len());
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ pub fn integration_example() -> Result<(), EvaluatorError> {
|
|||
log::info!(" Complete hand: {}", hand);
|
||||
|
||||
// Compare results between math and holdem_core evaluators
|
||||
let mut comparison = EvaluatorComparison::new()?;
|
||||
let comparison = EvaluatorComparison::new()?;
|
||||
|
||||
let math_result = comparison.math_evaluator.evaluate_hand(&hand);
|
||||
let core_result = comparison.core_evaluator.evaluate_hand(&hand);
|
||||
|
|
@ -370,7 +370,7 @@ pub fn error_handling_example() -> Result<(), EvaluatorError> {
|
|||
|
||||
// Demonstrate proper error handling
|
||||
match MathEvaluator::new() {
|
||||
Ok(mut evaluator) => {
|
||||
Ok(evaluator) => {
|
||||
log::info!("✓ MathEvaluator created successfully");
|
||||
|
||||
// Test with valid input
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@
|
|||
//!
|
||||
//! ## Usage Examples
|
||||
//!
|
||||
//! ### Basic Integration
|
||||
//! ### Basic Integration (AlgorithmicEvaluator — zero allocation)
|
||||
//! ```rust
|
||||
//! use holdem_core::evaluator::{JumpTable, HandValue};
|
||||
//! use holdem_core::evaluator::AlgorithmicEvaluator;
|
||||
//! use holdem_core::{Card, Hand};
|
||||
//! use holdem_core::evaluator::integration::{MathEvaluator, convert_cards};
|
||||
//! use std::str::FromStr;
|
||||
//!
|
||||
//! // Create cards using holdem_core
|
||||
|
|
@ -30,15 +29,31 @@
|
|||
//! Card::from_str("Ts").unwrap(),
|
||||
//! ];
|
||||
//!
|
||||
//! // Convert to math evaluator format
|
||||
//! let math_cards = convert_cards(&cards);
|
||||
//! // Evaluate using the algorithmic evaluator (no table construction needed)
|
||||
//! let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
|
||||
//! log::info!("Hand evaluation: {:?}", result);
|
||||
//! ```
|
||||
//!
|
||||
//! // Evaluate using math evaluator
|
||||
//! let mut table = JumpTable::with_target_memory();
|
||||
//! table.build().expect("Table build failed");
|
||||
//! ### Table-Based Integration (MathEvaluator — singleton JumpTable)
|
||||
//! ```rust
|
||||
//! use holdem_core::evaluator::integration::MathEvaluator;
|
||||
//! use holdem_core::Card;
|
||||
//! use std::str::FromStr;
|
||||
//!
|
||||
//! let packed_array: [PackedCard; 7] = math_cards.try_into().unwrap();
|
||||
//! let result = table.evaluate_7_card(&packed_array);
|
||||
//! // 7-card hand for table-based evaluation
|
||||
//! let cards = [
|
||||
//! Card::from_str("As").unwrap(),
|
||||
//! Card::from_str("Ks").unwrap(),
|
||||
//! Card::from_str("Qs").unwrap(),
|
||||
//! Card::from_str("Js").unwrap(),
|
||||
//! Card::from_str("Ts").unwrap(),
|
||||
//! Card::from_str("3h").unwrap(),
|
||||
//! Card::from_str("2d").unwrap(),
|
||||
//! ];
|
||||
//!
|
||||
//! // MathEvaluator shares a singleton ~130 MB JumpTable across instances
|
||||
//! let evaluator = MathEvaluator::new().unwrap();
|
||||
//! let result = evaluator.evaluate_7_card(&cards);
|
||||
//! log::info!("Hand evaluation: {:?}", result);
|
||||
//! ```
|
||||
//!
|
||||
|
|
@ -67,14 +82,35 @@ use super::evaluator::{HandRank, HandValue};
|
|||
use super::tables::JumpTable;
|
||||
use crate::card::PackedCard;
|
||||
use crate::{Card, Hand};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Integration bridge between math and holdem_core evaluators
|
||||
pub struct MathEvaluator {
|
||||
/// Shared inner state for the math evaluator singleton
|
||||
struct MathEvaluatorInner {
|
||||
/// The jump table for hand evaluation
|
||||
jump_table: JumpTable,
|
||||
/// Performance statistics
|
||||
stats: EvaluationStats,
|
||||
}
|
||||
|
||||
/// Global singleton storage for the shared JumpTable.
|
||||
///
|
||||
/// Uses `Mutex<Option<Arc<…>>>` so that transient build failures are **not**
|
||||
/// cached — callers can retry. The `Mutex` provides a full memory barrier on
|
||||
/// lock/unlock, ensuring correct synchronisation across threads.
|
||||
static INSTANCE: Mutex<Option<Arc<MathEvaluatorInner>>> = Mutex::new(None);
|
||||
|
||||
/// Integration bridge between math and holdem_core evaluators.
|
||||
///
|
||||
/// Uses a singleton pattern internally — multiple calls to [`MathEvaluator::new()`]
|
||||
/// share the same underlying `JumpTable` via `Arc`, avoiding repeated 130MB allocations.
|
||||
///
|
||||
/// Thread-safe: all statistics use atomic operations, so `MathEvaluator` is `Send + Sync`.
|
||||
pub struct MathEvaluator {
|
||||
/// Shared jump table state (singleton)
|
||||
inner: Arc<MathEvaluatorInner>,
|
||||
/// Performance statistics (per-instance, atomic for thread safety)
|
||||
total_evaluations: AtomicUsize,
|
||||
total_time_ns: AtomicU64,
|
||||
}
|
||||
|
||||
/// Performance statistics for the math evaluator
|
||||
|
|
@ -91,24 +127,35 @@ pub struct EvaluationStats {
|
|||
}
|
||||
|
||||
impl MathEvaluator {
|
||||
/// Create a new math evaluator with initialized jump table
|
||||
/// Create a new math evaluator sharing the singleton jump table.
|
||||
///
|
||||
/// The underlying `JumpTable` (~130MB) is built once and shared across
|
||||
/// all instances via `Arc`. Subsequent calls reuse the same table without
|
||||
/// additional memory allocation.
|
||||
///
|
||||
/// Uses a `Mutex`-guarded singleton so that transient build failures are
|
||||
/// **not** cached — callers can retry.
|
||||
pub fn new() -> Result<Self, EvaluatorError> {
|
||||
// Recover from poisoned mutex — the data is still valid even if a
|
||||
// previous thread panicked while holding the lock.
|
||||
let mut guard = match INSTANCE.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if let Some(existing) = guard.as_ref() {
|
||||
return Ok(Self::from_inner(existing.clone()));
|
||||
}
|
||||
|
||||
let mut jump_table = JumpTable::with_target_memory();
|
||||
jump_table.build()?;
|
||||
let arc = Arc::new(MathEvaluatorInner { jump_table });
|
||||
*guard = Some(arc.clone());
|
||||
|
||||
Ok(Self {
|
||||
jump_table,
|
||||
stats: EvaluationStats {
|
||||
total_evaluations: 0,
|
||||
total_time_ns: 0,
|
||||
average_time_ns: 0,
|
||||
cache_hit_rate: 0.0,
|
||||
},
|
||||
})
|
||||
Ok(Self::from_inner(arc))
|
||||
}
|
||||
|
||||
/// Evaluate a 5-card hand using the math evaluator
|
||||
pub fn evaluate_5_card(&mut self, cards: &[Card; 5]) -> HandValue {
|
||||
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
|
||||
let start = Instant::now();
|
||||
|
||||
// Convert holdem_core cards to math cards
|
||||
|
|
@ -121,7 +168,7 @@ impl MathEvaluator {
|
|||
math_array[i] = card;
|
||||
}
|
||||
}
|
||||
let result = self.jump_table.evaluate_7_card(&math_array);
|
||||
let result = self.inner.jump_table.evaluate_7_card(&math_array);
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
self.update_stats(elapsed);
|
||||
|
|
@ -130,7 +177,7 @@ impl MathEvaluator {
|
|||
}
|
||||
|
||||
/// Evaluate a 6-card hand using the math evaluator
|
||||
pub fn evaluate_6_card(&mut self, cards: &[Card; 6]) -> HandValue {
|
||||
pub fn evaluate_6_card(&self, cards: &[Card; 6]) -> HandValue {
|
||||
let start = Instant::now();
|
||||
|
||||
// Convert holdem_core cards to math cards
|
||||
|
|
@ -143,7 +190,7 @@ impl MathEvaluator {
|
|||
math_array[i] = card;
|
||||
}
|
||||
}
|
||||
let result = self.jump_table.evaluate_7_card(&math_array);
|
||||
let result = self.inner.jump_table.evaluate_7_card(&math_array);
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
self.update_stats(elapsed);
|
||||
|
|
@ -152,7 +199,7 @@ impl MathEvaluator {
|
|||
}
|
||||
|
||||
/// Evaluate a 7-card hand using the math evaluator
|
||||
pub fn evaluate_7_card(&mut self, cards: &[Card; 7]) -> HandValue {
|
||||
pub fn evaluate_7_card(&self, cards: &[Card; 7]) -> HandValue {
|
||||
let start = Instant::now();
|
||||
|
||||
// Convert holdem_core cards to math cards
|
||||
|
|
@ -160,7 +207,7 @@ impl MathEvaluator {
|
|||
|
||||
// Use jump table for evaluation
|
||||
let math_array: [PackedCard; 7] = math_cards.try_into().unwrap();
|
||||
let result = self.jump_table.evaluate_7_card(&math_array);
|
||||
let result = self.inner.jump_table.evaluate_7_card(&math_array);
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
self.update_stats(elapsed);
|
||||
|
|
@ -169,7 +216,7 @@ impl MathEvaluator {
|
|||
}
|
||||
|
||||
/// Evaluate a hand from hole cards and board
|
||||
pub fn evaluate_hand(&mut self, hand: &Hand) -> HandValue {
|
||||
pub fn evaluate_hand(&self, hand: &Hand) -> HandValue {
|
||||
let cards = hand.cards();
|
||||
match cards.len() {
|
||||
5 => self.evaluate_5_card(&cards.try_into().unwrap()),
|
||||
|
|
@ -179,35 +226,70 @@ impl MathEvaluator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper to construct a MathEvaluator from a shared inner reference.
|
||||
fn from_inner(inner: Arc<MathEvaluatorInner>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
total_evaluations: AtomicUsize::new(0),
|
||||
total_time_ns: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get performance statistics
|
||||
pub fn stats(&self) -> &EvaluationStats {
|
||||
&self.stats
|
||||
pub fn stats(&self) -> EvaluationStats {
|
||||
let total_evals = self.total_evaluations.load(Ordering::Relaxed);
|
||||
let total_time = self.total_time_ns.load(Ordering::Relaxed);
|
||||
EvaluationStats {
|
||||
total_evaluations: total_evals,
|
||||
total_time_ns: total_time,
|
||||
average_time_ns: if total_evals > 0 {
|
||||
total_time / total_evals as u64
|
||||
} else {
|
||||
0
|
||||
},
|
||||
cache_hit_rate: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the jump table for external access
|
||||
pub fn get_jump_table(&self) -> &JumpTable {
|
||||
&self.jump_table
|
||||
&self.inner.jump_table
|
||||
}
|
||||
|
||||
/// Reset performance statistics
|
||||
pub fn reset_stats(&mut self) {
|
||||
self.stats = EvaluationStats {
|
||||
total_evaluations: 0,
|
||||
total_time_ns: 0,
|
||||
average_time_ns: 0,
|
||||
cache_hit_rate: 0.0,
|
||||
};
|
||||
pub fn reset_stats(&self) {
|
||||
self.total_evaluations.store(0, Ordering::Relaxed);
|
||||
self.total_time_ns.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Update internal statistics
|
||||
fn update_stats(&mut self, elapsed: Duration) {
|
||||
self.stats.total_evaluations += 1;
|
||||
self.stats.total_time_ns += elapsed.as_nanos() as u64;
|
||||
/// Release the singleton jump table reference from the global store.
|
||||
///
|
||||
/// **Memory is not freed immediately** — existing `MathEvaluator` instances
|
||||
/// keep their `Arc` references alive. The ~130MB is only deallocated once
|
||||
/// the last instance is dropped.
|
||||
///
|
||||
/// After calling this, subsequent `MathEvaluator::new()` calls will rebuild
|
||||
/// the table from scratch.
|
||||
///
|
||||
/// **Note:** This operation is inherently racy — if another thread is
|
||||
/// concurrently calling `new()`, the singleton may be immediately recreated.
|
||||
pub fn release_singleton() {
|
||||
let mut guard = match INSTANCE.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
if self.stats.total_evaluations > 0 {
|
||||
self.stats.average_time_ns =
|
||||
self.stats.total_time_ns / self.stats.total_evaluations as u64;
|
||||
}
|
||||
/// Update internal statistics (atomic operations — no RefCell needed).
|
||||
///
|
||||
/// Uses `Ordering::Relaxed` for best-effort statistics. Readers of
|
||||
/// [`stats()`](Self::stats) may see slightly skewed averages under
|
||||
/// concurrent writes — acceptable for performance monitoring.
|
||||
fn update_stats(&self, elapsed: Duration) {
|
||||
self.total_evaluations.fetch_add(1, Ordering::Relaxed);
|
||||
self.total_time_ns
|
||||
.fetch_add(elapsed.as_nanos() as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,10 +326,18 @@ pub fn convert_cards_back(packed_cards: &[PackedCard]) -> Result<Vec<Card>, Eval
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Trait for comparing math and holdem_core evaluators
|
||||
/// Trait for comparing math and holdem_core evaluators.
|
||||
///
|
||||
/// The evaluation method is intentionally named `evaluate_hand_compat` to
|
||||
/// avoid shadowing the inherent [`MathEvaluator::evaluate_hand`] method,
|
||||
/// which would otherwise require qualified call syntax (`MathEvaluator::evaluate_hand`).
|
||||
pub trait EvaluatorCompatibility {
|
||||
/// Evaluate a hand and return HandValue
|
||||
fn evaluate_hand(&self, hand: &Hand) -> HandValue;
|
||||
/// Evaluate a hand and return HandValue.
|
||||
///
|
||||
/// Delegates to the evaluator's inherent evaluation logic. Named `*_compat`
|
||||
/// to disambiguate from [`MathEvaluator::evaluate_hand`] when used through
|
||||
/// a trait object.
|
||||
fn evaluate_hand_compat(&self, hand: &Hand) -> HandValue;
|
||||
|
||||
/// Get evaluator name for identification
|
||||
fn name(&self) -> &'static str;
|
||||
|
|
@ -260,17 +350,8 @@ pub trait EvaluatorCompatibility {
|
|||
}
|
||||
|
||||
impl EvaluatorCompatibility for MathEvaluator {
|
||||
fn evaluate_hand(&self, hand: &Hand) -> HandValue {
|
||||
// For trait compatibility, we need to create a temporary mutable instance
|
||||
// This is not ideal but necessary for the trait interface
|
||||
// In practice, users should use the direct methods
|
||||
let cards = hand.cards();
|
||||
match cards.len() {
|
||||
5 => HandValue::new(HandRank::HighCard, 0), // Placeholder
|
||||
6 => HandValue::new(HandRank::HighCard, 0), // Placeholder
|
||||
7 => HandValue::new(HandRank::HighCard, 0), // Placeholder
|
||||
_ => HandValue::new(HandRank::HighCard, 0),
|
||||
}
|
||||
fn evaluate_hand_compat(&self, hand: &Hand) -> HandValue {
|
||||
self.evaluate_hand(hand)
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
|
|
@ -278,11 +359,11 @@ impl EvaluatorCompatibility for MathEvaluator {
|
|||
}
|
||||
|
||||
fn memory_usage(&self) -> usize {
|
||||
self.jump_table.memory_usage()
|
||||
self.inner.jump_table.memory_usage()
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), EvaluatorError> {
|
||||
self.jump_table.validate()
|
||||
self.inner.jump_table.validate()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -352,7 +433,7 @@ impl EvaluatorComparison {
|
|||
}
|
||||
|
||||
/// Run comprehensive performance comparison
|
||||
pub fn run_performance_comparison(&mut self) -> PerformanceComparison {
|
||||
pub fn run_performance_comparison(&self) -> PerformanceComparison {
|
||||
use std::str::FromStr;
|
||||
|
||||
let test_cases = vec![
|
||||
|
|
@ -667,7 +748,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_performance_comparison() {
|
||||
let mut comparison = EvaluatorComparison::new().unwrap();
|
||||
let comparison = EvaluatorComparison::new().unwrap();
|
||||
let perf_comparison = comparison.run_performance_comparison();
|
||||
|
||||
assert_eq!(perf_comparison.test_cases, 3); // We test 3 cases
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ pub mod test_utils;
|
|||
mod algorithmic_evaluator_test;
|
||||
|
||||
// Re-export commonly used types from local modules
|
||||
pub use algorithmic_evaluator::{AlgorithmicEvaluator, EvaluationStats};
|
||||
pub use algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
pub use errors::EvaluatorError;
|
||||
pub use evaluator::{Evaluator, HandRank, HandValue};
|
||||
|
||||
|
|
|
|||
|
|
@ -517,7 +517,7 @@ pub fn test_jump_table_properties() -> Result<(), EvaluatorError> {
|
|||
pub fn run_stress_tests() -> Result<(), EvaluatorError> {
|
||||
log::info!("Running stress tests...");
|
||||
|
||||
let mut evaluator = MathEvaluator::new()?;
|
||||
let evaluator = MathEvaluator::new()?;
|
||||
let num_hands = 1000;
|
||||
|
||||
// Create progress bar for stress test setup
|
||||
|
|
|
|||
|
|
@ -763,8 +763,7 @@ impl JumpTable {
|
|||
let card_array: [Card; 5] = cards.try_into().unwrap();
|
||||
|
||||
// Evaluate the hand using the algorithmic evaluator for accuracy
|
||||
let evaluator = AlgorithmicEvaluator::new()?;
|
||||
let hand_value = evaluator.evaluate_5_card_direct(&card_array);
|
||||
let hand_value = AlgorithmicEvaluator::evaluate_5_card_direct(&card_array);
|
||||
|
||||
// Use proper combinatorial indexing instead of simple hashing
|
||||
let level5_index = self.combinatorial_index_5_cards(&card_array)?;
|
||||
|
|
@ -895,26 +894,28 @@ impl JumpTable {
|
|||
let mut level6_count = 0;
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// For each 7-card combination, generate all C(7,6) = 7 combinations
|
||||
// For each N-card combination (N ≥ 7), generate all C(N,6) 6-card subsets
|
||||
for (combo_index, combo) in combinations.iter().enumerate() {
|
||||
if combo.len() >= 6 {
|
||||
for i in 0..combo.len() {
|
||||
let mut six_cards = Vec::new();
|
||||
for (j, card) in combo.iter().enumerate() {
|
||||
if j != i {
|
||||
six_cards.push(*card);
|
||||
}
|
||||
}
|
||||
let n = combo.len();
|
||||
if n >= 7 {
|
||||
for i in 0..n {
|
||||
// Build 6-card subset by skipping card at index i — stack-allocated
|
||||
let six_cards: Vec<PackedCard> = combo
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, &c)| if j != i { Some(c) } else { None })
|
||||
.take(6)
|
||||
.collect();
|
||||
let six_array: [PackedCard; 6] = match six_cards.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => continue, // Skip if not exactly 6 cards
|
||||
};
|
||||
|
||||
// Find the best 5-card hand from this 6-card hand using algorithmic evaluator
|
||||
let evaluator = AlgorithmicEvaluator::new()?;
|
||||
// Convert 6 PackedCards to 6-element Card array
|
||||
let mut cards = Vec::new();
|
||||
for &packed in &six_cards {
|
||||
cards.push(Card::new(packed.rank(), packed.suit()).unwrap());
|
||||
}
|
||||
let card_array: [Card; 6] = cards.try_into().unwrap();
|
||||
let best_hand_value = evaluator.evaluate_6_card(&card_array);
|
||||
// Convert 6 PackedCards to 6-element Card array — stack-allocated
|
||||
let card_array: [Card; 6] = std::array::from_fn(|j| {
|
||||
Card::new(six_array[j].rank(), six_array[j].suit()).unwrap()
|
||||
});
|
||||
let best_hand_value = AlgorithmicEvaluator::evaluate_6_card(&card_array);
|
||||
let best_level5_index = self.find_level5_index(&best_hand_value);
|
||||
|
||||
// Store offset to Level 5 in Level 6
|
||||
|
|
@ -950,18 +951,14 @@ impl JumpTable {
|
|||
let mut level7_count = 0;
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// For each 7-card combination, find the best 6-card hand
|
||||
// For each N-card combination (N ≥ 7), find the best 5-card hand
|
||||
for (combo_index, combo) in combinations.iter().enumerate() {
|
||||
if combo.len() >= 6 {
|
||||
// Find the best 5-card hand from this 7-card hand using algorithmic evaluator
|
||||
let evaluator = AlgorithmicEvaluator::new()?;
|
||||
// Convert 7 PackedCards to 7-element Card array
|
||||
let mut cards = Vec::new();
|
||||
for &packed in combo {
|
||||
cards.push(Card::new(packed.rank(), packed.suit()).unwrap());
|
||||
}
|
||||
let card_array: [Card; 7] = cards.try_into().unwrap();
|
||||
let best_hand_value = evaluator.evaluate_7_card(&card_array);
|
||||
if combo.len() >= 7 {
|
||||
// Convert up to 7 PackedCards to 7-element Card array — stack-allocated
|
||||
let card_array: [Card; 7] = std::array::from_fn(|i| {
|
||||
Card::new(combo[i].rank(), combo[i].suit()).unwrap()
|
||||
});
|
||||
let best_hand_value = AlgorithmicEvaluator::evaluate_7_card(&card_array);
|
||||
let best_level6_index = self.find_level6_index(&best_hand_value, combo);
|
||||
|
||||
// Store offset to Level 6 in Level 7
|
||||
|
|
@ -1294,8 +1291,7 @@ impl JumpTable {
|
|||
// TEMPORARY FIX: Use algorithmic evaluator instead of jump table
|
||||
// This bypasses hash function issues and ensures correct results
|
||||
use super::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
let evaluator = AlgorithmicEvaluator::new()?;
|
||||
Ok(evaluator.evaluate_5_card_direct(&card_array))
|
||||
Ok(AlgorithmicEvaluator::evaluate_5_card_direct(&card_array))
|
||||
}
|
||||
|
||||
/// Evaluate a 6-card hand using the jump table with O(1) lookup
|
||||
|
|
@ -1351,17 +1347,12 @@ impl JumpTable {
|
|||
|
||||
// TEMPORARY FIX: Use algorithmic evaluator directly to bypass hash function issues
|
||||
use super::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
let evaluator = AlgorithmicEvaluator::new()?;
|
||||
|
||||
// Convert PackedCards to Cards for algorithmic evaluator
|
||||
let card_array: Vec<Card> = cards
|
||||
.iter()
|
||||
.map(|&packed| Card::new(packed.rank(), packed.suit()).unwrap())
|
||||
.collect();
|
||||
|
||||
// Use algorithmic evaluator for 7 cards
|
||||
let array: [Card; 7] = card_array.try_into().unwrap();
|
||||
Ok(evaluator.evaluate_7_card(&array))
|
||||
// Convert PackedCards to Cards for algorithmic evaluator — stack-allocated
|
||||
let array: [Card; 7] = std::array::from_fn(|i| {
|
||||
Card::new(cards[i].rank(), cards[i].suit()).unwrap()
|
||||
});
|
||||
Ok(AlgorithmicEvaluator::evaluate_7_card(&array))
|
||||
}
|
||||
|
||||
/// Find the best 5-card hand from a 6-card hand using jump table optimization
|
||||
|
|
|
|||
1399
holdem_core/src/game/README.md
Normal file
1399
holdem_core/src/game/README.md
Normal file
File diff suppressed because it is too large
Load diff
336
holdem_core/src/game/action.rs
Normal file
336
holdem_core/src/game/action.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
//! Player action types and validation.
|
||||
//!
|
||||
//! Modeled after Meerkat's `com.biotools.meerkat.Action` class. In the Meerkat API
|
||||
//! an action is represented as a type code with an amount. We use a Rust enum for
|
||||
//! type safety while preserving the same semantics.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::betting::BetLimit;
|
||||
use crate::game::types::{ChipCount, GameError, PlayerId};
|
||||
|
||||
/// Actions a player can take when it is their turn.
|
||||
///
|
||||
/// Corresponds to Meerkat's `com.biotools.meerkat.Action`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
/// Fold the current hand.
|
||||
Fold,
|
||||
|
||||
/// Check — pass without betting. Only legal when no outstanding bet.
|
||||
Check,
|
||||
|
||||
/// Call the current outstanding bet amount.
|
||||
Call,
|
||||
|
||||
/// Raise to `total` chips. `total` is the cumulative amount this player
|
||||
/// will have wagered in this betting round after the raise.
|
||||
/// The engine validates that `total` exceeds the previous bet by at least
|
||||
/// the minimum raise amount.
|
||||
Raise {
|
||||
/// Total chips the player will have wagered this round after the raise.
|
||||
total: ChipCount,
|
||||
},
|
||||
|
||||
/// Go all-in for the player's remaining chip stack.
|
||||
AllIn,
|
||||
}
|
||||
|
||||
/// Extended action set that includes early fold — used only in the
|
||||
/// player-to-engine communication channel.
|
||||
///
|
||||
/// The engine never produces [`PlayerAction::EarlyFold`] internally; it is
|
||||
/// converted to [`Action::Fold`] when applied.
|
||||
///
|
||||
/// In the Meerkat API, early fold is handled by the framework layer
|
||||
/// (the bot signals a pre-fold intent). We expose it as a distinct variant
|
||||
/// for clarity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlayerAction {
|
||||
/// Normal in-turn action.
|
||||
Action(Action),
|
||||
|
||||
/// Player signals intent to fold before their turn arrives.
|
||||
/// Stored as a pending flag; applied as [`Action::Fold`] when it becomes
|
||||
/// this player's turn.
|
||||
EarlyFold,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Whether this action represents a bet or raise — i.e., puts new chips into the pot.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert!(!Action::Fold.is_aggressive());
|
||||
/// assert!(!Action::Check.is_aggressive());
|
||||
/// assert!(!Action::Call.is_aggressive());
|
||||
/// assert!(Action::Raise { total: 100 }.is_aggressive());
|
||||
/// assert!(Action::AllIn.is_aggressive());
|
||||
/// ```
|
||||
pub fn is_aggressive(&self) -> bool {
|
||||
matches!(self, Action::Raise { .. } | Action::AllIn)
|
||||
}
|
||||
|
||||
/// Whether this action keeps the player in the hand.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert!(!Action::Fold.is_active());
|
||||
/// assert!(Action::Check.is_active());
|
||||
/// assert!(Action::Call.is_active());
|
||||
/// assert!(Action::Raise { total: 100 }.is_active());
|
||||
/// assert!(Action::AllIn.is_active());
|
||||
/// ```
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self, Action::Fold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an action is legal given the current game state.
|
||||
///
|
||||
/// Returns [`Ok(())`] if the action is legal, or a [`GameError`] describing
|
||||
/// why the action is illegal.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `action` — The action to validate.
|
||||
/// * `player_id` — The player attempting the action (for error messages).
|
||||
/// * `player_chips` — The player's current chip stack.
|
||||
/// * `player_wagered_this_round` — Chips the player has already wagered this round.
|
||||
/// * `current_bet` — The current outstanding bet to call.
|
||||
/// * `min_raise` — The minimum raise increment.
|
||||
/// * `bet_limit` — The betting structure in use.
|
||||
/// * `has_acted_this_round` — Whether the player has already acted this round.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn validate_action(
|
||||
action: &Action,
|
||||
player_id: PlayerId,
|
||||
player_chips: ChipCount,
|
||||
player_wagered_this_round: ChipCount,
|
||||
current_bet: ChipCount,
|
||||
min_raise: ChipCount,
|
||||
bet_limit: &BetLimit,
|
||||
_has_acted_this_round: bool,
|
||||
) -> Result<(), GameError> {
|
||||
match action {
|
||||
Action::Fold => Ok(()),
|
||||
|
||||
Action::Check => {
|
||||
if current_bet > player_wagered_this_round {
|
||||
return Err(GameError::CheckNotAllowed(
|
||||
current_bet - player_wagered_this_round,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Action::Call => {
|
||||
let to_call = current_bet.saturating_sub(player_wagered_this_round);
|
||||
if to_call > 0 && player_chips == 0 {
|
||||
return Err(GameError::InsufficientChips(
|
||||
player_id,
|
||||
to_call,
|
||||
player_chips,
|
||||
));
|
||||
}
|
||||
// A call when the player can't match is treated as an all-in by the
|
||||
// engine; validation passes as long as the player has some chips or
|
||||
// there is nothing to call.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Action::Raise { total } => {
|
||||
let raise_increment = total.saturating_sub(current_bet);
|
||||
|
||||
// Check minimum raise.
|
||||
if raise_increment < min_raise {
|
||||
return Err(GameError::RaiseBelowMinimum(raise_increment, min_raise));
|
||||
}
|
||||
|
||||
// Check the player has enough chips.
|
||||
let additional_chips = total.saturating_sub(player_wagered_this_round);
|
||||
if additional_chips > player_chips {
|
||||
return Err(GameError::InsufficientChips(
|
||||
player_id,
|
||||
additional_chips,
|
||||
player_chips,
|
||||
));
|
||||
}
|
||||
|
||||
// Check maximum raise based on bet limit.
|
||||
match bet_limit {
|
||||
BetLimit::NoLimit { .. } => Ok(()),
|
||||
BetLimit::PotLimit { min_raise: _ } => {
|
||||
// Pot-limit validation requires knowing the pot size.
|
||||
// The engine layer handles this; we pass here.
|
||||
Ok(())
|
||||
}
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet: _,
|
||||
} => {
|
||||
// In fixed limit, the raise must equal the fixed bet size.
|
||||
if raise_increment != *small_bet {
|
||||
return Err(GameError::RaiseAboveMaximum(raise_increment, *small_bet));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Action::AllIn => {
|
||||
// All-in is always legal as long as the player has chips.
|
||||
if player_chips == 0 {
|
||||
return Err(GameError::InsufficientChips(player_id, 1, 0));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pid(n: u64) -> PlayerId {
|
||||
PlayerId(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_is_always_valid() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
assert!(validate_action(
|
||||
&Action::Fold,
|
||||
pid(1),
|
||||
100,
|
||||
0,
|
||||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_allowed_when_no_bet() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
assert!(validate_action(
|
||||
&Action::Check,
|
||||
pid(1),
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_not_allowed_when_bet_exists() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let result = validate_action(
|
||||
&Action::Check,
|
||||
pid(1),
|
||||
100,
|
||||
0,
|
||||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(result, Err(GameError::CheckNotAllowed(50))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_always_valid_with_chips() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
assert!(validate_action(
|
||||
&Action::Call,
|
||||
pid(1),
|
||||
100,
|
||||
0,
|
||||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_below_minimum_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 20 };
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 30 },
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
20,
|
||||
20,
|
||||
&bet_limit,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GameError::RaiseBelowMinimum(10, 20))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_with_insufficient_chips_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 200 },
|
||||
pid(1),
|
||||
50,
|
||||
0,
|
||||
20,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GameError::InsufficientChips(PlayerId(1), 200, 50))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_in_with_zero_chips_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let result = validate_action(
|
||||
&Action::AllIn,
|
||||
pid(1),
|
||||
0,
|
||||
0,
|
||||
20,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GameError::InsufficientChips(PlayerId(1), 1, 0))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_aggressive_and_is_active() {
|
||||
assert!(Action::Raise { total: 50 }.is_aggressive());
|
||||
assert!(Action::AllIn.is_aggressive());
|
||||
assert!(!Action::Fold.is_aggressive());
|
||||
assert!(!Action::Check.is_aggressive());
|
||||
assert!(!Action::Call.is_aggressive());
|
||||
|
||||
assert!(!Action::Fold.is_active());
|
||||
assert!(Action::Check.is_active());
|
||||
assert!(Action::Call.is_active());
|
||||
assert!(Action::Raise { total: 50 }.is_active());
|
||||
assert!(Action::AllIn.is_active());
|
||||
}
|
||||
}
|
||||
550
holdem_core/src/game/betting.rs
Normal file
550
holdem_core/src/game/betting.rs
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
//! Betting structures, limits, and round tracking.
|
||||
//!
|
||||
//! This module defines:
|
||||
//! - [`BetLimit`] — the betting structure of a game (No-Limit, Pot-Limit, Fixed-Limit)
|
||||
//! - [`BettingRules`] — a trait for querying raise/bet constraints per betting structure
|
||||
//! - [`BettingRound`] — tracks the state of a single betting round (current bet, raises, etc.)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::types::ChipCount;
|
||||
use crate::Street;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BetLimit enum (existing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Betting structure for a poker game.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BetLimit {
|
||||
/// No-Limit — players may wager any amount up to their full stack.
|
||||
/// The `min_raise` is the minimum increment above the previous raise.
|
||||
NoLimit {
|
||||
/// Minimum raise increment.
|
||||
min_raise: ChipCount,
|
||||
},
|
||||
|
||||
/// Pot-Limit — players may raise up to the current pot size.
|
||||
/// The `min_raise` is the minimum increment above the previous raise.
|
||||
PotLimit {
|
||||
/// Minimum raise increment.
|
||||
min_raise: ChipCount,
|
||||
},
|
||||
|
||||
/// Fixed-Limit — bets and raises are in fixed increments.
|
||||
/// `small_bet` applies on the flop, `big_bet` on the turn and river.
|
||||
FixedLimit {
|
||||
/// Bet size for the first two betting rounds (preflop and flop).
|
||||
small_bet: ChipCount,
|
||||
/// Bet size for the last two betting rounds (turn and river).
|
||||
big_bet: ChipCount,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BettingRules trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rules governing bet sizes and raise limits for a given betting structure.
|
||||
///
|
||||
/// Each variant of [`BetLimit`] implements this trait so that the rest of the
|
||||
/// engine can query raise/bet constraints in a uniform way.
|
||||
pub trait BettingRules {
|
||||
/// Returns the minimum raise increment given the current bet and the size
|
||||
/// of the previous raise (or the big blind if this is the first raise).
|
||||
fn min_raise_amount(&self, current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount;
|
||||
|
||||
/// Returns the maximum raise amount, or `None` if there is no upper bound
|
||||
/// (as in No-Limit).
|
||||
///
|
||||
/// Parameters:
|
||||
/// - `pot` — the current pot size
|
||||
/// - `call_amount` — the amount needed to call
|
||||
/// - `player_stack` — the acting player's remaining chips
|
||||
fn max_raise_amount(
|
||||
&self,
|
||||
pot: ChipCount,
|
||||
call_amount: ChipCount,
|
||||
player_stack: ChipCount,
|
||||
) -> Option<ChipCount>;
|
||||
|
||||
/// Returns the fixed bet size for the given street, if applicable.
|
||||
/// Only meaningful for Fixed-Limit; returns `None` for No/Pot-Limit.
|
||||
fn fixed_bet_size(&self, street: Street) -> Option<ChipCount>;
|
||||
|
||||
/// Returns `true` if a raise is permitted on the given street given the
|
||||
/// number of raises already made this round.
|
||||
fn can_raise(&self, street: Street, raises_this_round: usize) -> bool;
|
||||
}
|
||||
|
||||
impl BettingRules for BetLimit {
|
||||
fn min_raise_amount(&self, _current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount {
|
||||
match self {
|
||||
BetLimit::NoLimit { min_raise } => (*min_raise).max(previous_raise),
|
||||
BetLimit::PotLimit { min_raise } => (*min_raise).max(previous_raise),
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet: _,
|
||||
} => *small_bet, // min_raise_amount is overridden per-street below isn't needed — fixed
|
||||
// Actually, for FixedLimit the min raise *is* the bet size for the street.
|
||||
// But we only have `current_bet` and `previous_raise` here, not `street`.
|
||||
// The caller should use `fixed_bet_size` for FixedLimit instead.
|
||||
// We return small_bet as a safe default; FixedLimit users should prefer
|
||||
// `fixed_bet_size(street)`.
|
||||
}
|
||||
}
|
||||
|
||||
fn max_raise_amount(
|
||||
&self,
|
||||
pot: ChipCount,
|
||||
call_amount: ChipCount,
|
||||
_player_stack: ChipCount,
|
||||
) -> Option<ChipCount> {
|
||||
match self {
|
||||
BetLimit::NoLimit { .. } => None,
|
||||
BetLimit::PotLimit { .. } => {
|
||||
// Standard pot-limit formula: pot + call_amount + previous_raise
|
||||
// Here we return the max *total* raise above the call amount.
|
||||
// The classic formula: max raise = pot + call_amount (where call_amount
|
||||
// is what the player needs to put in to call).
|
||||
Some(pot + call_amount)
|
||||
}
|
||||
BetLimit::FixedLimit { .. } => {
|
||||
// Max raise equals min raise (fixed increments).
|
||||
// The caller should use `fixed_bet_size` to determine the amount.
|
||||
// Returning `Some(0)` isn't useful; FixedLimit max is the same as min.
|
||||
// We return None to indicate "use fixed_bet_size" — but the trait
|
||||
// contract says Some means there's a cap. For FixedLimit, the cap
|
||||
// IS the fixed bet size. Let's return it via fixed_bet_size instead.
|
||||
// Actually, to keep the trait useful, we return `Some` equal to the
|
||||
// fixed bet size. But we don't have `street` here...
|
||||
// The simplest correct thing: FixedLimit max raise == min raise,
|
||||
// and the caller should use fixed_bet_size(street) for the actual value.
|
||||
// We return Some(0) as a sentinel — no, that's bad.
|
||||
// Best approach: the HandRunner will use fixed_bet_size for FixedLimit.
|
||||
// For the trait, we return None and rely on can_raise for capping.
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_bet_size(&self, street: Street) -> Option<ChipCount> {
|
||||
match self {
|
||||
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => None,
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet,
|
||||
} => Some(match street {
|
||||
Street::Preflop | Street::Flop => *small_bet,
|
||||
Street::Turn | Street::River => *big_bet,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
|
||||
match self {
|
||||
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
|
||||
BetLimit::FixedLimit { .. } => raises_this_round < 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BettingRound struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks the mutable state of a single betting round.
|
||||
///
|
||||
/// A `BettingRound` is a lightweight data struct. The higher-level `HandRunner`
|
||||
/// is responsible for player iteration and action dispatch; this struct only
|
||||
/// records the betting-line state (current bet, number of raises, etc.).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BettingRound {
|
||||
/// Index (seat) of the next player expected to act.
|
||||
pub next_to_act: usize,
|
||||
/// The current highest bet on the table for this round.
|
||||
pub current_bet: ChipCount,
|
||||
/// The size of the last raise increment (used to compute min-raise).
|
||||
pub last_raise_size: ChipCount,
|
||||
/// Number of raises made so far this round.
|
||||
pub num_raises: usize,
|
||||
/// Whether the round is considered complete.
|
||||
pub is_complete: bool,
|
||||
/// The seat index of the first player to act this round.
|
||||
pub first_to_act: usize,
|
||||
}
|
||||
|
||||
impl BettingRound {
|
||||
/// Creates a new betting round for the **preflop** street.
|
||||
///
|
||||
/// `current_bet` starts at `big_blind` and `last_raise_size` is set to
|
||||
/// `big_blind` so that the minimum re-raise is at least one big blind.
|
||||
pub fn new(first_to_act: usize, big_blind: ChipCount) -> Self {
|
||||
Self {
|
||||
next_to_act: first_to_act,
|
||||
current_bet: big_blind,
|
||||
last_raise_size: big_blind,
|
||||
num_raises: 0,
|
||||
is_complete: false,
|
||||
first_to_act,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new betting round for **postflop** streets (flop, turn, river).
|
||||
///
|
||||
/// `current_bet` starts at 0 and `last_raise_size` is 0.
|
||||
pub fn new_postflop(first_to_act: usize) -> Self {
|
||||
Self {
|
||||
next_to_act: first_to_act,
|
||||
current_bet: 0,
|
||||
last_raise_size: 0,
|
||||
num_raises: 0,
|
||||
is_complete: false,
|
||||
first_to_act,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a bet or raise of the given total amount.
|
||||
///
|
||||
/// `amount` is the **total** bet level (not the increment). For example, if
|
||||
/// the current bet is 100 and a player raises to 300, pass `300`.
|
||||
pub fn apply_bet(&mut self, amount: ChipCount) {
|
||||
if amount > self.current_bet {
|
||||
let raise_increment = amount - self.current_bet;
|
||||
self.last_raise_size = raise_increment;
|
||||
self.current_bet = amount;
|
||||
self.num_raises += 1;
|
||||
}
|
||||
// If amount == current_bet, it's a call — no state change to bet tracking.
|
||||
}
|
||||
|
||||
/// Records a call. No bet-tracking state changes are needed.
|
||||
pub fn apply_call(&mut self) {
|
||||
// Intentionally empty — calls don't affect current_bet / last_raise_size / num_raises.
|
||||
}
|
||||
|
||||
/// Checks whether the round is complete.
|
||||
///
|
||||
/// A round is complete when every actable player (active, not all-in) has
|
||||
/// acted and matched the current bet. The caller provides:
|
||||
/// - `num_active_players` — players still in the hand (including all-in)
|
||||
/// - `num_actable_players` — active players who *can* still act (have chips, not all-in)
|
||||
///
|
||||
/// The round is complete when `is_complete` is explicitly set, or when there
|
||||
/// are zero or one actable players (everyone else is all-in or folded), or
|
||||
/// when only one active player remains (everyone else folded).
|
||||
pub fn is_round_complete(&self, num_active_players: usize, num_actable_players: usize) -> bool {
|
||||
if self.is_complete {
|
||||
return true;
|
||||
}
|
||||
// Only one player left in the hand — round is over.
|
||||
if num_active_players <= 1 {
|
||||
return true;
|
||||
}
|
||||
// No actable players (everyone remaining is all-in) — round is over.
|
||||
if num_actable_players == 0 {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper — No-Limit with a minimum raise of 10 (big-blind sized).
|
||||
fn nl() -> BetLimit {
|
||||
BetLimit::NoLimit { min_raise: 10 }
|
||||
}
|
||||
|
||||
/// Helper — Pot-Limit with a minimum raise of 10.
|
||||
fn pl() -> BetLimit {
|
||||
BetLimit::PotLimit { min_raise: 10 }
|
||||
}
|
||||
|
||||
/// Helper — Fixed-Limit with small bet 10, big bet 20.
|
||||
fn fl() -> BetLimit {
|
||||
BetLimit::FixedLimit {
|
||||
small_bet: 10,
|
||||
big_bet: 20,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BettingRules: NoLimit -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nl_min_raise_is_at_least_configured_minimum() {
|
||||
let rules = nl();
|
||||
// previous_raise is 0 (first raise) → min_raise_amount is max(10, 0) = 10
|
||||
assert_eq!(rules.min_raise_amount(10, 0), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_min_raise_is_at_least_previous_raise() {
|
||||
let rules = nl();
|
||||
// previous_raise is 50 → min_raise_amount is max(10, 50) = 50
|
||||
assert_eq!(rules.min_raise_amount(100, 50), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_max_raise_is_none() {
|
||||
let rules = nl();
|
||||
assert!(rules.max_raise_amount(1000, 100, 5000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_fixed_bet_is_none() {
|
||||
let rules = nl();
|
||||
assert!(rules.fixed_bet_size(Street::Preflop).is_none());
|
||||
assert!(rules.fixed_bet_size(Street::Flop).is_none());
|
||||
assert!(rules.fixed_bet_size(Street::Turn).is_none());
|
||||
assert!(rules.fixed_bet_size(Street::River).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_can_always_raise() {
|
||||
let rules = nl();
|
||||
assert!(rules.can_raise(Street::Preflop, 0));
|
||||
assert!(rules.can_raise(Street::Preflop, 5));
|
||||
assert!(rules.can_raise(Street::Preflop, 100));
|
||||
}
|
||||
|
||||
// ---- BettingRules: PotLimit ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pl_min_raise_same_as_nolimit() {
|
||||
let rules = pl();
|
||||
assert_eq!(rules.min_raise_amount(10, 0), 10);
|
||||
assert_eq!(rules.min_raise_amount(100, 50), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pl_max_raise_uses_pot_formula() {
|
||||
let rules = pl();
|
||||
// pot=100, call_amount=50 → max raise = 100 + 50 = 150
|
||||
assert_eq!(rules.max_raise_amount(100, 50, 5000), Some(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pl_max_raise_with_zero_pot() {
|
||||
let rules = pl();
|
||||
assert_eq!(rules.max_raise_amount(0, 0, 5000), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pl_fixed_bet_is_none() {
|
||||
let rules = pl();
|
||||
assert!(rules.fixed_bet_size(Street::Flop).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pl_can_always_raise() {
|
||||
let rules = pl();
|
||||
assert!(rules.can_raise(Street::Turn, 10));
|
||||
}
|
||||
|
||||
// ---- BettingRules: FixedLimit --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fl_fixed_bet_preflop_and_flop_use_small_bet() {
|
||||
let rules = fl();
|
||||
assert_eq!(rules.fixed_bet_size(Street::Preflop), Some(10));
|
||||
assert_eq!(rules.fixed_bet_size(Street::Flop), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fl_fixed_bet_turn_and_river_use_big_bet() {
|
||||
let rules = fl();
|
||||
assert_eq!(rules.fixed_bet_size(Street::Turn), Some(20));
|
||||
assert_eq!(rules.fixed_bet_size(Street::River), Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fl_can_raise_up_to_three_raises() {
|
||||
let rules = fl();
|
||||
assert!(rules.can_raise(Street::Flop, 0)); // 0 raises → can raise
|
||||
assert!(rules.can_raise(Street::Flop, 1)); // 1 raise → can raise
|
||||
assert!(rules.can_raise(Street::Flop, 2)); // 2 raises → can raise
|
||||
assert!(!rules.can_raise(Street::Flop, 3)); // 3 raises → cap reached
|
||||
assert!(!rules.can_raise(Street::Flop, 4)); // beyond cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fl_max_raise_is_none_uses_fixed_bet_size() {
|
||||
let rules = fl();
|
||||
// FixedLimit returns None from max_raise_amount; the caller uses fixed_bet_size.
|
||||
assert!(rules.max_raise_amount(100, 10, 5000).is_none());
|
||||
}
|
||||
|
||||
// ---- BettingRound: construction ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_preflop_sets_big_blind_as_current_bet() {
|
||||
let round = BettingRound::new(3, 100);
|
||||
assert_eq!(round.next_to_act, 3);
|
||||
assert_eq!(round.current_bet, 100);
|
||||
assert_eq!(round.last_raise_size, 100);
|
||||
assert_eq!(round.num_raises, 0);
|
||||
assert!(!round.is_complete);
|
||||
assert_eq!(round.first_to_act, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_postflop_starts_with_zero_bet() {
|
||||
let round = BettingRound::new_postflop(1);
|
||||
assert_eq!(round.next_to_act, 1);
|
||||
assert_eq!(round.current_bet, 0);
|
||||
assert_eq!(round.last_raise_size, 0);
|
||||
assert_eq!(round.num_raises, 0);
|
||||
assert!(!round.is_complete);
|
||||
}
|
||||
|
||||
// ---- BettingRound: apply_bet ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn apply_bet_first_raise_sets_current_bet_and_last_raise() {
|
||||
let mut round = BettingRound::new_postflop(0);
|
||||
round.apply_bet(50);
|
||||
assert_eq!(round.current_bet, 50);
|
||||
assert_eq!(round.last_raise_size, 50); // 50 - 0 = 50
|
||||
assert_eq!(round.num_raises, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_bet_re_raise_updates_last_raise_size() {
|
||||
let mut round = BettingRound::new_postflop(0);
|
||||
round.apply_bet(50); // first raise to 50
|
||||
round.apply_bet(150); // re-raise to 150
|
||||
assert_eq!(round.current_bet, 150);
|
||||
assert_eq!(round.last_raise_size, 100); // 150 - 50 = 100
|
||||
assert_eq!(round.num_raises, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_bet_with_amount_equal_to_current_is_a_call() {
|
||||
let mut round = BettingRound::new_postflop(0);
|
||||
round.apply_bet(50);
|
||||
round.apply_bet(50); // calling, not raising
|
||||
assert_eq!(round.current_bet, 50);
|
||||
assert_eq!(round.last_raise_size, 50);
|
||||
assert_eq!(round.num_raises, 1); // no additional raise counted
|
||||
}
|
||||
|
||||
// ---- BettingRound: apply_call --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn apply_call_does_not_change_bet_state() {
|
||||
let mut round = BettingRound::new_postflop(0);
|
||||
round.apply_bet(50);
|
||||
round.apply_call();
|
||||
assert_eq!(round.current_bet, 50);
|
||||
assert_eq!(round.last_raise_size, 50);
|
||||
assert_eq!(round.num_raises, 1);
|
||||
}
|
||||
|
||||
// ---- BettingRound: is_round_complete --------------------------------
|
||||
|
||||
#[test]
|
||||
fn round_not_complete_with_multiple_actable_players() {
|
||||
let round = BettingRound::new_postflop(0);
|
||||
assert!(!round.is_round_complete(3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_complete_when_only_one_active_player() {
|
||||
let round = BettingRound::new_postflop(0);
|
||||
assert!(round.is_round_complete(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_complete_when_no_actable_players() {
|
||||
let round = BettingRound::new_postflop(0);
|
||||
// 3 active but 0 actable (everyone all-in)
|
||||
assert!(round.is_round_complete(3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_complete_when_flag_is_set() {
|
||||
let mut round = BettingRound::new_postflop(0);
|
||||
round.is_complete = true;
|
||||
assert!(round.is_round_complete(5, 5));
|
||||
}
|
||||
|
||||
// ---- BettingRound: preflop scenario --------------------------------
|
||||
|
||||
#[test]
|
||||
fn preflop_scenario_with_raises() {
|
||||
let mut round = BettingRound::new(1, 10); // BB = 10
|
||||
assert_eq!(round.current_bet, 10);
|
||||
|
||||
// UTG raises to 30 (raise of 20)
|
||||
round.apply_bet(30);
|
||||
assert_eq!(round.current_bet, 30);
|
||||
assert_eq!(round.last_raise_size, 20);
|
||||
assert_eq!(round.num_raises, 1);
|
||||
|
||||
// Button re-raises to 80 (raise of 50)
|
||||
round.apply_bet(80);
|
||||
assert_eq!(round.current_bet, 80);
|
||||
assert_eq!(round.last_raise_size, 50);
|
||||
assert_eq!(round.num_raises, 2);
|
||||
|
||||
// BB calls (amount == current_bet → no raise counted)
|
||||
round.apply_bet(80);
|
||||
assert_eq!(round.num_raises, 2);
|
||||
}
|
||||
|
||||
// ---- FixedLimit: full round cap scenario ----------------------------
|
||||
|
||||
#[test]
|
||||
fn fixed_limit_caps_at_three_raises() {
|
||||
let rules = fl();
|
||||
let _round = BettingRound::new_postflop(0);
|
||||
|
||||
// Bet (counts as raise 0 for FixedLimit — the initial bet)
|
||||
// In FixedLimit, the initial bet is not a "raise" per se, but
|
||||
// apply_bet increments num_raises. The can_raise check uses
|
||||
// raises_this_round which the HandRunner tracks. Here we test
|
||||
// the can_raise logic directly.
|
||||
assert!(rules.can_raise(Street::Flop, 0));
|
||||
assert!(rules.can_raise(Street::Flop, 1));
|
||||
assert!(rules.can_raise(Street::Flop, 2));
|
||||
assert!(!rules.can_raise(Street::Flop, 3));
|
||||
|
||||
// Verify the bet sizes are correct for each street
|
||||
assert_eq!(rules.fixed_bet_size(Street::Preflop), Some(10));
|
||||
assert_eq!(rules.fixed_bet_size(Street::Flop), Some(10));
|
||||
assert_eq!(rules.fixed_bet_size(Street::Turn), Some(20));
|
||||
assert_eq!(rules.fixed_bet_size(Street::River), Some(20));
|
||||
}
|
||||
|
||||
// ---- PotLimit: max raise calculation --------------------------------
|
||||
|
||||
#[test]
|
||||
fn pot_limit_max_raise_standard_scenario() {
|
||||
let rules = pl();
|
||||
// Pot is 300, player needs to call 100.
|
||||
// Max raise = pot + call_amount = 300 + 100 = 400
|
||||
// (This is the amount *above* the call that can be raised.)
|
||||
assert_eq!(rules.max_raise_amount(300, 100, 10000), Some(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pot_limit_max_raise_small_pot() {
|
||||
let rules = pl();
|
||||
// Pot is 20, call is 10 → max raise = 30
|
||||
assert_eq!(rules.max_raise_amount(20, 10, 5000), Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pot_limit_max_raise_large_pot() {
|
||||
let rules = pl();
|
||||
// Pot is 1000, call is 200 → max raise = 1200
|
||||
assert_eq!(rules.max_raise_amount(1000, 200, 5000), Some(1200));
|
||||
}
|
||||
}
|
||||
140
holdem_core/src/game/bot_factory.rs
Normal file
140
holdem_core/src/game/bot_factory.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! Bot factory for creating bot instances from configuration.
|
||||
//!
|
||||
//! Maps [`BotConfig.bot_type`](super::config::BotConfig::bot_type) strings to
|
||||
//! `Box<dyn `[`HoldemPlayer`]`>` instances. This is the central registry that
|
||||
//! connects configuration to concrete bot implementations.
|
||||
//!
|
||||
//! # Supported Bot Types
|
||||
//!
|
||||
//! | `bot_type` string | Bot implementation |
|
||||
//! |-------------------|--------------------|
|
||||
//! | `"call_bot"` | [`CallBot`](super::bots::CallBot) |
|
||||
//! | `"fold_bot"` | [`FoldBot`](super::bots::FoldBot) |
|
||||
//! | `"raise_bot"` | [`RaiseBot`](super::bots::RaiseBot) |
|
||||
//! | `"random_bot"` | [`RandomBot`](super::bots::RandomBot) |
|
||||
|
||||
use super::bots::{CallBot, FoldBot, RandomBot, RaiseBot};
|
||||
use super::config::BotConfig;
|
||||
use super::player::HoldemPlayer;
|
||||
use super::types::PlayerId;
|
||||
|
||||
/// Creates a bot instance from a [`BotConfig`].
|
||||
///
|
||||
/// The `id` parameter is assigned to the bot for tracking throughout the
|
||||
/// simulation. If `config.name` is `None`, the `bot_type` string is used as
|
||||
/// the display name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for unknown `bot_type` values.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use holdem_core::game::bot_factory::create_bot;
|
||||
/// use holdem_core::game::config::BotConfig;
|
||||
/// use holdem_core::game::types::PlayerId;
|
||||
/// use std::collections::HashMap;
|
||||
///
|
||||
/// let config = BotConfig {
|
||||
/// bot_type: "call_bot".to_string(),
|
||||
/// name: Some("Alice".to_string()),
|
||||
/// seat: None,
|
||||
/// parameters: HashMap::new(),
|
||||
/// };
|
||||
///
|
||||
/// let bot = create_bot(&config, PlayerId(1)).unwrap();
|
||||
/// assert_eq!(bot.id(), PlayerId(1));
|
||||
/// assert_eq!(bot.name(), "Alice");
|
||||
/// ```
|
||||
pub fn create_bot(config: &BotConfig, id: PlayerId) -> Result<Box<dyn HoldemPlayer>, String> {
|
||||
let name = config
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.bot_type.clone());
|
||||
match config.bot_type.as_str() {
|
||||
"call_bot" => Ok(Box::new(CallBot::new(id, name))),
|
||||
"fold_bot" => Ok(Box::new(FoldBot::new(id, name))),
|
||||
"raise_bot" => Ok(Box::new(RaiseBot::new(id, name))),
|
||||
"random_bot" => Ok(Box::new(RandomBot::new(id, name))),
|
||||
other => Err(format!("unknown bot type: '{}'", other)),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_config(bot_type: &str, name: Option<&str>) -> BotConfig {
|
||||
BotConfig {
|
||||
bot_type: bot_type.to_string(),
|
||||
name: name.map(|s| s.to_string()),
|
||||
seat: None,
|
||||
parameters: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_call_bot() {
|
||||
let config = make_config("call_bot", Some("Caller"));
|
||||
let bot = create_bot(&config, PlayerId(1)).unwrap();
|
||||
assert_eq!(bot.id(), PlayerId(1));
|
||||
assert_eq!(bot.name(), "Caller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_fold_bot() {
|
||||
let config = make_config("fold_bot", Some("Folder"));
|
||||
let bot = create_bot(&config, PlayerId(2)).unwrap();
|
||||
assert_eq!(bot.id(), PlayerId(2));
|
||||
assert_eq!(bot.name(), "Folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_raise_bot() {
|
||||
let config = make_config("raise_bot", Some("Raiser"));
|
||||
let bot = create_bot(&config, PlayerId(3)).unwrap();
|
||||
assert_eq!(bot.id(), PlayerId(3));
|
||||
assert_eq!(bot.name(), "Raiser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_random_bot() {
|
||||
let config = make_config("random_bot", Some("Random"));
|
||||
let bot = create_bot(&config, PlayerId(4)).unwrap();
|
||||
assert_eq!(bot.id(), PlayerId(4));
|
||||
assert_eq!(bot.name(), "Random");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_name_uses_bot_type() {
|
||||
let config = make_config("call_bot", None);
|
||||
let bot = create_bot(&config, PlayerId(5)).unwrap();
|
||||
assert_eq!(bot.name(), "call_bot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_bot_type_returns_error() {
|
||||
let config = make_config("super_ai_bot", None);
|
||||
let result = create_bot(&config, PlayerId(99));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.err().unwrap();
|
||||
assert!(err_msg.contains("unknown bot type"));
|
||||
assert!(err_msg.contains("super_ai_bot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multiple_bots_with_sequential_ids() {
|
||||
let types = ["call_bot", "fold_bot", "raise_bot", "random_bot"];
|
||||
for (i, bot_type) in types.iter().enumerate() {
|
||||
let config = make_config(bot_type, None);
|
||||
let bot = create_bot(&config, PlayerId(i as u64)).unwrap();
|
||||
assert_eq!(bot.id(), PlayerId(i as u64));
|
||||
}
|
||||
}
|
||||
}
|
||||
187
holdem_core/src/game/bots/call_bot.rs
Normal file
187
holdem_core/src/game/bots/call_bot.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//! A bot that always calls (or checks if no bet to call).
|
||||
//!
|
||||
//! [`CallBot`] is the simplest possible poker strategy: never fold, never raise.
|
||||
//! It checks when there is no outstanding bet and calls when there is one.
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::player::HoldemPlayer;
|
||||
use crate::game::types::PlayerId;
|
||||
use crate::{HandValue, HoleCards};
|
||||
|
||||
/// A bot that **always calls** (or checks if no bet to call).
|
||||
///
|
||||
/// This is the simplest poker strategy: never fold, never raise.
|
||||
/// When facing a bet, it calls. When no bet is outstanding, it checks.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::bots::CallBot;
|
||||
/// use holdem_core::game::player::HoldemPlayer;
|
||||
/// use holdem_core::game::types::PlayerId;
|
||||
///
|
||||
/// let bot = CallBot::new(PlayerId(1), "CallBot-1".to_string());
|
||||
/// assert_eq!(bot.id(), PlayerId(1));
|
||||
/// assert_eq!(bot.name(), "CallBot-1");
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct CallBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl CallBot {
|
||||
/// Creates a new [`CallBot`] with the given player ID and display name.
|
||||
pub fn new(id: PlayerId, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldemPlayer for CallBot {
|
||||
fn id(&self) -> PlayerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn act(&mut self, game_info: &GameInfo) -> Action {
|
||||
let my_info = game_info.player_by_id(self.id);
|
||||
let wagered = my_info.map(|m| m.wagered_this_round).unwrap_or(0);
|
||||
|
||||
if game_info.current_bet == 0 || wagered >= game_info.current_bet {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Call
|
||||
}
|
||||
}
|
||||
|
||||
fn action_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_action: &Action,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_hole_cards: Option<&HoleCards>,
|
||||
_hand_value: Option<&HandValue>,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::betting::BetLimit;
|
||||
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||
use crate::game::types::ChipCount;
|
||||
use crate::Street;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Helper to build a minimal [`GameInfo`] for testing.
|
||||
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street: Street::Flop,
|
||||
board: vec![],
|
||||
pot: 100,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(1),
|
||||
name: "CallBot".to_string(),
|
||||
seat: 0,
|
||||
stack: 1000,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_bot_checks_when_no_bet() {
|
||||
let mut bot = CallBot::new(PlayerId(1), "CallBot".to_string());
|
||||
let game_info = make_game_info(0, 0);
|
||||
assert_eq!(bot.act(&game_info), Action::Check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_bot_checks_when_already_matched_bet() {
|
||||
let mut bot = CallBot::new(PlayerId(1), "CallBot".to_string());
|
||||
// current_bet is 20, but player has already wagered 20
|
||||
let game_info = make_game_info(20, 20);
|
||||
assert_eq!(bot.act(&game_info), Action::Check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_bot_calls_when_facing_bet() {
|
||||
let mut bot = CallBot::new(PlayerId(1), "CallBot".to_string());
|
||||
let game_info = make_game_info(50, 0);
|
||||
assert_eq!(bot.act(&game_info), Action::Call);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_bot_calls_when_partial_wager() {
|
||||
let mut bot = CallBot::new(PlayerId(1), "CallBot".to_string());
|
||||
// current_bet is 50, player has wagered 20 (partial)
|
||||
let game_info = make_game_info(50, 20);
|
||||
assert_eq!(bot.act(&game_info), Action::Call);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_bot_trait_methods_are_no_ops() {
|
||||
let mut bot = CallBot::new(PlayerId(1), "CallBot".to_string());
|
||||
let game_info = make_game_info(0, 0);
|
||||
|
||||
// These should all compile and run without panic
|
||||
bot.init(&game_info);
|
||||
bot.game_start_event(&game_info);
|
||||
bot.hole_cards_dealt(
|
||||
&HoleCards::new(
|
||||
crate::Card::from_str("As").unwrap(),
|
||||
crate::Card::from_str("Kh").unwrap(),
|
||||
).unwrap(),
|
||||
&game_info,
|
||||
);
|
||||
bot.stage_event(&game_info);
|
||||
bot.action_event(PlayerId(2), &Action::Call, &game_info);
|
||||
bot.showdown_event(PlayerId(2), None, None, &game_info);
|
||||
bot.game_over_event(&game_info);
|
||||
bot.on_eliminated(3);
|
||||
}
|
||||
}
|
||||
185
holdem_core/src/game/bots/fold_bot.rs
Normal file
185
holdem_core/src/game/bots/fold_bot.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! A bot that always folds when facing a bet, checks otherwise.
|
||||
//!
|
||||
//! [`FoldBot`] represents an extremely passive strategy: never voluntarily put chips
|
||||
//! into the pot. It checks when possible and folds whenever facing any bet.
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::player::HoldemPlayer;
|
||||
use crate::game::types::PlayerId;
|
||||
use crate::{HandValue, HoleCards};
|
||||
|
||||
/// A bot that **always folds** when facing a bet, checks otherwise.
|
||||
///
|
||||
/// This represents the most passive possible poker strategy: never voluntarily
|
||||
/// put chips into the pot. When there is no bet to call, it checks. When facing
|
||||
/// any bet, it folds.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::bots::FoldBot;
|
||||
/// use holdem_core::game::player::HoldemPlayer;
|
||||
/// use holdem_core::game::types::PlayerId;
|
||||
///
|
||||
/// let bot = FoldBot::new(PlayerId(2), "FoldBot-2".to_string());
|
||||
/// assert_eq!(bot.id(), PlayerId(2));
|
||||
/// assert_eq!(bot.name(), "FoldBot-2");
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct FoldBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl FoldBot {
|
||||
/// Creates a new [`FoldBot`] with the given player ID and display name.
|
||||
pub fn new(id: PlayerId, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldemPlayer for FoldBot {
|
||||
fn id(&self) -> PlayerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn act(&mut self, game_info: &GameInfo) -> Action {
|
||||
let my_info = game_info.player_by_id(self.id);
|
||||
let wagered = my_info.map(|m| m.wagered_this_round).unwrap_or(0);
|
||||
|
||||
if game_info.current_bet == 0 || wagered >= game_info.current_bet {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Fold
|
||||
}
|
||||
}
|
||||
|
||||
fn action_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_action: &Action,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_hole_cards: Option<&HoleCards>,
|
||||
_hand_value: Option<&HandValue>,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::betting::BetLimit;
|
||||
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||
use crate::game::types::ChipCount;
|
||||
use crate::Street;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Helper to build a minimal [`GameInfo`] for testing.
|
||||
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street: Street::Flop,
|
||||
board: vec![],
|
||||
pot: 100,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(2),
|
||||
name: "FoldBot".to_string(),
|
||||
seat: 0,
|
||||
stack: 1000,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_bot_checks_when_no_bet() {
|
||||
let mut bot = FoldBot::new(PlayerId(2), "FoldBot".to_string());
|
||||
let game_info = make_game_info(0, 0);
|
||||
assert_eq!(bot.act(&game_info), Action::Check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_bot_checks_when_already_matched_bet() {
|
||||
let mut bot = FoldBot::new(PlayerId(2), "FoldBot".to_string());
|
||||
let game_info = make_game_info(20, 20);
|
||||
assert_eq!(bot.act(&game_info), Action::Check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_bot_folds_when_facing_bet() {
|
||||
let mut bot = FoldBot::new(PlayerId(2), "FoldBot".to_string());
|
||||
let game_info = make_game_info(50, 0);
|
||||
assert_eq!(bot.act(&game_info), Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_bot_folds_when_partial_wager() {
|
||||
let mut bot = FoldBot::new(PlayerId(2), "FoldBot".to_string());
|
||||
let game_info = make_game_info(50, 20);
|
||||
assert_eq!(bot.act(&game_info), Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_bot_trait_methods_are_no_ops() {
|
||||
let mut bot = FoldBot::new(PlayerId(2), "FoldBot".to_string());
|
||||
let game_info = make_game_info(0, 0);
|
||||
|
||||
bot.init(&game_info);
|
||||
bot.game_start_event(&game_info);
|
||||
bot.hole_cards_dealt(
|
||||
&HoleCards::new(
|
||||
crate::Card::from_str("As").unwrap(),
|
||||
crate::Card::from_str("Kh").unwrap(),
|
||||
).unwrap(),
|
||||
&game_info,
|
||||
);
|
||||
bot.stage_event(&game_info);
|
||||
bot.action_event(PlayerId(1), &Action::Fold, &game_info);
|
||||
bot.showdown_event(PlayerId(1), None, None, &game_info);
|
||||
bot.game_over_event(&game_info);
|
||||
bot.on_eliminated(5);
|
||||
}
|
||||
}
|
||||
38
holdem_core/src/game/bots/mod.rs
Normal file
38
holdem_core/src/game/bots/mod.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Production-quality bot implementations for Texas Hold'em simulations.
|
||||
//!
|
||||
//! This module provides ready-to-use bot implementations that conform to the
|
||||
//! [`HoldemPlayer`](crate::game::player::HoldemPlayer) trait. Each bot represents
|
||||
//! a different poker strategy, from passive to aggressive to random.
|
||||
//!
|
||||
//! # Available Bots
|
||||
//!
|
||||
//! | Bot | Strategy | Description |
|
||||
//! |-----|----------|-------------|
|
||||
//! | [`CallBot`] | Passive | Always calls or checks — never folds, never raises |
|
||||
//! | [`FoldBot`] | Ultra-passive | Folds to any bet, checks otherwise |
|
||||
//! | [`RaiseBot`] | Ultra-aggressive | Always raises the maximum allowed amount |
|
||||
//! | [`RandomBot`] | Random | Picks a random legal action at each decision point |
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use holdem_core::game::bots::{CallBot, FoldBot, RaiseBot, RandomBot};
|
||||
//! use holdem_core::game::player::HoldemPlayer;
|
||||
//! use holdem_core::game::types::PlayerId;
|
||||
//!
|
||||
//! let call_bot = CallBot::new(PlayerId(1), "Caller".to_string());
|
||||
//! let fold_bot = FoldBot::new(PlayerId(2), "Folder".to_string());
|
||||
//! let raise_bot = RaiseBot::new(PlayerId(3), "Raiser".to_string());
|
||||
//! let random_bot = RandomBot::new(PlayerId(4), "Random".to_string());
|
||||
//! ```
|
||||
|
||||
pub mod call_bot;
|
||||
pub mod fold_bot;
|
||||
pub mod random_bot;
|
||||
pub mod raise_bot;
|
||||
|
||||
// Re-export all bots at the module level for convenience.
|
||||
pub use call_bot::CallBot;
|
||||
pub use fold_bot::FoldBot;
|
||||
pub use random_bot::RandomBot;
|
||||
pub use raise_bot::RaiseBot;
|
||||
496
holdem_core/src/game/bots/raise_bot.rs
Normal file
496
holdem_core/src/game/bots/raise_bot.rs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
//! A bot that always raises the maximum allowed amount.
|
||||
//!
|
||||
//! [`RaiseBot`] represents an extremely aggressive strategy: always raise or bet
|
||||
//! the maximum allowed amount. For No-Limit and Pot-Limit games, it raises to pot
|
||||
//! size. For Fixed-Limit games, it raises the fixed bet size.
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::betting::{BetLimit, BettingRules};
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::player::HoldemPlayer;
|
||||
use crate::game::types::{ChipCount, PlayerId};
|
||||
use crate::{HandValue, HoleCards};
|
||||
|
||||
/// A bot that **always raises** the maximum allowed amount.
|
||||
///
|
||||
/// For **No-Limit** games: raises to pot size (or all-in if stack is smaller).
|
||||
/// For **Pot-Limit** games: raises to the maximum pot-size raise.
|
||||
/// For **Fixed-Limit** games: raises the fixed bet size for the current street.
|
||||
///
|
||||
/// If the player cannot afford to raise, it goes all-in. If the player's stack
|
||||
/// is zero, it folds when facing a bet or checks otherwise.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::bots::RaiseBot;
|
||||
/// use holdem_core::game::player::HoldemPlayer;
|
||||
/// use holdem_core::game::types::PlayerId;
|
||||
///
|
||||
/// let bot = RaiseBot::new(PlayerId(3), "RaiseBot-3".to_string());
|
||||
/// assert_eq!(bot.id(), PlayerId(3));
|
||||
/// assert_eq!(bot.name(), "RaiseBot-3");
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct RaiseBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl RaiseBot {
|
||||
/// Creates a new [`RaiseBot`] with the given player ID and display name.
|
||||
pub fn new(id: PlayerId, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
|
||||
/// Computes the call amount for this player given the current game state.
|
||||
fn call_amount(&self, game_info: &GameInfo) -> ChipCount {
|
||||
let my_info = game_info.player_by_id(self.id);
|
||||
let wagered = my_info.map(|m| m.wagered_this_round).unwrap_or(0);
|
||||
game_info.current_bet.saturating_sub(wagered)
|
||||
}
|
||||
|
||||
/// Returns this player's current stack.
|
||||
fn stack(&self, game_info: &GameInfo) -> ChipCount {
|
||||
game_info
|
||||
.player_by_id(self.id)
|
||||
.map(|m| m.stack)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldemPlayer for RaiseBot {
|
||||
fn id(&self) -> PlayerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn act(&mut self, game_info: &GameInfo) -> Action {
|
||||
let stack = self.stack(game_info);
|
||||
let call_amount = self.call_amount(game_info);
|
||||
let current_bet = game_info.current_bet;
|
||||
let my_info = game_info.player_by_id(self.id);
|
||||
let wagered = my_info.map(|m| m.wagered_this_round).unwrap_or(0);
|
||||
|
||||
// If stack is 0, can't do anything aggressive
|
||||
if stack == 0 {
|
||||
return if call_amount == 0 {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Fold
|
||||
};
|
||||
}
|
||||
|
||||
// If we can't even afford to call, go all-in
|
||||
if call_amount > 0 && stack <= call_amount {
|
||||
return Action::AllIn;
|
||||
}
|
||||
|
||||
match game_info.bet_limit {
|
||||
BetLimit::NoLimit { .. } => {
|
||||
// No-Limit: raise to pot size
|
||||
// Pot-size raise total = current_bet + call_amount + pot
|
||||
// This is the standard formula: raise to (pot + call_amount + current_bet)
|
||||
let pot_raise_total =
|
||||
game_info.pot + call_amount + current_bet;
|
||||
|
||||
// The raise total must be at least current_bet + min_raise
|
||||
let min_raise_total = current_bet + game_info.min_raise;
|
||||
|
||||
// Use the larger of pot-raise and min-raise
|
||||
let raise_total = pot_raise_total.max(min_raise_total);
|
||||
|
||||
// If raise total exceeds what player can afford, go all-in
|
||||
let max_total = wagered + stack;
|
||||
if raise_total >= max_total {
|
||||
Action::AllIn
|
||||
} else {
|
||||
Action::Raise { total: raise_total }
|
||||
}
|
||||
}
|
||||
BetLimit::PotLimit { .. } => {
|
||||
// Pot-Limit: maximum raise is pot + call_amount above the call
|
||||
// Max raise total = current_bet + call_amount + pot
|
||||
let max_raise_total = game_info.pot + call_amount + current_bet;
|
||||
|
||||
// The raise total must be at least current_bet + min_raise
|
||||
let min_raise_total = current_bet + game_info.min_raise;
|
||||
|
||||
// Clamp to valid range
|
||||
let raise_total = max_raise_total.max(min_raise_total);
|
||||
|
||||
// If player can't afford it, go all-in
|
||||
let max_total = wagered + stack;
|
||||
if raise_total >= max_total {
|
||||
Action::AllIn
|
||||
} else {
|
||||
Action::Raise { total: raise_total }
|
||||
}
|
||||
}
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet: _,
|
||||
} => {
|
||||
// Fixed-Limit: raise by the fixed bet size
|
||||
let fixed_size = game_info
|
||||
.bet_limit
|
||||
.fixed_bet_size(game_info.street)
|
||||
.unwrap_or(small_bet);
|
||||
|
||||
// Check if raises are still allowed (typically max 3 raises per round)
|
||||
// We approximate raises_this_round from actions
|
||||
let raises_this_round = count_raises(game_info);
|
||||
|
||||
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
|
||||
// Can't raise anymore, just call
|
||||
return if call_amount == 0 {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Call
|
||||
};
|
||||
}
|
||||
|
||||
let raise_total = current_bet + fixed_size;
|
||||
|
||||
// In fixed-limit, the raise amount is exactly the fixed bet size
|
||||
// If player can't afford it, go all-in
|
||||
let max_total = wagered + stack;
|
||||
if raise_total >= max_total {
|
||||
Action::AllIn
|
||||
} else {
|
||||
Action::Raise { total: raise_total }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_action: &Action,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_hole_cards: Option<&HoleCards>,
|
||||
_hand_value: Option<&HandValue>,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
/// Count the number of raises in the current betting round from the action history.
|
||||
///
|
||||
/// Both [`Action::Raise`] and [`Action::AllIn`] are counted as raises for the
|
||||
/// purpose of the Fixed-Limit cap check.
|
||||
fn count_raises(game_info: &GameInfo) -> usize {
|
||||
let street_index = match game_info.street {
|
||||
crate::Street::Preflop => 0,
|
||||
crate::Street::Flop => 1,
|
||||
crate::Street::Turn => 2,
|
||||
crate::Street::River => 3,
|
||||
};
|
||||
|
||||
game_info
|
||||
.actions_this_hand
|
||||
.get(street_index)
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter(|(_, a)| matches!(a, Action::Raise { .. } | Action::AllIn))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||
use crate::Street;
|
||||
|
||||
/// Helper to build a [`GameInfo`] for No-Limit testing.
|
||||
fn make_nl_game_info(
|
||||
current_bet: ChipCount,
|
||||
pot: ChipCount,
|
||||
stack: ChipCount,
|
||||
wagered_this_round: ChipCount,
|
||||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street: Street::Flop,
|
||||
board: vec![],
|
||||
pot,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(3),
|
||||
name: "RaiseBot".to_string(),
|
||||
seat: 0,
|
||||
stack,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a [`GameInfo`] for Fixed-Limit testing.
|
||||
fn make_fl_game_info(
|
||||
current_bet: ChipCount,
|
||||
pot: ChipCount,
|
||||
stack: ChipCount,
|
||||
wagered_this_round: ChipCount,
|
||||
street: Street,
|
||||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::FixedLimit {
|
||||
small_bet: 10,
|
||||
big_bet: 20,
|
||||
},
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street,
|
||||
board: vec![],
|
||||
pot,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(3),
|
||||
name: "RaiseBot".to_string(),
|
||||
seat: 0,
|
||||
stack,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a [`GameInfo`] for Pot-Limit testing.
|
||||
fn make_pl_game_info(
|
||||
current_bet: ChipCount,
|
||||
pot: ChipCount,
|
||||
stack: ChipCount,
|
||||
wagered_this_round: ChipCount,
|
||||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::PotLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street: Street::Flop,
|
||||
board: vec![],
|
||||
pot,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(3),
|
||||
name: "RaiseBot".to_string(),
|
||||
seat: 0,
|
||||
stack,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// --- No-Limit Tests ---
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_raises_pot_when_no_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// No bet, pot = 100, stack = 1000
|
||||
let game_info = make_nl_game_info(0, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Pot-size raise: pot(100) + call(0) + current_bet(0) = 100
|
||||
assert_eq!(action, Action::Raise { total: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_raises_pot_when_facing_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 20, pot = 100, stack = 1000, wagered = 0
|
||||
let game_info = make_nl_game_info(20, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Pot-size raise total: pot(100) + call(20) + current_bet(20) = 140
|
||||
assert_eq!(action, Action::Raise { total: 140 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_goes_all_in_when_cant_afford_raise() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 20, pot = 100, stack = 30, wagered = 0
|
||||
// call_amount = 20, raise would be to 140 but max_total = 0 + 30 = 30
|
||||
let game_info = make_nl_game_info(20, 100, 30, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::AllIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_goes_all_in_when_cant_afford_call() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 50, stack = 30, wagered = 0
|
||||
// call_amount = 50, stack < call_amount
|
||||
let game_info = make_nl_game_info(50, 100, 30, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::AllIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_folds_when_stack_zero_facing_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
let game_info = make_nl_game_info(50, 100, 0, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_nl_checks_when_stack_zero_no_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
let game_info = make_nl_game_info(0, 100, 0, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::Check);
|
||||
}
|
||||
|
||||
// --- Fixed-Limit Tests ---
|
||||
|
||||
#[test]
|
||||
fn raise_bot_fl_raises_fixed_bet_flop() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// Flop: fixed_bet_size = 10, current_bet = 0
|
||||
let game_info = make_fl_game_info(0, 100, 1000, 0, Street::Flop);
|
||||
let action = bot.act(&game_info);
|
||||
// Raise total = current_bet(0) + fixed_bet(10) = 10
|
||||
assert_eq!(action, Action::Raise { total: 10 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_fl_raises_fixed_bet_turn() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// Turn: fixed_bet_size = 20, current_bet = 0
|
||||
let game_info = make_fl_game_info(0, 100, 1000, 0, Street::Turn);
|
||||
let action = bot.act(&game_info);
|
||||
// Raise total = current_bet(0) + fixed_bet(20) = 20
|
||||
assert_eq!(action, Action::Raise { total: 20 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_fl_raises_over_current_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// Flop: fixed_bet_size = 10, current_bet = 10
|
||||
let game_info = make_fl_game_info(10, 100, 1000, 0, Street::Flop);
|
||||
let action = bot.act(&game_info);
|
||||
// Raise total = current_bet(10) + fixed_bet(10) = 20
|
||||
assert_eq!(action, Action::Raise { total: 20 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_fl_calls_when_max_raises_reached() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// Create game info with 3 raises already made on the flop (Fixed-Limit max)
|
||||
let mut game_info = make_fl_game_info(40, 100, 1000, 0, Street::Flop);
|
||||
// Index 0 = preflop (empty), Index 1 = flop (3 raises)
|
||||
game_info.actions_this_hand = vec![
|
||||
vec![],
|
||||
vec![
|
||||
(PlayerId(1), Action::Raise { total: 20 }),
|
||||
(PlayerId(2), Action::Raise { total: 30 }),
|
||||
(PlayerId(1), Action::Raise { total: 40 }),
|
||||
],
|
||||
];
|
||||
let action = bot.act(&game_info);
|
||||
// Can't raise anymore, should call
|
||||
assert_eq!(action, Action::Call);
|
||||
}
|
||||
|
||||
// --- Pot-Limit Tests ---
|
||||
|
||||
#[test]
|
||||
fn raise_bot_pl_raises_pot_size() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 0, pot = 100, stack = 1000
|
||||
let game_info = make_pl_game_info(0, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Pot-size raise total: pot(100) + call(0) + current_bet(0) = 100
|
||||
assert_eq!(action, Action::Raise { total: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_pl_raises_max_when_facing_bet() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 20, pot = 100, stack = 1000, wagered = 0
|
||||
let game_info = make_pl_game_info(20, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Max pot raise total: pot(100) + call(20) + current_bet(20) = 140
|
||||
assert_eq!(action, Action::Raise { total: 140 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_bot_pl_goes_all_in_when_cant_afford() {
|
||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||
// current_bet = 20, pot = 100, stack = 25, wagered = 0
|
||||
let game_info = make_pl_game_info(20, 100, 25, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::AllIn);
|
||||
}
|
||||
}
|
||||
411
holdem_core/src/game/bots/random_bot.rs
Normal file
411
holdem_core/src/game/bots/random_bot.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
//! A bot that picks a random legal action.
|
||||
//!
|
||||
//! [`RandomBot`] selects uniformly from the set of legal actions available at each
|
||||
//! decision point. For raise amounts, it picks a random amount between the minimum
|
||||
//! and maximum raise (or goes all-in).
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::betting::{BetLimit, BettingRules};
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::player::HoldemPlayer;
|
||||
use crate::game::types::{ChipCount, PlayerId};
|
||||
use crate::{HandValue, HoleCards};
|
||||
|
||||
/// A bot that **picks a random legal action** at each decision point.
|
||||
///
|
||||
/// The bot builds a list of all legal actions given the current game state and
|
||||
/// selects one uniformly at random. For raise actions, it picks a random total
|
||||
/// between the minimum and maximum allowed raise amounts.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::bots::RandomBot;
|
||||
/// use holdem_core::game::player::HoldemPlayer;
|
||||
/// use holdem_core::game::types::PlayerId;
|
||||
///
|
||||
/// let bot = RandomBot::new(PlayerId(4), "RandomBot-4".to_string());
|
||||
/// assert_eq!(bot.id(), PlayerId(4));
|
||||
/// assert_eq!(bot.name(), "RandomBot-4");
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct RandomBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl RandomBot {
|
||||
/// Creates a new [`RandomBot`] with the given player ID and display name.
|
||||
pub fn new(id: PlayerId, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
|
||||
/// Returns this player's current stack.
|
||||
fn stack(&self, game_info: &GameInfo) -> ChipCount {
|
||||
game_info
|
||||
.player_by_id(self.id)
|
||||
.map(|m| m.stack)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Computes the call amount for this player.
|
||||
fn call_amount(&self, game_info: &GameInfo) -> ChipCount {
|
||||
let wagered = game_info
|
||||
.player_by_id(self.id)
|
||||
.map(|m| m.wagered_this_round)
|
||||
.unwrap_or(0);
|
||||
game_info.current_bet.saturating_sub(wagered)
|
||||
}
|
||||
|
||||
/// Counts the number of raises in the current betting round.
|
||||
fn count_raises_current_street(&self, game_info: &GameInfo) -> usize {
|
||||
let street_index = match game_info.street {
|
||||
crate::Street::Preflop => 0,
|
||||
crate::Street::Flop => 1,
|
||||
crate::Street::Turn => 2,
|
||||
crate::Street::River => 3,
|
||||
};
|
||||
|
||||
game_info
|
||||
.actions_this_hand
|
||||
.get(street_index)
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Builds a list of legal actions and picks one at random.
|
||||
///
|
||||
/// The list always includes the "safe" actions (fold/check/call) and
|
||||
/// conditionally includes raise options with a randomly chosen total.
|
||||
fn pick_random_action(&self, game_info: &GameInfo) -> Action {
|
||||
let mut rng = rand::rng();
|
||||
let stack = self.stack(game_info);
|
||||
let call_amount = self.call_amount(game_info);
|
||||
let current_bet = game_info.current_bet;
|
||||
let wagered = game_info
|
||||
.player_by_id(self.id)
|
||||
.map(|m| m.wagered_this_round)
|
||||
.unwrap_or(0);
|
||||
|
||||
// If stack is 0, can't do anything aggressive
|
||||
if stack == 0 {
|
||||
return if call_amount == 0 {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Fold
|
||||
};
|
||||
}
|
||||
|
||||
// Build list of legal actions
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
|
||||
if call_amount == 0 {
|
||||
// No bet to call: can check
|
||||
actions.push(Action::Check);
|
||||
} else {
|
||||
// Facing a bet: can fold or call
|
||||
actions.push(Action::Fold);
|
||||
if stack <= call_amount {
|
||||
// Can't afford full call, go all-in
|
||||
actions.push(Action::AllIn);
|
||||
} else {
|
||||
actions.push(Action::Call);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if raising is possible
|
||||
let can_raise = game_info
|
||||
.bet_limit
|
||||
.can_raise(game_info.street, self.count_raises_current_street(game_info));
|
||||
|
||||
if can_raise && stack > call_amount {
|
||||
// Player has chips remaining after calling
|
||||
let chips_after_call = stack - call_amount;
|
||||
let min_raise_increment = game_info.min_raise;
|
||||
let min_raise_total = current_bet + min_raise_increment;
|
||||
let max_total = wagered + stack; // The absolute maximum the player can put in
|
||||
|
||||
if chips_after_call > 0 && max_total >= min_raise_total {
|
||||
// Can raise at least the minimum
|
||||
match game_info.bet_limit {
|
||||
BetLimit::NoLimit { .. } => {
|
||||
// Pick a random raise total between min_raise_total and max_total
|
||||
if min_raise_total <= max_total {
|
||||
let raise_total = rng.random_range(min_raise_total..=max_total);
|
||||
actions.push(Action::Raise { total: raise_total });
|
||||
}
|
||||
// All-in is always an option
|
||||
actions.push(Action::AllIn);
|
||||
}
|
||||
BetLimit::PotLimit { .. } => {
|
||||
let max_pot_raise = game_info.pot + call_amount + current_bet;
|
||||
let effective_max = max_pot_raise.min(max_total);
|
||||
|
||||
if min_raise_total <= effective_max {
|
||||
let raise_total =
|
||||
rng.random_range(min_raise_total..=effective_max);
|
||||
actions.push(Action::Raise { total: raise_total });
|
||||
}
|
||||
// All-in is always an option
|
||||
actions.push(Action::AllIn);
|
||||
}
|
||||
BetLimit::FixedLimit { .. } => {
|
||||
let fixed_size = game_info
|
||||
.bet_limit
|
||||
.fixed_bet_size(game_info.street)
|
||||
.unwrap_or(min_raise_increment);
|
||||
let raise_total = current_bet + fixed_size;
|
||||
|
||||
if raise_total <= max_total {
|
||||
actions.push(Action::Raise { total: raise_total });
|
||||
} else {
|
||||
actions.push(Action::AllIn);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if chips_after_call > 0 {
|
||||
// Can't raise minimum but can go all-in
|
||||
actions.push(Action::AllIn);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a random action from the legal list
|
||||
if actions.is_empty() {
|
||||
// Fallback: should not happen, but be safe
|
||||
if call_amount == 0 {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Fold
|
||||
}
|
||||
} else {
|
||||
let index = rng.random_range(0..actions.len());
|
||||
actions.swap_remove(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldemPlayer for RandomBot {
|
||||
fn id(&self) -> PlayerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn act(&mut self, game_info: &GameInfo) -> Action {
|
||||
self.pick_random_action(game_info)
|
||||
}
|
||||
|
||||
fn action_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_action: &Action,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_hole_cards: Option<&HoleCards>,
|
||||
_hand_value: Option<&HandValue>,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||
use crate::Street;
|
||||
|
||||
/// Helper to build a minimal [`GameInfo`] for testing.
|
||||
fn make_game_info(
|
||||
current_bet: ChipCount,
|
||||
pot: ChipCount,
|
||||
stack: ChipCount,
|
||||
wagered_this_round: ChipCount,
|
||||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
street: Street::Flop,
|
||||
board: vec![],
|
||||
pot,
|
||||
current_bet,
|
||||
min_raise: 10,
|
||||
button_seat: 0,
|
||||
small_blind_seat: 0,
|
||||
big_blind_seat: 1,
|
||||
next_to_act: Some(0),
|
||||
players: vec![Some(PlayerInfo {
|
||||
id: PlayerId(4),
|
||||
name: "RandomBot".to_string(),
|
||||
seat: 0,
|
||||
stack,
|
||||
wagered_this_round,
|
||||
wagered_this_hand: wagered_this_round,
|
||||
state: PlayerState::Active,
|
||||
has_acted_this_round: false,
|
||||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_returns_valid_action_no_bet() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
let game_info = make_game_info(0, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Should be Check, Raise, or AllIn (all legal when no bet)
|
||||
assert!(matches!(
|
||||
action,
|
||||
Action::Check | Action::Raise { .. } | Action::AllIn
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_returns_valid_action_facing_bet() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
let game_info = make_game_info(50, 100, 1000, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Should be Fold, Call, Raise, or AllIn
|
||||
assert!(matches!(
|
||||
action,
|
||||
Action::Fold | Action::Call | Action::Raise { .. } | Action::AllIn
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_checks_or_folds_when_stack_zero() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
|
||||
// No bet, stack 0 -> Check
|
||||
let game_info = make_game_info(0, 100, 0, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::Check);
|
||||
|
||||
// Facing bet, stack 0 -> Fold
|
||||
let game_info = make_game_info(50, 100, 0, 0);
|
||||
let action = bot.act(&game_info);
|
||||
assert_eq!(action, Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_can_go_all_in_when_short_stacked() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
// Facing a 50 bet with only 30 chips
|
||||
let game_info = make_game_info(50, 100, 30, 0);
|
||||
let action = bot.act(&game_info);
|
||||
// Should be Fold or AllIn (can't afford full call)
|
||||
assert!(matches!(action, Action::Fold | Action::AllIn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_produces_many_different_actions_over_iterations() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
let game_info = make_game_info(0, 100, 1000, 0);
|
||||
|
||||
let mut got_check = false;
|
||||
let mut got_raise = false;
|
||||
let mut got_allin = false;
|
||||
|
||||
// Run many iterations to check randomness
|
||||
for _ in 0..200 {
|
||||
match bot.act(&game_info) {
|
||||
Action::Check => got_check = true,
|
||||
Action::Raise { .. } => got_raise = true,
|
||||
Action::AllIn => got_allin = true,
|
||||
_ => {}
|
||||
}
|
||||
if got_check && got_raise && got_allin {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Over 200 iterations with 3 options, we should see all of them
|
||||
// (extremely unlikely to miss any)
|
||||
assert!(got_check, "Expected to see at least one Check action");
|
||||
assert!(got_raise, "Expected to see at least one Raise action");
|
||||
assert!(got_allin, "Expected to see at least one AllIn action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_facing_bet_produces_varied_actions() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
let game_info = make_game_info(50, 100, 1000, 0);
|
||||
|
||||
let mut got_fold = false;
|
||||
let mut got_call = false;
|
||||
let mut got_raise = false;
|
||||
let mut got_allin = false;
|
||||
|
||||
for _ in 0..200 {
|
||||
match bot.act(&game_info) {
|
||||
Action::Fold => got_fold = true,
|
||||
Action::Call => got_call = true,
|
||||
Action::Raise { .. } => got_raise = true,
|
||||
Action::AllIn => got_allin = true,
|
||||
Action::Check => panic!("Check should not be legal when facing a bet"),
|
||||
}
|
||||
if got_fold && got_call && got_raise && got_allin {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(got_fold, "Expected to see at least one Fold action");
|
||||
assert!(got_call, "Expected to see at least one Call action");
|
||||
assert!(got_raise, "Expected to see at least one Raise action");
|
||||
assert!(got_allin, "Expected to see at least one AllIn action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bot_raise_total_within_valid_range() {
|
||||
let mut bot = RandomBot::new(PlayerId(4), "RandomBot".to_string());
|
||||
let game_info = make_game_info(0, 100, 1000, 0);
|
||||
|
||||
for _ in 0..100 {
|
||||
if let Action::Raise { total } = bot.act(&game_info) {
|
||||
// min_raise_total = current_bet(0) + min_raise(10) = 10
|
||||
// max_total = wagered(0) + stack(1000) = 1000
|
||||
assert!(
|
||||
total >= 10,
|
||||
"Raise total {total} is below minimum 10"
|
||||
);
|
||||
assert!(
|
||||
total <= 1000,
|
||||
"Raise total {total} exceeds maximum 1000"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1058
holdem_core/src/game/config.rs
Normal file
1058
holdem_core/src/game/config.rs
Normal file
File diff suppressed because it is too large
Load diff
105
holdem_core/src/game/game_info.rs
Normal file
105
holdem_core/src/game/game_info.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Read-only game state snapshot for bots.
|
||||
//!
|
||||
//! Modeled after Meerkat's `com.biotools.meerkat.GameInfo` — a read-only snapshot
|
||||
//! of the current game state that is provided to bots so they can make decisions.
|
||||
//! Bots receive a `&GameInfo` reference in their `act()` callback and can query
|
||||
//! any public game state.
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::betting::BetLimit;
|
||||
use crate::game::player_info::PlayerInfo;
|
||||
use crate::game::types::{ChipCount, PlayerId, SeatIndex};
|
||||
use crate::{Card, Street};
|
||||
|
||||
/// Read-only snapshot of the current game state, provided to bots.
|
||||
///
|
||||
/// Corresponds to Meerkat's `com.biotools.meerkat.GameInfo`.
|
||||
///
|
||||
/// This struct is constructed by the engine and passed to players.
|
||||
/// Players should not construct this themselves.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameInfo {
|
||||
// --- Table configuration ---
|
||||
/// Number of seats at the table.
|
||||
pub num_seats: SeatIndex,
|
||||
|
||||
/// Betting structure for this game.
|
||||
pub bet_limit: BetLimit,
|
||||
|
||||
/// Small blind amount.
|
||||
pub small_blind: ChipCount,
|
||||
|
||||
/// Big blind amount.
|
||||
pub big_blind: ChipCount,
|
||||
|
||||
/// Ante amount per hand, 0 if no ante.
|
||||
pub ante: ChipCount,
|
||||
|
||||
// --- Hand state ---
|
||||
/// Current betting street.
|
||||
pub street: Street,
|
||||
|
||||
/// Community board cards. Empty preflop, 3 on flop, 4 on turn, 5 on river.
|
||||
pub board: Vec<Card>,
|
||||
|
||||
/// Total chips in the pot.
|
||||
pub pot: ChipCount,
|
||||
|
||||
/// Current outstanding bet to call.
|
||||
pub current_bet: ChipCount,
|
||||
|
||||
/// Minimum raise increment.
|
||||
pub min_raise: ChipCount,
|
||||
|
||||
// --- Player positions ---
|
||||
/// Seat index of the dealer button.
|
||||
pub button_seat: SeatIndex,
|
||||
|
||||
/// Seat index of the small blind.
|
||||
pub small_blind_seat: SeatIndex,
|
||||
|
||||
/// Seat index of the big blind.
|
||||
pub big_blind_seat: SeatIndex,
|
||||
|
||||
/// Seat index of the player who should act next.
|
||||
pub next_to_act: Option<SeatIndex>,
|
||||
|
||||
// --- Player information ---
|
||||
/// Information about all players at the table, indexed by seat.
|
||||
/// `None` for empty seats.
|
||||
pub players: Vec<Option<PlayerInfo>>,
|
||||
|
||||
// --- Hand history for current hand ---
|
||||
/// Actions taken so far in the current hand, grouped by street.
|
||||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
||||
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||
|
||||
// --- Game metadata ---
|
||||
/// Hand number within the session.
|
||||
pub hand_number: u64,
|
||||
|
||||
/// Number of active (non-folded, non-all-in) players.
|
||||
pub active_players: usize,
|
||||
}
|
||||
|
||||
impl GameInfo {
|
||||
/// Get the [`PlayerInfo`] for a specific seat, if occupied.
|
||||
pub fn player_at_seat(&self, seat: SeatIndex) -> Option<&PlayerInfo> {
|
||||
self.players.get(seat as usize).and_then(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Get the [`PlayerInfo`] for a specific player by ID.
|
||||
pub fn player_by_id(&self, id: PlayerId) -> Option<&PlayerInfo> {
|
||||
self.players.iter().flatten().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Get the number of community cards currently on the board.
|
||||
pub fn num_board_cards(&self) -> usize {
|
||||
self.board.len()
|
||||
}
|
||||
|
||||
/// Whether the hand is in a post-flop stage.
|
||||
pub fn is_postflop(&self) -> bool {
|
||||
self.street >= Street::Flop
|
||||
}
|
||||
}
|
||||
1482
holdem_core/src/game/hand_runner.rs
Normal file
1482
holdem_core/src/game/hand_runner.rs
Normal file
File diff suppressed because it is too large
Load diff
52
holdem_core/src/game/mod.rs
Normal file
52
holdem_core/src/game/mod.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Texas Hold'em simulation engine.
|
||||
//!
|
||||
//! This module provides the game engine components for running Texas Hold'em poker
|
||||
//! simulations, modeled after the Meerkat API from the University of Alberta's
|
||||
//! Poker Academy / OpenTestbed project.
|
||||
//!
|
||||
//! # Module Structure
|
||||
//!
|
||||
//! - [`types`] — Core shared types: [`PlayerId`], [`ChipCount`], [`SeatIndex`], [`GameError`]
|
||||
//! - [`action`] — Player action types and validation
|
||||
//! - [`player_info`] — Per-player state visible to all players
|
||||
//! - [`game_info`] — Read-only game state snapshot for bots
|
||||
//! - [`player`] — Standardized bot interface ([`HoldemPlayer`]) and internal [`Seat`] struct
|
||||
//! - [`betting`] — Betting structures and limits
|
||||
//! - [`pot`] — Pot management with side pot algorithm
|
||||
//! - [`hand_runner`] — Core state machine that executes a single hand
|
||||
//! - [`table`] — Table management with seating, button rotation, and hand execution
|
||||
//! - [`tournament`] — Tournament formats, blind schedules, and prize structures
|
||||
//! - [`config`] — TOML-based configuration system for game simulations
|
||||
|
||||
pub mod action;
|
||||
pub mod betting;
|
||||
pub mod bot_factory;
|
||||
pub mod bots;
|
||||
pub mod config;
|
||||
pub mod game_info;
|
||||
pub mod hand_runner;
|
||||
pub mod player;
|
||||
pub mod player_info;
|
||||
pub mod pot;
|
||||
pub mod simulation;
|
||||
pub mod stats;
|
||||
pub mod table;
|
||||
pub mod tournament;
|
||||
pub mod types;
|
||||
|
||||
// Re-export core types for convenience.
|
||||
pub use action::{Action, PlayerAction};
|
||||
pub use betting::BetLimit;
|
||||
pub use bot_factory::create_bot;
|
||||
pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot};
|
||||
pub use config::{BetLimitConfig, BotConfig, GameConfig, OutputConfig, SimulationConfig};
|
||||
pub use game_info::GameInfo;
|
||||
pub use hand_runner::{HandResult, HandRunner, HandState};
|
||||
pub use player::{HoldemPlayer, Seat};
|
||||
pub use player_info::{PlayerInfo, PlayerState};
|
||||
pub use pot::{Pot, PotManager, PotWinner};
|
||||
pub use simulation::run_cash_game;
|
||||
pub use stats::{PlayerStats, SimulationStats};
|
||||
pub use table::{Table, TableConfig};
|
||||
pub use tournament::{BlindLevel, BlindSchedule, GameType, PrizeStructure, Tournament};
|
||||
pub use types::{ChipCount, GameError, PlayerId, SeatIndex, MAX_SEATS, MIN_PLAYERS};
|
||||
722
holdem_core/src/game/player.rs
Normal file
722
holdem_core/src/game/player.rs
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
//! Standardized poker bot interface and internal seat state.
|
||||
//!
|
||||
//! This module defines the [`HoldemPlayer`] trait — the core bot interface modeled
|
||||
//! after the Meerkat API's `com.biotools.meerkat.Player` — and the [`Seat`] struct
|
||||
//! used internally by the engine to bind a player to a table position.
|
||||
//!
|
||||
//! # Meerkat API Compatibility
|
||||
//!
|
||||
//! The [`HoldemPlayer`] trait maps directly to the Meerkat `Player` interface:
|
||||
//!
|
||||
//! | Meerkat Method | `HoldemPlayer` Method |
|
||||
//! |-------------------------|---------------------------|
|
||||
//! | `init` | [`HoldemPlayer::init`] |
|
||||
//! | `gameStartEvent` | [`HoldemPlayer::game_start_event`] |
|
||||
//! | `dealHoleCards` | [`HoldemPlayer::hole_cards_dealt`] |
|
||||
//! | `stageEvent` | [`HoldemPlayer::stage_event`] |
|
||||
//! | `act` | [`HoldemPlayer::act`] |
|
||||
//! | `actionEvent` | [`HoldemPlayer::action_event`] |
|
||||
//! | `showdownEvent` | [`HoldemPlayer::showdown_event`] |
|
||||
//! | `gameOverEvent` | [`HoldemPlayer::game_over_event`] |
|
||||
|
||||
use crate::game::action::Action;
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||
use crate::game::types::{ChipCount, PlayerId, SeatIndex};
|
||||
use crate::{HandValue, HoleCards};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HoldemPlayer Trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The standardized poker bot interface.
|
||||
///
|
||||
/// Modeled after the Meerkat API's `com.biotools.meerkat.Player`. Every bot (and
|
||||
/// every human player adapter) must implement this trait. The simulation engine
|
||||
/// drives the game loop and invokes the trait methods at well-defined points in
|
||||
/// the hand lifecycle.
|
||||
///
|
||||
/// # Lifecycle
|
||||
///
|
||||
/// The engine calls trait methods in the following order during a single hand:
|
||||
///
|
||||
/// 1. **[`init`](HoldemPlayer::init)** — Called once when the player is first
|
||||
/// seated at a table. Provides the initial game configuration.
|
||||
/// 2. **[`game_start_event`](HoldemPlayer::game_start_event)** — Called at the
|
||||
/// start of each hand, before blinds are posted.
|
||||
/// 3. **[`hole_cards_dealt`](HoldemPlayer::hole_cards_dealt)** — Called when
|
||||
/// this player's hole cards are dealt. Only this player receives their hole
|
||||
/// cards; opponents do not see them until showdown.
|
||||
/// 4. **[`stage_event`](HoldemPlayer::stage_event)** — Called when a new
|
||||
/// betting street begins (preflop, flop, turn, river).
|
||||
/// 5. **[`act`](HoldemPlayer::act)** — Called when it is this player's turn.
|
||||
/// Returns an [`Action`] representing the player's decision.
|
||||
/// 6. **[`action_event`](HoldemPlayer::action_event)** — Called when **any**
|
||||
/// player at the table takes an action (including this player). Useful for
|
||||
/// tracking opponent behaviour.
|
||||
/// 7. **[`showdown_event`](HoldemPlayer::showdown_event)** — Called at
|
||||
/// showdown when hands are revealed. Only invoked for hands that reach
|
||||
/// showdown — not when everyone folds to a single player.
|
||||
/// 8. **[`game_over_event`](HoldemPlayer::game_over_event)** — Called when
|
||||
/// the hand is complete, after pot distribution.
|
||||
/// 9. **[`on_eliminated`](HoldemPlayer::on_eliminated)** — Called when the
|
||||
/// player is eliminated from a tournament.
|
||||
///
|
||||
/// # Design Decision: Synchronous Trait
|
||||
///
|
||||
/// The trait is intentionally synchronous. The simulation engine drives the game
|
||||
/// loop and calls [`act()`](HoldemPlayer::act) when it is a player's turn.
|
||||
/// For bot players, the implementation computes and returns immediately.
|
||||
/// For human players, the implementation blocks until the human responds — the
|
||||
/// surrounding infrastructure handles async networking, timeouts, and channel
|
||||
/// communication outside this trait.
|
||||
///
|
||||
/// # Thread Safety
|
||||
///
|
||||
/// The [`Send + Sync`] bound ensures that players can be shared across threads
|
||||
/// when the engine uses parallel hand execution in tournament scenarios.
|
||||
pub trait HoldemPlayer: Send + Sync {
|
||||
/// Returns the unique identifier for this player.
|
||||
///
|
||||
/// The engine uses this to correlate actions, bets, and showdown results
|
||||
/// with the correct player instance.
|
||||
fn id(&self) -> PlayerId;
|
||||
|
||||
/// Returns the display name of this player.
|
||||
///
|
||||
/// Shown in UIs, logs, and hand histories. Should be a human-readable
|
||||
/// string (e.g., `"Bot-Alice"`, `"Human-Bob"`).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Called once when the player is seated at a table.
|
||||
///
|
||||
/// Provides the initial [`GameInfo`] so the player can inspect the table
|
||||
/// configuration (number of seats, betting structure, blind levels, etc.)
|
||||
/// before the first hand begins.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_info` — Read-only snapshot of the current game configuration.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#init`.
|
||||
fn init(&mut self, game_info: &GameInfo);
|
||||
|
||||
/// Called at the start of each hand, before blinds are posted.
|
||||
///
|
||||
/// Use this callback to reset per-hand state in the bot's internal model.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_info` — Current read-only game state snapshot.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#gameStartEvent`.
|
||||
fn game_start_event(&mut self, game_info: &GameInfo);
|
||||
|
||||
/// Called when this player's hole cards are dealt.
|
||||
///
|
||||
/// Only this player receives their hole cards — other players do not see
|
||||
/// them until showdown. Use this callback to update the bot's internal
|
||||
/// hand representation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `hole_cards` — The two private cards dealt to this player.
|
||||
/// * `game_info` — Current read-only game state snapshot.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#dealHoleCards`.
|
||||
fn hole_cards_dealt(&mut self, hole_cards: &HoleCards, game_info: &GameInfo);
|
||||
|
||||
/// Called when a new betting street begins (preflop, flop, turn, river).
|
||||
///
|
||||
/// The [`GameInfo`] reflects the new street, including any newly dealt
|
||||
/// community cards. Use this callback to update street-specific state.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_info` — Current read-only game state snapshot with updated
|
||||
/// street and board cards.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#stageEvent`.
|
||||
fn stage_event(&mut self, game_info: &GameInfo);
|
||||
|
||||
/// Called when it is this player's turn to act.
|
||||
///
|
||||
/// This is the core decision-making method. The player should examine
|
||||
/// `game_info` and return an [`Action`] representing the chosen move.
|
||||
///
|
||||
/// The engine validates the returned action. If the action is illegal, the
|
||||
/// engine may request a new action or apply a default (typically [`Fold`]).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_info` — Current read-only game state snapshot.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The [`Action`] this player wishes to take.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#act`.
|
||||
fn act(&mut self, game_info: &GameInfo) -> Action;
|
||||
|
||||
/// Called when **any** player at the table takes an action, including this player.
|
||||
///
|
||||
/// This allows bots to track opponent behaviour for statistics gathering,
|
||||
/// opponent modelling, and hand history construction.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `player_id` — The [`PlayerId`] of the player who performed the action.
|
||||
/// * `action` — The [`Action`] that was taken.
|
||||
/// * `game_info` — Current read-only game state snapshot (reflecting the
|
||||
/// action that was just applied).
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#actionEvent`.
|
||||
fn action_event(
|
||||
&mut self,
|
||||
player_id: PlayerId,
|
||||
action: &Action,
|
||||
game_info: &GameInfo,
|
||||
);
|
||||
|
||||
/// Called at showdown when hands are revealed.
|
||||
///
|
||||
/// Invoked once per player who reaches showdown. Provides the player's hole
|
||||
/// cards (if visible) and the evaluated hand value. Only called for hands
|
||||
/// that reach showdown — not when all opponents fold.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `player_id` — The [`PlayerId`] of the player whose hand is being revealed.
|
||||
/// * `hole_cards` — The player's hole cards, or [`None`] if they are hidden
|
||||
/// (e.g., the losing player mucks in some configurations).
|
||||
/// * `hand_value` — The evaluated [`HandValue`] of the player's best hand,
|
||||
/// or [`None`] if hole cards are not visible.
|
||||
/// * `game_info` — Current read-only game state snapshot.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#showdownEvent`.
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
player_id: PlayerId,
|
||||
hole_cards: Option<&HoleCards>,
|
||||
hand_value: Option<&HandValue>,
|
||||
game_info: &GameInfo,
|
||||
);
|
||||
|
||||
/// Called when the hand is complete, after pot distribution.
|
||||
///
|
||||
/// Use this callback to record hand results, update statistics, and
|
||||
/// prepare for the next hand.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_info` — Final read-only game state snapshot for the completed hand.
|
||||
///
|
||||
/// # Meerkat API
|
||||
///
|
||||
/// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`.
|
||||
fn game_over_event(&mut self, game_info: &GameInfo);
|
||||
|
||||
/// Signal an early-fold intent.
|
||||
///
|
||||
/// Called by the framework layer when a player wants to fold before it is
|
||||
/// their turn. The engine stores this as a pending flag and applies it as
|
||||
/// [`Action::Fold`] when the player's turn arrives. If the hand ends before
|
||||
/// their turn (e.g., all other players fold), the early fold has no effect.
|
||||
///
|
||||
/// The default implementation is a no-op — bots that do not support early
|
||||
/// fold can simply ignore this.
|
||||
fn early_fold(&mut self) {}
|
||||
|
||||
/// Called when this player is eliminated from a tournament.
|
||||
///
|
||||
/// Provides the final placement for prize determination. Not called in
|
||||
/// cash games — only in tournament formats (SNG, MTT).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `placement` — The player's finishing position (1-based). A placement
|
||||
/// of `1` means the player won the tournament.
|
||||
fn on_eliminated(&self, placement: u32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seat Struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Internal seat state maintained by the engine.
|
||||
///
|
||||
/// A [`Seat`] binds a [`HoldemPlayer`] implementation to a specific position at
|
||||
/// the table. This struct is **not** exposed to players — they see the public
|
||||
/// [`PlayerInfo`] snapshot instead (produced by [`Seat::to_player_info`]).
|
||||
///
|
||||
/// The engine creates one `Seat` per occupied position and updates its fields
|
||||
/// as the hand progresses (posting blinds, dealing cards, processing actions).
|
||||
pub struct Seat {
|
||||
/// Position at the table (0-based index).
|
||||
pub index: SeatIndex,
|
||||
|
||||
/// Unique identifier of the player occupying this seat.
|
||||
/// Cached from `player.id()` for efficient access.
|
||||
pub player_id: PlayerId,
|
||||
|
||||
/// The player implementation (bot or human adapter).
|
||||
pub player: Box<dyn HoldemPlayer>,
|
||||
|
||||
/// Current chip stack. Decremented when posting blinds/antes or wagering,
|
||||
/// incremented when winning a pot.
|
||||
pub stack: ChipCount,
|
||||
|
||||
/// Chips wagered in the current betting round only.
|
||||
/// Reset to `0` at the start of each new betting street.
|
||||
pub wagered_this_round: ChipCount,
|
||||
|
||||
/// Cumulative chips wagered across all betting rounds in the current hand.
|
||||
/// Reset to `0` at the start of each new hand.
|
||||
pub wagered_this_hand: ChipCount,
|
||||
|
||||
/// Current participation state within the hand.
|
||||
pub state: PlayerState,
|
||||
|
||||
/// Hole cards dealt this hand, or [`None`] if not yet dealt.
|
||||
pub hole_cards: Option<HoleCards>,
|
||||
|
||||
/// Whether this player has acted (bet, called, raised, or folded) in the
|
||||
/// current betting round. Reset at the start of each new street.
|
||||
pub has_acted_this_round: bool,
|
||||
|
||||
/// Pending early-fold flag. Set to `true` when the player signals an
|
||||
/// early fold intent; applied as [`Action::Fold`] when it becomes this
|
||||
/// player's turn.
|
||||
pub early_fold_pending: bool,
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// Creates a new [`Seat`] for the given player at the given position.
|
||||
///
|
||||
/// The player's stack is initialised to `buy_in` chips. All wagered counters
|
||||
/// are set to zero, the state is [`PlayerState::Active`], and no hole cards
|
||||
/// are dealt.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `index` — The 0-based seat position at the table.
|
||||
/// * `player` — A boxed implementation of the [`HoldemPlayer`] trait.
|
||||
/// * `buy_in` — The initial chip count for this seat.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use holdem_core::game::player::{HoldemPlayer, Seat};
|
||||
/// use holdem_core::game::types::{PlayerId, SeatIndex};
|
||||
///
|
||||
/// // Assuming `my_bot` implements `HoldemPlayer`:
|
||||
/// let seat = Seat::new(0, Box::new(my_bot), 1000);
|
||||
/// assert_eq!(seat.index, 0);
|
||||
/// assert_eq!(seat.stack, 1000);
|
||||
/// ```
|
||||
pub fn new(index: SeatIndex, player: Box<dyn HoldemPlayer>, buy_in: ChipCount) -> Self {
|
||||
let player_id = player.id();
|
||||
Self {
|
||||
index,
|
||||
player_id,
|
||||
player,
|
||||
stack: buy_in,
|
||||
wagered_this_round: 0,
|
||||
wagered_this_hand: 0,
|
||||
state: PlayerState::Active,
|
||||
hole_cards: None,
|
||||
has_acted_this_round: false,
|
||||
early_fold_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts this seat into a public [`PlayerInfo`] snapshot.
|
||||
///
|
||||
/// The engine calls this method to construct the `PlayerInfo` that is
|
||||
/// visible to all players through [`GameInfo`]. Positional flags (dealer,
|
||||
/// small blind, big blind) are supplied by the caller because they depend
|
||||
/// on the current hand's button position.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `is_dealer` — Whether this seat holds the dealer button.
|
||||
/// * `is_small_blind` — Whether this seat is the small blind.
|
||||
/// * `is_big_blind` — Whether this seat is the big blind.
|
||||
pub fn to_player_info(
|
||||
&self,
|
||||
is_dealer: bool,
|
||||
is_small_blind: bool,
|
||||
is_big_blind: bool,
|
||||
) -> PlayerInfo {
|
||||
PlayerInfo {
|
||||
id: self.player_id,
|
||||
name: self.player.name().to_string(),
|
||||
seat: self.index,
|
||||
stack: self.stack,
|
||||
wagered_this_round: self.wagered_this_round,
|
||||
wagered_this_hand: self.wagered_this_hand,
|
||||
state: self.state,
|
||||
has_acted_this_round: self.has_acted_this_round,
|
||||
is_dealer,
|
||||
is_small_blind,
|
||||
is_big_blind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the player is still active in the current hand.
|
||||
///
|
||||
/// A player is considered active if their state is [`PlayerState::Active`]
|
||||
/// or [`PlayerState::AllIn`]. Folded and sitting-out players are not active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.state, PlayerState::Active | PlayerState::AllIn)
|
||||
}
|
||||
|
||||
/// Returns `true` if the player can take an action right now.
|
||||
///
|
||||
/// A player can act only if their state is [`PlayerState::Active`] — they
|
||||
/// have not folded, are not all-in, and are not sitting out. An all-in
|
||||
/// player is still *in* the hand (see [`is_active`](Seat::is_active)) but
|
||||
/// cannot take further actions.
|
||||
pub fn can_act(&self) -> bool {
|
||||
self.state == PlayerState::Active
|
||||
}
|
||||
|
||||
/// Resets all per-hand state for a new hand.
|
||||
///
|
||||
/// Clears wagered amounts, hole cards, the acted flag, and the early-fold
|
||||
/// pending flag. Sets the player state back to [`PlayerState::Active`].
|
||||
///
|
||||
/// Called by the engine between hands.
|
||||
pub fn reset_for_new_hand(&mut self) {
|
||||
self.wagered_this_round = 0;
|
||||
self.wagered_this_hand = 0;
|
||||
self.hole_cards = None;
|
||||
self.has_acted_this_round = false;
|
||||
self.early_fold_pending = false;
|
||||
self.state = PlayerState::Active;
|
||||
}
|
||||
|
||||
/// Resets per-round (per-street) state for a new betting round.
|
||||
///
|
||||
/// Clears the wagered-this-round counter and the acted flag. Called by the
|
||||
/// engine at the start of each new betting street (flop, turn, river).
|
||||
pub fn reset_for_new_round(&mut self) {
|
||||
self.wagered_this_round = 0;
|
||||
self.has_acted_this_round = false;
|
||||
}
|
||||
|
||||
/// Posts a blind (small blind or big blind) from this seat's stack.
|
||||
///
|
||||
/// If the player's stack is less than `amount`, the entire remaining stack
|
||||
/// is posted (short-stacked / all-in scenario). The player's state is
|
||||
/// updated to [`PlayerState::AllIn`] in that case.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `amount` — The requested blind amount.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The actual number of chips posted (may be less than `amount` if the
|
||||
/// player is short-stacked).
|
||||
pub fn post_blind(&mut self, amount: ChipCount) -> ChipCount {
|
||||
let actual = amount.min(self.stack);
|
||||
self.stack -= actual;
|
||||
self.wagered_this_round += actual;
|
||||
self.wagered_this_hand += actual;
|
||||
if self.stack == 0 && actual > 0 {
|
||||
self.state = PlayerState::AllIn;
|
||||
}
|
||||
actual
|
||||
}
|
||||
|
||||
/// Posts an ante from this seat's stack.
|
||||
///
|
||||
/// Antes are posted by all players before the hand begins. If the player's
|
||||
/// stack is less than `amount`, the entire remaining stack is posted
|
||||
/// (short-stacked / all-in scenario).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `amount` — The requested ante amount.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The actual number of chips posted (may be less than `amount` if the
|
||||
/// player is short-stacked).
|
||||
pub fn post_ante(&mut self, amount: ChipCount) -> ChipCount {
|
||||
let actual = amount.min(self.stack);
|
||||
self.stack -= actual;
|
||||
self.wagered_this_round += actual;
|
||||
self.wagered_this_hand += actual;
|
||||
if self.stack == 0 && actual > 0 {
|
||||
self.state = PlayerState::AllIn;
|
||||
}
|
||||
actual
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::action::Action;
|
||||
use crate::game::game_info::GameInfo;
|
||||
use crate::game::types::PlayerId;
|
||||
|
||||
/// A minimal `HoldemPlayer` implementation for testing.
|
||||
struct TestBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TestBot {
|
||||
fn new(id: u64, name: &str) -> Self {
|
||||
Self {
|
||||
id: PlayerId(id),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldemPlayer for TestBot {
|
||||
fn id(&self) -> PlayerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn act(&mut self, _game_info: &GameInfo) -> Action {
|
||||
Action::Fold
|
||||
}
|
||||
|
||||
fn action_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_action: &Action,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn showdown_event(
|
||||
&mut self,
|
||||
_player_id: PlayerId,
|
||||
_hole_cards: Option<&HoleCards>,
|
||||
_hand_value: Option<&HandValue>,
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seat_new_initializes_fields_correctly() {
|
||||
let bot = TestBot::new(42, "Alice");
|
||||
let seat = Seat::new(3, Box::new(bot), 1000);
|
||||
|
||||
assert_eq!(seat.index, 3);
|
||||
assert_eq!(seat.player_id, PlayerId(42));
|
||||
assert_eq!(seat.stack, 1000);
|
||||
assert_eq!(seat.wagered_this_round, 0);
|
||||
assert_eq!(seat.wagered_this_hand, 0);
|
||||
assert_eq!(seat.state, PlayerState::Active);
|
||||
assert!(seat.hole_cards.is_none());
|
||||
assert!(!seat.has_acted_this_round);
|
||||
assert!(!seat.early_fold_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_player_info_sets_positional_flags() {
|
||||
let bot = TestBot::new(1, "Bob");
|
||||
let seat = Seat::new(0, Box::new(bot), 500);
|
||||
|
||||
let info = seat.to_player_info(true, false, false);
|
||||
assert_eq!(info.id, PlayerId(1));
|
||||
assert_eq!(info.name, "Bob");
|
||||
assert_eq!(info.seat, 0);
|
||||
assert_eq!(info.stack, 500);
|
||||
assert!(info.is_dealer);
|
||||
assert!(!info.is_small_blind);
|
||||
assert!(!info.is_big_blind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_active_true_for_active_and_allin() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 100);
|
||||
|
||||
seat.state = PlayerState::Active;
|
||||
assert!(seat.is_active());
|
||||
|
||||
seat.state = PlayerState::AllIn;
|
||||
assert!(seat.is_active());
|
||||
|
||||
seat.state = PlayerState::Folded;
|
||||
assert!(!seat.is_active());
|
||||
|
||||
seat.state = PlayerState::SittingOut;
|
||||
assert!(!seat.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_act_true_only_for_active_state() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 100);
|
||||
|
||||
seat.state = PlayerState::Active;
|
||||
assert!(seat.can_act());
|
||||
|
||||
seat.state = PlayerState::AllIn;
|
||||
assert!(!seat.can_act());
|
||||
|
||||
seat.state = PlayerState::Folded;
|
||||
assert!(!seat.can_act());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_for_new_hand_clears_state() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 100);
|
||||
|
||||
// Simulate some state.
|
||||
seat.wagered_this_round = 50;
|
||||
seat.wagered_this_hand = 150;
|
||||
seat.has_acted_this_round = true;
|
||||
seat.early_fold_pending = true;
|
||||
seat.state = PlayerState::Folded;
|
||||
|
||||
seat.reset_for_new_hand();
|
||||
|
||||
assert_eq!(seat.wagered_this_round, 0);
|
||||
assert_eq!(seat.wagered_this_hand, 0);
|
||||
assert!(seat.hole_cards.is_none());
|
||||
assert!(!seat.has_acted_this_round);
|
||||
assert!(!seat.early_fold_pending);
|
||||
assert_eq!(seat.state, PlayerState::Active);
|
||||
// Stack is NOT reset.
|
||||
assert_eq!(seat.stack, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_for_new_round_clears_round_state() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 100);
|
||||
|
||||
seat.wagered_this_round = 80;
|
||||
seat.wagered_this_hand = 200;
|
||||
seat.has_acted_this_round = true;
|
||||
|
||||
seat.reset_for_new_round();
|
||||
|
||||
assert_eq!(seat.wagered_this_round, 0);
|
||||
assert!(!seat.has_acted_this_round);
|
||||
// Hand-level state is preserved.
|
||||
assert_eq!(seat.wagered_this_hand, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_blind_full_amount() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 1000);
|
||||
|
||||
let posted = seat.post_blind(50);
|
||||
assert_eq!(posted, 50);
|
||||
assert_eq!(seat.stack, 950);
|
||||
assert_eq!(seat.wagered_this_round, 50);
|
||||
assert_eq!(seat.wagered_this_hand, 50);
|
||||
assert_eq!(seat.state, PlayerState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_blind_short_stacked() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 30);
|
||||
|
||||
let posted = seat.post_blind(50);
|
||||
assert_eq!(posted, 30);
|
||||
assert_eq!(seat.stack, 0);
|
||||
assert_eq!(seat.wagered_this_round, 30);
|
||||
assert_eq!(seat.wagered_this_hand, 30);
|
||||
assert_eq!(seat.state, PlayerState::AllIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_blind_zero_stack() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 0);
|
||||
|
||||
let posted = seat.post_blind(50);
|
||||
assert_eq!(posted, 0);
|
||||
assert_eq!(seat.stack, 0);
|
||||
assert_eq!(seat.state, PlayerState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_ante_full_amount() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 1000);
|
||||
|
||||
let posted = seat.post_ante(25);
|
||||
assert_eq!(posted, 25);
|
||||
assert_eq!(seat.stack, 975);
|
||||
assert_eq!(seat.wagered_this_round, 25);
|
||||
assert_eq!(seat.wagered_this_hand, 25);
|
||||
assert_eq!(seat.state, PlayerState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_ante_short_stacked() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 10);
|
||||
|
||||
let posted = seat.post_ante(25);
|
||||
assert_eq!(posted, 10);
|
||||
assert_eq!(seat.stack, 0);
|
||||
assert_eq!(seat.wagered_this_round, 10);
|
||||
assert_eq!(seat.wagered_this_hand, 10);
|
||||
assert_eq!(seat.state, PlayerState::AllIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_blind_then_ante_accumulates() {
|
||||
let bot = TestBot::new(1, "A");
|
||||
let mut seat = Seat::new(0, Box::new(bot), 1000);
|
||||
|
||||
seat.post_blind(50);
|
||||
seat.post_ante(10);
|
||||
|
||||
assert_eq!(seat.wagered_this_round, 60);
|
||||
assert_eq!(seat.wagered_this_hand, 60);
|
||||
assert_eq!(seat.stack, 940);
|
||||
}
|
||||
}
|
||||
81
holdem_core/src/game/player_info.rs
Normal file
81
holdem_core/src/game/player_info.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Per-player state visible to all players.
|
||||
//!
|
||||
//! Modeled after Meerkat's `com.biotools.meerkat.PlayerInfo` — this is the
|
||||
//! public-facing view of a player. Private information like hole cards is only
|
||||
//! included for the requesting player (via the [`HoldemPlayer`](crate::game::player::HoldemPlayer) trait).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::types::{ChipCount, PlayerId, SeatIndex};
|
||||
|
||||
/// Status of a player within the current hand.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlayerState {
|
||||
/// Player is active and has not folded.
|
||||
Active,
|
||||
|
||||
/// Player has folded this hand.
|
||||
Folded,
|
||||
|
||||
/// Player is all-in; cannot take further actions.
|
||||
AllIn,
|
||||
|
||||
/// Player is sitting out this hand but remains seated.
|
||||
SittingOut,
|
||||
}
|
||||
|
||||
/// Public information about a player at the table.
|
||||
///
|
||||
/// Corresponds to Meerkat's `com.biotools.meerkat.PlayerInfo`.
|
||||
///
|
||||
/// This is what other players can see. Hole cards are **not** included here —
|
||||
/// they are provided separately through
|
||||
/// [`HoldemPlayer::hole_cards_dealt()`](crate::game::player::HoldemPlayer::hole_cards_dealt).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayerInfo {
|
||||
/// Unique player identifier.
|
||||
pub id: PlayerId,
|
||||
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
|
||||
/// Seat position at the table.
|
||||
pub seat: SeatIndex,
|
||||
|
||||
/// Current chip stack.
|
||||
pub stack: ChipCount,
|
||||
|
||||
/// Chips wagered in the current betting round.
|
||||
pub wagered_this_round: ChipCount,
|
||||
|
||||
/// Chips wagered across all betting rounds in the current hand.
|
||||
pub wagered_this_hand: ChipCount,
|
||||
|
||||
/// Current participation state.
|
||||
pub state: PlayerState,
|
||||
|
||||
/// Whether this player has acted in the current betting round.
|
||||
pub has_acted_this_round: bool,
|
||||
|
||||
/// Whether this player is the dealer.
|
||||
pub is_dealer: bool,
|
||||
|
||||
/// Whether this player is the small blind.
|
||||
pub is_small_blind: bool,
|
||||
|
||||
/// Whether this player is the big blind.
|
||||
pub is_big_blind: bool,
|
||||
}
|
||||
|
||||
impl PlayerInfo {
|
||||
/// Whether the player is still active in the current hand
|
||||
/// (has not folded and is not sitting out).
|
||||
pub fn is_in_hand(&self) -> bool {
|
||||
matches!(self.state, PlayerState::Active | PlayerState::AllIn)
|
||||
}
|
||||
|
||||
/// Whether the player can take an action (active, not all-in).
|
||||
pub fn can_act(&self) -> bool {
|
||||
self.state == PlayerState::Active
|
||||
}
|
||||
}
|
||||
691
holdem_core/src/game/pot.rs
Normal file
691
holdem_core/src/game/pot.rs
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
//! Pot management system including side pots for all-in scenarios.
|
||||
//!
|
||||
//! This module provides the [`PotManager`] struct which tracks player wagers
|
||||
//! and creates the pot structure (main pot + side pots) after betting is
|
||||
//! complete. It also handles distribution of pots to winners at showdown.
|
||||
//!
|
||||
//! # Side Pot Algorithm
|
||||
//!
|
||||
//! When a player goes all-in for less than the current bet, side pots are
|
||||
//! created so that each player can only win chips they have contributed to:
|
||||
//!
|
||||
//! 1. Collect all unique wager amounts, sorted ascending.
|
||||
//! 2. For each threshold level, create a pot where each player contributes
|
||||
//! `min(their_wager, threshold - previous_threshold)`.
|
||||
//! 3. Eligible players for each pot are those who are active (not folded)
|
||||
//! AND whose total wager >= threshold.
|
||||
//!
|
||||
//! Example: Player A bets 100 (all-in), Player B bets 200 (all-in),
|
||||
//! Player C bets 200 (calls):
|
||||
//! - Pot 1: 300 chips (100 from each), eligible: A, B, C
|
||||
//! - Pot 2: 200 chips (100 from B + 100 from C), eligible: B, C
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::evaluator::evaluator::HandValue;
|
||||
use crate::game::types::{ChipCount, PlayerId};
|
||||
|
||||
/// A single pot — either the main pot or a side pot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pot {
|
||||
/// Total chips in this pot.
|
||||
pub amount: ChipCount,
|
||||
/// Players eligible to win this pot.
|
||||
pub eligible_players: Vec<PlayerId>,
|
||||
}
|
||||
|
||||
impl Pot {
|
||||
/// Creates a new empty pot.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
amount: 0,
|
||||
eligible_players: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds chips to this pot.
|
||||
pub fn add(&mut self, amount: ChipCount) {
|
||||
self.amount += amount;
|
||||
}
|
||||
|
||||
/// Returns `true` if the given player is eligible to win this pot.
|
||||
pub fn is_eligible(&self, player_id: PlayerId) -> bool {
|
||||
self.eligible_players.contains(&player_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Pot {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of pot distribution at showdown.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PotWinner {
|
||||
/// The player who won (a share of) the pot.
|
||||
pub player_id: PlayerId,
|
||||
/// The amount awarded to this player.
|
||||
pub amount: ChipCount,
|
||||
/// The hand value that won the pot.
|
||||
pub hand_value: HandValue,
|
||||
}
|
||||
|
||||
/// Manages pot creation and distribution, including side pots for all-in scenarios.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// 1. Call [`place_bet`](PotManager::place_bet) as players put chips in during betting.
|
||||
/// 2. After all betting rounds complete, call
|
||||
/// [`finalize_pots`](PotManager::finalize_pots) with the list of players
|
||||
/// still active (not folded).
|
||||
/// 3. At showdown, call [`distribute`](PotManager::distribute) with hand values
|
||||
/// to determine winners.
|
||||
/// 4. Call [`clear`](PotManager::clear) to reset for the next hand.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PotManager {
|
||||
pots: Vec<Pot>,
|
||||
wagers: HashMap<PlayerId, ChipCount>,
|
||||
}
|
||||
|
||||
impl PotManager {
|
||||
/// Creates a new empty pot manager.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pots: Vec::new(),
|
||||
wagers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a bet from a player. Adds to their existing wager.
|
||||
pub fn place_bet(&mut self, player_id: PlayerId, amount: ChipCount) {
|
||||
*self.wagers.entry(player_id).or_insert(0) += amount;
|
||||
}
|
||||
|
||||
/// Returns the total amount wagered by all players.
|
||||
pub fn total_pot(&self) -> ChipCount {
|
||||
self.wagers.values().sum()
|
||||
}
|
||||
|
||||
/// Returns the total wager for a specific player.
|
||||
pub fn player_wager(&self, player_id: PlayerId) -> ChipCount {
|
||||
self.wagers.get(&player_id).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Creates the pot structure after all betting is complete.
|
||||
///
|
||||
/// This implements the side pot algorithm:
|
||||
///
|
||||
/// 1. Collect all unique wager amounts from all players (both active and
|
||||
/// folded), sorted ascending.
|
||||
/// 2. For each threshold level, create a pot:
|
||||
/// - Each player contributes `min(their_wager, threshold - previous_threshold)`
|
||||
/// to this pot.
|
||||
/// - Eligible players are those who are active (not folded) AND whose
|
||||
/// total wager >= threshold.
|
||||
/// 3. The result is a series of pots from smallest to largest, where each
|
||||
/// pot has its own set of eligible players.
|
||||
pub fn finalize_pots(&mut self, active_player_ids: &[PlayerId]) {
|
||||
self.pots.clear();
|
||||
|
||||
let active_set: HashMap<PlayerId, bool> = active_player_ids
|
||||
.iter()
|
||||
.map(|&id| (id, true))
|
||||
.collect();
|
||||
|
||||
// Step 1: Collect all unique wager amounts, sorted ascending.
|
||||
let mut thresholds: Vec<ChipCount> = self.wagers.values().copied().collect();
|
||||
thresholds.sort();
|
||||
thresholds.dedup();
|
||||
|
||||
if thresholds.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: For each threshold, create a pot layer.
|
||||
let mut prev_threshold: ChipCount = 0;
|
||||
for &threshold in &thresholds {
|
||||
let mut pot = Pot::new();
|
||||
|
||||
// Each player contributes min(their_wager, threshold - prev_threshold)
|
||||
// to this pot.
|
||||
let contribution = threshold - prev_threshold;
|
||||
for (&player_id, &wager) in &self.wagers {
|
||||
let player_contribution = wager.saturating_sub(prev_threshold).min(contribution);
|
||||
if player_contribution > 0 {
|
||||
pot.add(player_contribution);
|
||||
}
|
||||
// Eligible: active AND total wager >= threshold.
|
||||
if wager >= threshold && active_set.contains_key(&player_id) {
|
||||
pot.eligible_players.push(player_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add non-empty pots.
|
||||
if pot.amount > 0 {
|
||||
self.pots.push(pot);
|
||||
}
|
||||
|
||||
prev_threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
/// Distributes each pot to its winner(s).
|
||||
///
|
||||
/// For each pot:
|
||||
/// 1. Find eligible players with the best hand value.
|
||||
/// 2. If tied, split the pot evenly (floor division, remainder goes to
|
||||
/// first player in order).
|
||||
/// 3. Returns a `Vec<PotWinner>` for each pot.
|
||||
pub fn distribute(
|
||||
&self,
|
||||
hand_values: &HashMap<PlayerId, HandValue>,
|
||||
) -> Vec<Vec<PotWinner>> {
|
||||
self.pots
|
||||
.iter()
|
||||
.map(|pot| {
|
||||
// Find the best hand value among eligible players.
|
||||
let best_value = pot
|
||||
.eligible_players
|
||||
.iter()
|
||||
.filter_map(|id| hand_values.get(id))
|
||||
.max()
|
||||
.copied();
|
||||
|
||||
let best_value = match best_value {
|
||||
Some(v) => v,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
// Collect all eligible players with the best hand.
|
||||
let winners: Vec<PlayerId> = pot
|
||||
.eligible_players
|
||||
.iter()
|
||||
.filter(|&&id| {
|
||||
hand_values
|
||||
.get(&id)
|
||||
.is_some_and(|hv| *hv == best_value)
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if winners.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let share = pot.amount / winners.len() as ChipCount;
|
||||
let remainder = pot.amount % winners.len() as ChipCount;
|
||||
|
||||
winners
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, player_id)| PotWinner {
|
||||
player_id,
|
||||
amount: share + if i < remainder as usize { 1 } else { 0 },
|
||||
hand_value: best_value,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the finalized pots.
|
||||
pub fn pots(&self) -> &[Pot] {
|
||||
&self.pots
|
||||
}
|
||||
|
||||
/// Resets the pot manager for the next hand.
|
||||
pub fn clear(&mut self) {
|
||||
self.pots.clear();
|
||||
self.wagers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PotManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::evaluator::evaluator::HandRank;
|
||||
|
||||
/// Helper to create a PlayerId.
|
||||
fn pid(id: u64) -> PlayerId {
|
||||
PlayerId(id)
|
||||
}
|
||||
|
||||
/// Helper to create a simple HandValue for testing.
|
||||
fn hv(rank: HandRank, value: u32) -> HandValue {
|
||||
HandValue::new(rank, value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_pot_three_players() {
|
||||
// 3 players each bet 100, one pot of 300, winner takes all.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 100);
|
||||
pm.place_bet(pid(3), 100);
|
||||
|
||||
assert_eq!(pm.total_pot(), 300);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 1);
|
||||
assert_eq!(pots[0].amount, 300);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
assert!(pots[0].is_eligible(pid(2)));
|
||||
assert!(pots[0].is_eligible(pid(3)));
|
||||
|
||||
// Player 2 wins.
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::Pair, 1)),
|
||||
(pid(2), hv(HandRank::TwoPair, 1)),
|
||||
(pid(3), hv(HandRank::Pair, 2)),
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].len(), 1);
|
||||
assert_eq!(results[0][0].player_id, pid(2));
|
||||
assert_eq!(results[0][0].amount, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_side_pot_two_all_ins() {
|
||||
// Player A all-in 50, Player B all-in 100, Player C calls 100.
|
||||
// Pot 1: 150 (50 from each), eligible: A, B, C
|
||||
// Pot 2: 100 (50 from B + 50 from C), eligible: B, C
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 50); // A: all-in 50
|
||||
pm.place_bet(pid(2), 100); // B: all-in 100
|
||||
pm.place_bet(pid(3), 100); // C: calls 100
|
||||
|
||||
assert_eq!(pm.total_pot(), 250);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 2);
|
||||
|
||||
// Pot 1 (main pot): 150, eligible: A, B, C
|
||||
assert_eq!(pots[0].amount, 150);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
assert!(pots[0].is_eligible(pid(2)));
|
||||
assert!(pots[0].is_eligible(pid(3)));
|
||||
|
||||
// Pot 2 (side pot): 100, eligible: B, C
|
||||
assert_eq!(pots[1].amount, 100);
|
||||
assert!(!pots[1].is_eligible(pid(1)));
|
||||
assert!(pots[1].is_eligible(pid(2)));
|
||||
assert!(pots[1].is_eligible(pid(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_side_pot_three_all_ins() {
|
||||
// Three different all-in amounts creating three pot layers.
|
||||
// Player A: 30, Player B: 60, Player C: 90, Player D: calls 90.
|
||||
// Pot 1: 120 (30 × 4), eligible: A, B, C, D
|
||||
// Pot 2: 90 (30 × 3), eligible: B, C, D
|
||||
// Pot 3: 60 (30 × 2), eligible: C, D
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 30); // A: all-in 30
|
||||
pm.place_bet(pid(2), 60); // B: all-in 60
|
||||
pm.place_bet(pid(3), 90); // C: all-in 90
|
||||
pm.place_bet(pid(4), 90); // D: calls 90
|
||||
|
||||
assert_eq!(pm.total_pot(), 270);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3), pid(4)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 3);
|
||||
|
||||
// Pot 1: 120, eligible: A, B, C, D
|
||||
assert_eq!(pots[0].amount, 120);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
assert!(pots[0].is_eligible(pid(2)));
|
||||
assert!(pots[0].is_eligible(pid(3)));
|
||||
assert!(pots[0].is_eligible(pid(4)));
|
||||
|
||||
// Pot 2: 90, eligible: B, C, D
|
||||
assert_eq!(pots[1].amount, 90);
|
||||
assert!(!pots[1].is_eligible(pid(1)));
|
||||
assert!(pots[1].is_eligible(pid(2)));
|
||||
assert!(pots[1].is_eligible(pid(3)));
|
||||
assert!(pots[1].is_eligible(pid(4)));
|
||||
|
||||
// Pot 3: 60, eligible: C, D
|
||||
assert_eq!(pots[2].amount, 60);
|
||||
assert!(!pots[2].is_eligible(pid(1)));
|
||||
assert!(!pots[2].is_eligible(pid(2)));
|
||||
assert!(pots[2].is_eligible(pid(3)));
|
||||
assert!(pots[2].is_eligible(pid(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_pot_two_way_tie() {
|
||||
// Two players tie, pot is split evenly.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 100);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 1);
|
||||
assert_eq!(pots[0].amount, 200);
|
||||
|
||||
// Both have the same hand value.
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::Pair, 5)),
|
||||
(pid(2), hv(HandRank::Pair, 5)),
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].len(), 2);
|
||||
// Each gets 100.
|
||||
assert_eq!(results[0][0].amount, 100);
|
||||
assert_eq!(results[0][1].amount, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_pot_odd_amount() {
|
||||
// 3 players with different wagers, all active, all tied.
|
||||
// Wagers: 34, 33, 33 → total 100.
|
||||
// Pot 1 (threshold 33): 99 chips (33 from each), eligible: all 3.
|
||||
// Pot 2 (threshold 34): 1 chip (1 from pid(1)), eligible: only pid(1).
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 34);
|
||||
pm.place_bet(pid(2), 33);
|
||||
pm.place_bet(pid(3), 33);
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 2);
|
||||
assert_eq!(pots[0].amount, 99);
|
||||
assert_eq!(pots[1].amount, 1);
|
||||
|
||||
// All three tie.
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::Pair, 5)),
|
||||
(pid(2), hv(HandRank::Pair, 5)),
|
||||
(pid(3), hv(HandRank::Pair, 5)),
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
|
||||
// Pot 1: 99 / 3 = 33 each.
|
||||
assert_eq!(results[0].len(), 3);
|
||||
assert_eq!(results[0][0].amount, 33);
|
||||
assert_eq!(results[0][1].amount, 33);
|
||||
assert_eq!(results[0][2].amount, 33);
|
||||
|
||||
// Pot 2: only pid(1) eligible, wins 1 chip.
|
||||
assert_eq!(results[1].len(), 1);
|
||||
assert_eq!(results[1][0].amount, 1);
|
||||
assert_eq!(results[1][0].player_id, pid(1));
|
||||
|
||||
// Total conserved: 99 + 1 = 100.
|
||||
let total: ChipCount = results
|
||||
.iter()
|
||||
.flat_map(|w| w.iter().map(|x| x.amount))
|
||||
.sum();
|
||||
assert_eq!(total, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_folded_player_contribution() {
|
||||
// A player who folded still contributes to pots but is not eligible.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100); // Active
|
||||
pm.place_bet(pid(2), 50); // Folded (all-in then folded — contributed 50)
|
||||
pm.place_bet(pid(3), 100); // Active
|
||||
|
||||
// Player 2 is NOT in the active list (folded).
|
||||
pm.finalize_pots(&[pid(1), pid(3)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 2);
|
||||
|
||||
// Pot 1: 150 (50 from each), eligible: 1, 3 (not 2)
|
||||
assert_eq!(pots[0].amount, 150);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
assert!(!pots[0].is_eligible(pid(2)));
|
||||
assert!(pots[0].is_eligible(pid(3)));
|
||||
|
||||
// Pot 2: 100 (50 from 1 + 50 from 3), eligible: 1, 3
|
||||
assert_eq!(pots[1].amount, 100);
|
||||
assert!(!pots[1].is_eligible(pid(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_stack_all_in() {
|
||||
// Player goes all-in for less than the big blind.
|
||||
// Player A: 10 (short stack all-in), Player B: 100, Player C: 100.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 10); // Short stack
|
||||
pm.place_bet(pid(2), 100);
|
||||
pm.place_bet(pid(3), 100);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 2);
|
||||
|
||||
// Pot 1: 30 (10 from each), eligible: A, B, C
|
||||
assert_eq!(pots[0].amount, 30);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
|
||||
// Pot 2: 180 (90 from B + 90 from C), eligible: B, C
|
||||
assert_eq!(pots[1].amount, 180);
|
||||
assert!(!pots[1].is_eligible(pid(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chip_conservation() {
|
||||
// Total distributed must equal total wagered.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 200);
|
||||
pm.place_bet(pid(3), 200);
|
||||
pm.place_bet(pid(4), 50);
|
||||
|
||||
let total_wagered = pm.total_pot();
|
||||
assert_eq!(total_wagered, 550);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3), pid(4)]);
|
||||
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::TwoPair, 10)),
|
||||
(pid(2), hv(HandRank::FullHouse, 5)),
|
||||
(pid(3), hv(HandRank::ThreeOfAKind, 8)),
|
||||
(pid(4), hv(HandRank::Flush, 3)),
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
|
||||
let total_distributed: ChipCount = results
|
||||
.iter()
|
||||
.flat_map(|winners| winners.iter().map(|w| w.amount))
|
||||
.sum();
|
||||
|
||||
assert_eq!(total_distributed, total_wagered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chip_conservation_with_folded_players() {
|
||||
// Folded players' chips must still be distributed.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 150); // Will fold
|
||||
pm.place_bet(pid(3), 150);
|
||||
|
||||
let total_wagered = pm.total_pot();
|
||||
|
||||
// Player 2 folded.
|
||||
pm.finalize_pots(&[pid(1), pid(3)]);
|
||||
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::Pair, 5)),
|
||||
(pid(3), hv(HandRank::TwoPair, 3)),
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
|
||||
let total_distributed: ChipCount = results
|
||||
.iter()
|
||||
.flat_map(|winners| winners.iter().map(|w| w.amount))
|
||||
.sum();
|
||||
|
||||
assert_eq!(total_distributed, total_wagered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_resets_state() {
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 100);
|
||||
pm.finalize_pots(&[pid(1), pid(2)]);
|
||||
|
||||
assert_eq!(pm.total_pot(), 200);
|
||||
assert!(!pm.pots().is_empty());
|
||||
|
||||
pm.clear();
|
||||
|
||||
assert_eq!(pm.total_pot(), 0);
|
||||
assert!(pm.pots().is_empty());
|
||||
assert_eq!(pm.player_wager(pid(1)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_place_bet_accumulates() {
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 50);
|
||||
assert_eq!(pm.player_wager(pid(1)), 50);
|
||||
pm.place_bet(pid(1), 30);
|
||||
assert_eq!(pm.player_wager(pid(1)), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_player_wager_unknown_player() {
|
||||
let pm = PotManager::new();
|
||||
assert_eq!(pm.player_wager(pid(99)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_pot_manager() {
|
||||
let pm = PotManager::new();
|
||||
assert_eq!(pm.total_pot(), 0);
|
||||
assert!(pm.pots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finalize_no_wagers() {
|
||||
let mut pm = PotManager::new();
|
||||
pm.finalize_pots(&[]);
|
||||
assert!(pm.pots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distribute_no_eligible_with_hand_values() {
|
||||
// Pot with eligible players but none have hand values provided.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 100);
|
||||
pm.finalize_pots(&[pid(1), pid(2)]);
|
||||
|
||||
// Empty hand values — no one can win.
|
||||
let hand_values = HashMap::new();
|
||||
let results = pm.distribute(&hand_values);
|
||||
|
||||
// Each pot should produce an empty winners list.
|
||||
for winners in &results {
|
||||
assert!(winners.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_side_pot_distribution_different_winners() {
|
||||
// Player A all-in 50, Player B all-in 100, Player C calls 100.
|
||||
// A wins main pot, C wins side pot.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 50); // A
|
||||
pm.place_bet(pid(2), 100); // B
|
||||
pm.place_bet(pid(3), 100); // C
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3)]);
|
||||
|
||||
let hand_values = HashMap::from([
|
||||
(pid(1), hv(HandRank::ThreeOfAKind, 1)), // A: best
|
||||
(pid(2), hv(HandRank::Pair, 1)), // B: worst
|
||||
(pid(3), hv(HandRank::TwoPair, 1)), // C: middle
|
||||
]);
|
||||
|
||||
let results = pm.distribute(&hand_values);
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
// Main pot: A wins (best hand among eligible A, B, C).
|
||||
assert_eq!(results[0].len(), 1);
|
||||
assert_eq!(results[0][0].player_id, pid(1));
|
||||
assert_eq!(results[0][0].amount, 150);
|
||||
|
||||
// Side pot: C wins (best hand among eligible B, C).
|
||||
assert_eq!(results[1].len(), 1);
|
||||
assert_eq!(results[1][0].player_id, pid(3));
|
||||
assert_eq!(results[1][0].amount, 100);
|
||||
|
||||
// Total distributed = total wagered.
|
||||
let total: ChipCount = results
|
||||
.iter()
|
||||
.flat_map(|w| w.iter().map(|x| x.amount))
|
||||
.sum();
|
||||
assert_eq!(total, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_equal_wagers() {
|
||||
// All players bet the same amount — single pot.
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 100);
|
||||
pm.place_bet(pid(3), 100);
|
||||
pm.place_bet(pid(4), 100);
|
||||
|
||||
pm.finalize_pots(&[pid(1), pid(2), pid(3), pid(4)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
assert_eq!(pots.len(), 1);
|
||||
assert_eq!(pots[0].amount, 400);
|
||||
assert_eq!(pots[0].eligible_players.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_player_pot() {
|
||||
// Edge case: only one player (everyone else folded).
|
||||
let mut pm = PotManager::new();
|
||||
pm.place_bet(pid(1), 100);
|
||||
pm.place_bet(pid(2), 50); // Folded
|
||||
|
||||
pm.finalize_pots(&[pid(1)]);
|
||||
|
||||
let pots = pm.pots();
|
||||
// Two wager levels: 50 and 100.
|
||||
assert_eq!(pots.len(), 2);
|
||||
|
||||
// Pot 1: 100 (50 from each), eligible: only pid(1).
|
||||
assert_eq!(pots[0].amount, 100);
|
||||
assert!(pots[0].is_eligible(pid(1)));
|
||||
assert!(!pots[0].is_eligible(pid(2)));
|
||||
|
||||
// Pot 2: 50 (50 from pid(1)), eligible: only pid(1).
|
||||
assert_eq!(pots[1].amount, 50);
|
||||
assert!(pots[1].is_eligible(pid(1)));
|
||||
assert!(!pots[1].is_eligible(pid(2)));
|
||||
}
|
||||
}
|
||||
329
holdem_core/src/game/simulation.rs
Normal file
329
holdem_core/src/game/simulation.rs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
//! Cash game simulation runner.
|
||||
//!
|
||||
//! Provides [`run_cash_game`] which creates a table, seats bots from
|
||||
//! configuration, runs the specified number of hands, and collects
|
||||
//! statistics.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::bot_factory;
|
||||
use super::config::GameConfig;
|
||||
use super::stats::SimulationStats;
|
||||
use super::table::Table;
|
||||
use super::types::{ChipCount, GameError, PlayerId};
|
||||
|
||||
/// Runs a cash game simulation based on the given configuration.
|
||||
///
|
||||
/// # Workflow
|
||||
///
|
||||
/// 1. Creates a [`Table`] from `config.to_table_config()`
|
||||
/// 2. Creates bots from `config.bots` via [`bot_factory::create_bot`]
|
||||
/// 3. Seats bots at the table (using `BotConfig.seat` if specified, otherwise auto-assigns)
|
||||
/// 4. Runs `config.num_hands` hands
|
||||
/// 5. Collects statistics via [`SimulationStats`]
|
||||
/// 6. Returns `(SimulationStats, Table)` with final state
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`GameError`] if:
|
||||
/// - A bot type is unknown
|
||||
/// - The table is full
|
||||
/// - There are not enough players
|
||||
/// - A hand fails to execute
|
||||
pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), GameError> {
|
||||
// Validate that we have at least 2 bots.
|
||||
if config.bots.len() < 2 {
|
||||
return Err(GameError::NotEnoughPlayers(config.bots.len()));
|
||||
}
|
||||
|
||||
// 1. Create the table.
|
||||
let table_config = config.to_table_config();
|
||||
let mut table = Table::new(table_config);
|
||||
|
||||
// Apply seed if configured.
|
||||
if let Some(seed) = config.seed {
|
||||
table = table.with_seed(seed);
|
||||
}
|
||||
|
||||
// 2. Create bots and track player names.
|
||||
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
||||
let mut starting_stacks: HashMap<PlayerId, ChipCount> = HashMap::new();
|
||||
|
||||
for (i, bot_config) in config.bots.iter().enumerate() {
|
||||
let player_id = PlayerId(i as u64);
|
||||
let bot = bot_factory::create_bot(bot_config, player_id).map_err(GameError::Custom)?;
|
||||
|
||||
// Store the name before seating (seating consumes the bot).
|
||||
let name = bot.name().to_string();
|
||||
player_names.insert(player_id, name);
|
||||
starting_stacks.insert(player_id, config.starting_chips);
|
||||
|
||||
// 3. Seat the bot.
|
||||
let seat_index = bot_config
|
||||
.seat
|
||||
.unwrap_or_else(|| table.find_empty_seat().unwrap_or(i as u8));
|
||||
|
||||
table.seat_player(seat_index, bot, config.starting_chips)?;
|
||||
}
|
||||
|
||||
// 4. Run hands and collect stats.
|
||||
let mut stats = SimulationStats::new("Cash");
|
||||
|
||||
for _ in 0..config.num_hands {
|
||||
match table.run_hand() {
|
||||
Ok(result) => {
|
||||
stats.record_hand_result(&result, &player_names);
|
||||
}
|
||||
Err(GameError::NotEnoughPlayers(_)) => {
|
||||
// Not enough active players to continue.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Record final stacks for accurate profit calculation.
|
||||
let final_stacks: HashMap<PlayerId, ChipCount> = table
|
||||
.seats()
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
slot.as_ref()
|
||||
.map(|seat| (seat.player_id, seat.stack))
|
||||
})
|
||||
.collect();
|
||||
stats.record_final_stacks(&final_stacks, &starting_stacks);
|
||||
|
||||
Ok((stats, table))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::config::{BetLimitConfig, BotConfig};
|
||||
use crate::game::tournament::GameType;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_cash_config(num_hands: u64, seed: Option<u64>) -> GameConfig {
|
||||
GameConfig {
|
||||
game_type: GameType::Cash,
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
starting_chips: 10_000,
|
||||
buy_in: 0,
|
||||
num_tables: 1,
|
||||
hands_per_level: 10,
|
||||
num_blind_levels: 15,
|
||||
num_hands,
|
||||
seed,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Alice".to_string()),
|
||||
seat: Some(0),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Bob".to_string()),
|
||||
seat: Some(1),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
BotConfig {
|
||||
bot_type: "fold_bot".to_string(),
|
||||
name: Some("Charlie".to_string()),
|
||||
seat: Some(2),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_basic() {
|
||||
let config = make_cash_config(10, Some(42));
|
||||
let result = run_cash_game(&config);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (stats, _table) = result.unwrap();
|
||||
assert_eq!(stats.total_hands, 10);
|
||||
assert_eq!(stats.players.len(), 3);
|
||||
assert_eq!(stats.game_type, "Cash");
|
||||
|
||||
// All players should have played 10 hands.
|
||||
for stats in stats.players.values() {
|
||||
assert_eq!(stats.hands_played, 10);
|
||||
}
|
||||
|
||||
// Total profit across all players should sum to zero (zero-sum game).
|
||||
let total_profit: i64 = stats.players.values().map(|p| p.total_profit).sum();
|
||||
assert_eq!(total_profit, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_reproducible_with_seed() {
|
||||
let config1 = make_cash_config(50, Some(12345));
|
||||
let config2 = make_cash_config(50, Some(12345));
|
||||
|
||||
let (stats1, _) = run_cash_game(&config1).unwrap();
|
||||
let (stats2, _) = run_cash_game(&config2).unwrap();
|
||||
|
||||
// Same seed should produce identical results.
|
||||
for pid in stats1.players.keys() {
|
||||
let p1 = stats1.players.get(pid).unwrap();
|
||||
let p2 = stats2.players.get(pid).unwrap();
|
||||
assert_eq!(p1.total_profit, p2.total_profit);
|
||||
assert_eq!(p1.hands_won, p2.hands_won);
|
||||
assert_eq!(p1.final_stack, p2.final_stack);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_different_seeds_differ() {
|
||||
let config1 = make_cash_config(50, Some(111));
|
||||
let config2 = make_cash_config(50, Some(222));
|
||||
|
||||
let (stats1, _) = run_cash_game(&config1).unwrap();
|
||||
let (stats2, _) = run_cash_game(&config2).unwrap();
|
||||
|
||||
// Different seeds should (almost certainly) produce different results.
|
||||
let profits1: Vec<i64> = stats1
|
||||
.players
|
||||
.values()
|
||||
.map(|p| p.total_profit)
|
||||
.collect();
|
||||
let profits2: Vec<i64> = stats2
|
||||
.players
|
||||
.values()
|
||||
.map(|p| p.total_profit)
|
||||
.collect();
|
||||
|
||||
assert_ne!(profits1, profits2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_not_enough_bots() {
|
||||
let config = GameConfig {
|
||||
game_type: GameType::Cash,
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
starting_chips: 10_000,
|
||||
buy_in: 0,
|
||||
num_tables: 1,
|
||||
hands_per_level: 10,
|
||||
num_blind_levels: 15,
|
||||
num_hands: 10,
|
||||
seed: None,
|
||||
bots: vec![BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Lonely".to_string()),
|
||||
seat: None,
|
||||
parameters: HashMap::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = run_cash_game(&config);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_unknown_bot_type() {
|
||||
let config = GameConfig {
|
||||
game_type: GameType::Cash,
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
starting_chips: 10_000,
|
||||
buy_in: 0,
|
||||
num_tables: 1,
|
||||
hands_per_level: 10,
|
||||
num_blind_levels: 15,
|
||||
num_hands: 10,
|
||||
seed: None,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Alice".to_string()),
|
||||
seat: Some(0),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
BotConfig {
|
||||
bot_type: "super_ai".to_string(),
|
||||
name: Some("Bob".to_string()),
|
||||
seat: Some(1),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result = run_cash_game(&config);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_auto_seat_assignment() {
|
||||
let config = GameConfig {
|
||||
game_type: GameType::Cash,
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
starting_chips: 10_000,
|
||||
buy_in: 0,
|
||||
num_tables: 1,
|
||||
hands_per_level: 10,
|
||||
num_blind_levels: 15,
|
||||
num_hands: 5,
|
||||
seed: Some(99),
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("A".to_string()),
|
||||
seat: None, // auto-assign
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
BotConfig {
|
||||
bot_type: "fold_bot".to_string(),
|
||||
name: Some("B".to_string()),
|
||||
seat: None, // auto-assign
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result = run_cash_game(&config);
|
||||
assert!(result.is_ok());
|
||||
let (stats, table) = result.unwrap();
|
||||
assert_eq!(stats.total_hands, 5);
|
||||
// Both players should be seated.
|
||||
assert_eq!(table.seated_player_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cash_game_summary() {
|
||||
let config = make_cash_config(20, Some(42));
|
||||
let (stats, _) = run_cash_game(&config).unwrap();
|
||||
|
||||
let summary = stats.summary(10_000);
|
||||
assert!(summary.contains("=== Simulation Results ==="));
|
||||
assert!(summary.contains("Cash"));
|
||||
assert!(summary.contains("20"));
|
||||
assert!(summary.contains("Alice"));
|
||||
assert!(summary.contains("Bob"));
|
||||
assert!(summary.contains("Charlie"));
|
||||
}
|
||||
}
|
||||
611
holdem_core/src/game/stats.rs
Normal file
611
holdem_core/src/game/stats.rs
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
//! Per-player statistics accumulator for simulation runs.
|
||||
//!
|
||||
//! Collects performance metrics across multiple hands (cash games) or entire
|
||||
//! tournaments, producing human-readable summary tables.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::hand_runner::HandResult;
|
||||
use super::tournament::Tournament;
|
||||
use super::types::{ChipCount, PlayerId};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlayerStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-player statistics accumulated across a simulation run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlayerStats {
|
||||
/// Unique player identifier.
|
||||
pub player_id: PlayerId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Total number of hands this player participated in.
|
||||
pub hands_played: u64,
|
||||
/// Number of hands where this player won chips (net positive).
|
||||
pub hands_won: u64,
|
||||
/// Net chips won or lost (positive = profit, negative = loss).
|
||||
pub total_profit: i64,
|
||||
/// Total chips put into pots across all hands.
|
||||
pub total_wagered: ChipCount,
|
||||
/// Largest single pot won by this player.
|
||||
pub biggest_pot_won: ChipCount,
|
||||
/// Stack at the end of the simulation.
|
||||
pub final_stack: ChipCount,
|
||||
}
|
||||
|
||||
impl PlayerStats {
|
||||
/// Creates a new `PlayerStats` with zeroed counters.
|
||||
pub fn new(player_id: PlayerId, name: String) -> Self {
|
||||
Self {
|
||||
player_id,
|
||||
name,
|
||||
hands_played: 0,
|
||||
hands_won: 0,
|
||||
total_profit: 0,
|
||||
total_wagered: 0,
|
||||
biggest_pot_won: 0,
|
||||
final_stack: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return on investment as a percentage.
|
||||
///
|
||||
/// ROI = `(total_profit / buy_in) * 100`.
|
||||
/// Returns `0.0` if `buy_in` is zero.
|
||||
pub fn roi(&self, buy_in: ChipCount) -> f64 {
|
||||
if buy_in == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.total_profit as f64 / buy_in as f64) * 100.0
|
||||
}
|
||||
|
||||
/// Win rate: fraction of hands played that were won.
|
||||
///
|
||||
/// Returns `0.0` if no hands have been played.
|
||||
pub fn win_rate(&self) -> f64 {
|
||||
if self.hands_played == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.hands_won as f64 / self.hands_played as f64
|
||||
}
|
||||
|
||||
/// Average profit per hand.
|
||||
///
|
||||
/// Returns `0.0` if no hands have been played.
|
||||
pub fn avg_profit_per_hand(&self) -> f64 {
|
||||
if self.hands_played == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.total_profit as f64 / self.hands_played as f64
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SimulationStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Accumulated statistics for all players in a simulation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SimulationStats {
|
||||
/// Per-player statistics keyed by [`PlayerId`].
|
||||
pub players: HashMap<PlayerId, PlayerStats>,
|
||||
/// Total number of hands played across the simulation.
|
||||
pub total_hands: u64,
|
||||
/// Game type label (e.g. `"Cash"`, `"SitAndGo"`).
|
||||
pub game_type: String,
|
||||
}
|
||||
|
||||
impl SimulationStats {
|
||||
/// Creates an empty `SimulationStats`.
|
||||
pub fn new(game_type: &str) -> Self {
|
||||
Self {
|
||||
players: HashMap::new(),
|
||||
total_hands: 0,
|
||||
game_type: game_type.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the result of a single cash-game hand.
|
||||
///
|
||||
/// For each player in `result.chip_movements`, updates their stats:
|
||||
/// - Increments `hands_played`
|
||||
/// - Increments `hands_won` if net positive
|
||||
/// - Adds net movement to `total_profit`
|
||||
/// - Updates `biggest_pot_won` if this hand's win exceeds the previous max
|
||||
///
|
||||
/// Players not yet tracked are inserted using names from `player_names`.
|
||||
pub fn record_hand_result(
|
||||
&mut self,
|
||||
result: &HandResult,
|
||||
player_names: &HashMap<PlayerId, String>,
|
||||
) {
|
||||
self.total_hands += 1;
|
||||
|
||||
for (&player_id, &movement) in &result.chip_movements {
|
||||
let stats = self
|
||||
.players
|
||||
.entry(player_id)
|
||||
.or_insert_with(|| {
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||
PlayerStats::new(player_id, name)
|
||||
});
|
||||
|
||||
stats.hands_played += 1;
|
||||
|
||||
// `movement` is i64: positive = net won, negative = net lost.
|
||||
stats.total_profit += movement;
|
||||
|
||||
if movement > 0 {
|
||||
stats.hands_won += 1;
|
||||
if (movement as u64) > stats.biggest_pot_won {
|
||||
stats.biggest_pot_won = movement as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the final stacks from a table into the stats.
|
||||
///
|
||||
/// Call this after the simulation loop ends to set `final_stack` and
|
||||
/// compute `total_profit` accurately (final_stack - starting_stack).
|
||||
pub fn record_final_stacks(
|
||||
&mut self,
|
||||
stacks: &HashMap<PlayerId, ChipCount>,
|
||||
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
||||
) {
|
||||
for (&player_id, &final_stack) in stacks {
|
||||
if let Some(stats) = self.players.get_mut(&player_id) {
|
||||
stats.final_stack = final_stack;
|
||||
let starting = starting_stacks.get(&player_id).copied().unwrap_or(0);
|
||||
stats.total_profit = final_stack as i64 - starting as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records tournament results from a completed [`Tournament`].
|
||||
///
|
||||
/// Uses the tournament's elimination standings to compute placements and
|
||||
/// profits based on the buy-in and prize structure.
|
||||
pub fn record_tournament_result(
|
||||
&mut self,
|
||||
tournament: &Tournament,
|
||||
player_names: &HashMap<PlayerId, String>,
|
||||
buy_in: ChipCount,
|
||||
) {
|
||||
self.total_hands = tournament.total_hands();
|
||||
|
||||
// Process standings: eliminated players are listed (player_id, placement).
|
||||
// Placement is the order of elimination (higher = eliminated earlier).
|
||||
// The last player standing (winner) is NOT in the eliminated list.
|
||||
let standings = tournament.standings();
|
||||
let _total_players = standings.len() + 1; // +1 for the winner
|
||||
|
||||
// Find the winner — a seated player not in the eliminated list
|
||||
let eliminated_ids: Vec<PlayerId> =
|
||||
standings.iter().map(|(id, _)| *id).collect();
|
||||
|
||||
// Record stats for eliminated players
|
||||
for &(player_id, placement) in standings {
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||
|
||||
let stats = self
|
||||
.players
|
||||
.entry(player_id)
|
||||
.or_insert_with(|| PlayerStats::new(player_id, name));
|
||||
|
||||
// In tournament standings, placement 1 = first eliminated (worst),
|
||||
// last entry = last eliminated before winner.
|
||||
// Convert to "finish position": total_players - index
|
||||
// Actually, the standings are ordered by elimination time.
|
||||
// placement seems to be stored as the elimination order.
|
||||
// For a 3-player SNG: standings = [(2nd_place_id, 2), (3rd_place_id, 3)]
|
||||
// Wait, let me re-read: standings returns &[(PlayerId, u32)]
|
||||
// where the u32 is the placement. Let me just use it as-is.
|
||||
stats.hands_played = tournament.total_hands(); // approximate
|
||||
stats.final_stack = 0; // eliminated players have 0 chips
|
||||
|
||||
// Profit = prize - buy_in. We'll compute prize from placement.
|
||||
// For now, just record the buy-in as a loss.
|
||||
if let Some(prize) = tournament.prize_structure().prize_for_placement(placement) {
|
||||
stats.total_profit = prize as i64 - buy_in as i64;
|
||||
} else {
|
||||
stats.total_profit = -(buy_in as i64);
|
||||
}
|
||||
}
|
||||
|
||||
// Find and record the winner (player not in eliminated list)
|
||||
for table in tournament.tables() {
|
||||
for seat in table.seats().iter().flatten() {
|
||||
if !eliminated_ids.contains(&seat.player_id) {
|
||||
let name = player_names
|
||||
.get(&seat.player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", seat.player_id.0));
|
||||
|
||||
let stats = self
|
||||
.players
|
||||
.entry(seat.player_id)
|
||||
.or_insert_with(|| PlayerStats::new(seat.player_id, name));
|
||||
|
||||
stats.final_stack = seat.stack;
|
||||
stats.hands_played = tournament.total_hands(); // approximate
|
||||
|
||||
// Winner gets 1st place prize
|
||||
if let Some(prize) = tournament.prize_structure().prize_for_placement(1) {
|
||||
stats.total_profit = prize as i64 - buy_in as i64;
|
||||
stats.hands_won += 1; // won the tournament
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a human-readable summary table.
|
||||
///
|
||||
/// For cash games:
|
||||
/// ```text
|
||||
/// === Simulation Results ===
|
||||
/// Game: Cash | Hands: 1000
|
||||
/// ------------------------------------------------------------
|
||||
/// Player Hands Won Profit ROI% Avg Profit/Hand
|
||||
/// Alice 1000 312 +4500 +45.0% +4.50
|
||||
/// ------------------------------------------------------------
|
||||
/// ```
|
||||
///
|
||||
/// For tournaments:
|
||||
/// ```text
|
||||
/// === Tournament Results ===
|
||||
/// Type: SitAndGo | Hands: 87
|
||||
/// ------------------------------------------------------------
|
||||
/// Player Place Profit ROI%
|
||||
/// Alice 1 +500 +50.0%
|
||||
/// ------------------------------------------------------------
|
||||
/// ```
|
||||
pub fn summary(&self, buy_in: ChipCount) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if self.game_type == "Cash" {
|
||||
lines.push("=== Simulation Results ===".to_string());
|
||||
lines.push(format!("Game: Cash | Hands: {}", self.total_hands));
|
||||
lines.push("-".repeat(60));
|
||||
|
||||
// Header
|
||||
lines.push(format!(
|
||||
"{:<16} {:>5} {:>3} {:>8} {:>7} {:>14}",
|
||||
"Player", "Hands", "Won", "Profit", "ROI%", "Avg Profit/Hand"
|
||||
));
|
||||
|
||||
// Sort players by total_profit descending
|
||||
let mut players: Vec<&PlayerStats> = self.players.values().collect();
|
||||
players.sort_by_key(|p| std::cmp::Reverse(p.total_profit));
|
||||
|
||||
for p in &players {
|
||||
let profit_str = if p.total_profit >= 0 {
|
||||
format!("+{}", p.total_profit)
|
||||
} else {
|
||||
format!("{}", p.total_profit)
|
||||
};
|
||||
|
||||
let roi_val = p.roi(buy_in);
|
||||
let roi_str = if roi_val >= 0.0 {
|
||||
format!("+{:.1}%", roi_val)
|
||||
} else {
|
||||
format!("{:.1}%", roi_val)
|
||||
};
|
||||
|
||||
let avg_str = format!("{:+.2}", p.avg_profit_per_hand());
|
||||
|
||||
lines.push(format!(
|
||||
"{:<16} {:>5} {:>3} {:>8} {:>7} {:>14}",
|
||||
p.name, p.hands_played, p.hands_won, profit_str, roi_str, avg_str
|
||||
));
|
||||
}
|
||||
|
||||
lines.push("-".repeat(60));
|
||||
} else {
|
||||
lines.push("=== Tournament Results ===".to_string());
|
||||
lines.push(format!("Type: {} | Hands: {}", self.game_type, self.total_hands));
|
||||
lines.push("-".repeat(60));
|
||||
|
||||
// Header
|
||||
lines.push(format!(
|
||||
"{:<16} {:>5} {:>8} {:>7}",
|
||||
"Player", "Place", "Profit", "ROI%"
|
||||
));
|
||||
|
||||
// Sort players by total_profit descending (winner first)
|
||||
let mut players: Vec<&PlayerStats> = self.players.values().collect();
|
||||
players.sort_by_key(|p| std::cmp::Reverse(p.total_profit));
|
||||
|
||||
for (i, p) in players.iter().enumerate() {
|
||||
let place = i + 1;
|
||||
let profit_str = if p.total_profit >= 0 {
|
||||
format!("+{}", p.total_profit)
|
||||
} else {
|
||||
format!("{}", p.total_profit)
|
||||
};
|
||||
|
||||
let roi_val = p.roi(buy_in);
|
||||
let roi_str = if roi_val >= 0.0 {
|
||||
format!("+{:.1}%", roi_val)
|
||||
} else {
|
||||
format!("{:.1}%", roi_val)
|
||||
};
|
||||
|
||||
lines.push(format!(
|
||||
"{:<16} {:>5} {:>8} {:>7}",
|
||||
p.name, place, profit_str, roi_str
|
||||
));
|
||||
}
|
||||
|
||||
lines.push("-".repeat(60));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::hand_runner::HandResult;
|
||||
use crate::game::pot::PotWinner;
|
||||
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||
use crate::Board;
|
||||
|
||||
/// Helper to create a dummy HandValue for tests.
|
||||
fn dummy_hand_value() -> HandValue {
|
||||
HandValue::new(HandRank::HighCard, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_stats_new() {
|
||||
let stats = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
assert_eq!(stats.player_id, PlayerId(1));
|
||||
assert_eq!(stats.name, "Alice");
|
||||
assert_eq!(stats.hands_played, 0);
|
||||
assert_eq!(stats.hands_won, 0);
|
||||
assert_eq!(stats.total_profit, 0);
|
||||
assert_eq!(stats.total_wagered, 0);
|
||||
assert_eq!(stats.biggest_pot_won, 0);
|
||||
assert_eq!(stats.final_stack, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_stats_roi() {
|
||||
let mut stats = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
stats.total_profit = 500;
|
||||
assert_eq!(stats.roi(1000), 50.0);
|
||||
|
||||
stats.total_profit = -500;
|
||||
assert_eq!(stats.roi(1000), -50.0);
|
||||
|
||||
// Zero buy-in
|
||||
assert_eq!(stats.roi(0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_stats_win_rate() {
|
||||
let mut stats = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
assert_eq!(stats.win_rate(), 0.0);
|
||||
|
||||
stats.hands_played = 100;
|
||||
stats.hands_won = 30;
|
||||
assert!((stats.win_rate() - 0.3).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_stats_avg_profit_per_hand() {
|
||||
let mut stats = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
assert_eq!(stats.avg_profit_per_hand(), 0.0);
|
||||
|
||||
stats.hands_played = 100;
|
||||
stats.total_profit = 500;
|
||||
assert!((stats.avg_profit_per_hand() - 5.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulation_stats_new() {
|
||||
let stats = SimulationStats::new("Cash");
|
||||
assert!(stats.players.is_empty());
|
||||
assert_eq!(stats.total_hands, 0);
|
||||
assert_eq!(stats.game_type, "Cash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_hand_result_accumulates() {
|
||||
let mut sim_stats = SimulationStats::new("Cash");
|
||||
let mut player_names = HashMap::new();
|
||||
player_names.insert(PlayerId(1), "Alice".to_string());
|
||||
player_names.insert(PlayerId(2), "Bob".to_string());
|
||||
|
||||
// Hand 1: Alice wins 100, Bob wins 0
|
||||
let mut chip_movements = HashMap::new();
|
||||
chip_movements.insert(PlayerId(1), 100);
|
||||
chip_movements.insert(PlayerId(2), 0);
|
||||
|
||||
let result = HandResult {
|
||||
pot_winners: vec![vec![PotWinner {
|
||||
player_id: PlayerId(1),
|
||||
amount: 100,
|
||||
hand_value: dummy_hand_value(),
|
||||
}]],
|
||||
hand_values: HashMap::new(),
|
||||
board: Board::new(),
|
||||
chip_movements,
|
||||
};
|
||||
|
||||
sim_stats.record_hand_result(&result, &player_names);
|
||||
assert_eq!(sim_stats.total_hands, 1);
|
||||
|
||||
let alice = sim_stats.players.get(&PlayerId(1)).unwrap();
|
||||
assert_eq!(alice.hands_played, 1);
|
||||
assert_eq!(alice.hands_won, 1);
|
||||
assert_eq!(alice.total_profit, 100);
|
||||
assert_eq!(alice.biggest_pot_won, 100);
|
||||
|
||||
let bob = sim_stats.players.get(&PlayerId(2)).unwrap();
|
||||
assert_eq!(bob.hands_played, 1);
|
||||
assert_eq!(bob.hands_won, 0);
|
||||
assert_eq!(bob.total_profit, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_hand_result_multiple_hands() {
|
||||
let mut sim_stats = SimulationStats::new("Cash");
|
||||
let mut player_names = HashMap::new();
|
||||
player_names.insert(PlayerId(1), "Alice".to_string());
|
||||
player_names.insert(PlayerId(2), "Bob".to_string());
|
||||
|
||||
// Hand 1: Alice wins 100
|
||||
let result1 = HandResult {
|
||||
pot_winners: vec![],
|
||||
hand_values: HashMap::new(),
|
||||
board: Board::new(),
|
||||
chip_movements: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(PlayerId(1), 100);
|
||||
m.insert(PlayerId(2), 0);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
// Hand 2: Bob wins 200
|
||||
let result2 = HandResult {
|
||||
pot_winners: vec![],
|
||||
hand_values: HashMap::new(),
|
||||
board: Board::new(),
|
||||
chip_movements: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(PlayerId(1), 0);
|
||||
m.insert(PlayerId(2), 200);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
sim_stats.record_hand_result(&result1, &player_names);
|
||||
sim_stats.record_hand_result(&result2, &player_names);
|
||||
|
||||
assert_eq!(sim_stats.total_hands, 2);
|
||||
|
||||
let alice = sim_stats.players.get(&PlayerId(1)).unwrap();
|
||||
assert_eq!(alice.hands_played, 2);
|
||||
assert_eq!(alice.hands_won, 1);
|
||||
assert_eq!(alice.total_profit, 100);
|
||||
|
||||
let bob = sim_stats.players.get(&PlayerId(2)).unwrap();
|
||||
assert_eq!(bob.hands_played, 2);
|
||||
assert_eq!(bob.hands_won, 1);
|
||||
assert_eq!(bob.total_profit, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_final_stacks_computes_profit() {
|
||||
let mut sim_stats = SimulationStats::new("Cash");
|
||||
let mut player_names = HashMap::new();
|
||||
player_names.insert(PlayerId(1), "Alice".to_string());
|
||||
player_names.insert(PlayerId(2), "Bob".to_string());
|
||||
|
||||
// Simulate recording a hand first
|
||||
let result = HandResult {
|
||||
pot_winners: vec![],
|
||||
hand_values: HashMap::new(),
|
||||
board: Board::new(),
|
||||
chip_movements: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(PlayerId(1), 100);
|
||||
m.insert(PlayerId(2), 0);
|
||||
m
|
||||
},
|
||||
};
|
||||
sim_stats.record_hand_result(&result, &player_names);
|
||||
|
||||
// Record final stacks
|
||||
let final_stacks = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(PlayerId(1), 10500);
|
||||
m.insert(PlayerId(2), 9500);
|
||||
m
|
||||
};
|
||||
let starting_stacks = {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(PlayerId(1), 10000);
|
||||
m.insert(PlayerId(2), 10000);
|
||||
m
|
||||
};
|
||||
sim_stats.record_final_stacks(&final_stacks, &starting_stacks);
|
||||
|
||||
let alice = sim_stats.players.get(&PlayerId(1)).unwrap();
|
||||
assert_eq!(alice.final_stack, 10500);
|
||||
assert_eq!(alice.total_profit, 500);
|
||||
|
||||
let bob = sim_stats.players.get(&PlayerId(2)).unwrap();
|
||||
assert_eq!(bob.final_stack, 9500);
|
||||
assert_eq!(bob.total_profit, -500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_format_cash_game() {
|
||||
let mut sim_stats = SimulationStats::new("Cash");
|
||||
let mut alice = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
alice.hands_played = 100;
|
||||
alice.hands_won = 30;
|
||||
alice.total_profit = 4500;
|
||||
alice.final_stack = 14500;
|
||||
|
||||
let mut bob = PlayerStats::new(PlayerId(2), "Bob".to_string());
|
||||
bob.hands_played = 100;
|
||||
bob.hands_won = 20;
|
||||
bob.total_profit = -8200;
|
||||
bob.final_stack = 1800;
|
||||
|
||||
sim_stats.players.insert(PlayerId(1), alice);
|
||||
sim_stats.players.insert(PlayerId(2), bob);
|
||||
sim_stats.total_hands = 100;
|
||||
|
||||
let summary = sim_stats.summary(10000);
|
||||
assert!(summary.contains("=== Simulation Results ==="));
|
||||
assert!(summary.contains("Cash"));
|
||||
assert!(summary.contains("100"));
|
||||
assert!(summary.contains("Alice"));
|
||||
assert!(summary.contains("Bob"));
|
||||
assert!(summary.contains("+4500"));
|
||||
assert!(summary.contains("-8200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_format_tournament() {
|
||||
let mut sim_stats = SimulationStats::new("SitAndGo");
|
||||
let mut alice = PlayerStats::new(PlayerId(1), "Alice".to_string());
|
||||
alice.total_profit = 500;
|
||||
alice.final_stack = 30000;
|
||||
|
||||
let mut bob = PlayerStats::new(PlayerId(2), "Bob".to_string());
|
||||
bob.total_profit = -1000;
|
||||
bob.final_stack = 0;
|
||||
|
||||
sim_stats.players.insert(PlayerId(1), alice);
|
||||
sim_stats.players.insert(PlayerId(2), bob);
|
||||
sim_stats.total_hands = 87;
|
||||
|
||||
let summary = sim_stats.summary(1000);
|
||||
assert!(summary.contains("=== Tournament Results ==="));
|
||||
assert!(summary.contains("SitAndGo"));
|
||||
assert!(summary.contains("87"));
|
||||
assert!(summary.contains("Alice"));
|
||||
assert!(summary.contains("Bob"));
|
||||
}
|
||||
}
|
||||
1146
holdem_core/src/game/table.rs
Normal file
1146
holdem_core/src/game/table.rs
Normal file
File diff suppressed because it is too large
Load diff
1050
holdem_core/src/game/tournament.rs
Normal file
1050
holdem_core/src/game/tournament.rs
Normal file
File diff suppressed because it is too large
Load diff
105
holdem_core/src/game/types.rs
Normal file
105
holdem_core/src/game/types.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Core shared types used across the game engine.
|
||||
//!
|
||||
//! This module defines the fundamental identifiers, aliases, constants, and error
|
||||
//! types that all other game modules depend on.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Unique player identifier, opaque wrapper around a [`u64`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PlayerId(pub u64);
|
||||
|
||||
impl std::fmt::Display for PlayerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Player({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip count — [`u64`] is sufficient for all practical poker scenarios.
|
||||
pub type ChipCount = u64;
|
||||
|
||||
/// Seat position at a table, 0-based. Valid range: `0..num_seats`.
|
||||
pub type SeatIndex = u8;
|
||||
|
||||
/// Maximum number of seats at a single table.
|
||||
pub const MAX_SEATS: SeatIndex = 10;
|
||||
|
||||
/// Minimum number of players to start a hand.
|
||||
pub const MIN_PLAYERS: SeatIndex = 2;
|
||||
|
||||
/// Errors that can occur during game engine operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GameError {
|
||||
/// A generic invalid action with a description.
|
||||
#[error("invalid action: {0}")]
|
||||
InvalidAction(String),
|
||||
|
||||
/// It is not the specified player's turn to act.
|
||||
#[error("not player {0}'s turn")]
|
||||
NotPlayersTurn(PlayerId),
|
||||
|
||||
/// The specified player was not found.
|
||||
#[error("player {0} not found")]
|
||||
PlayerNotFound(PlayerId),
|
||||
|
||||
/// The requested seat is already occupied.
|
||||
#[error("seat {0} is occupied")]
|
||||
SeatOccupied(u8),
|
||||
|
||||
/// The requested seat index is out of the valid range.
|
||||
#[error("seat {0} is out of range")]
|
||||
SeatOutOfRange(u8),
|
||||
|
||||
/// The table has no empty seats.
|
||||
#[error("table is full")]
|
||||
TableFull,
|
||||
|
||||
/// Not enough players to start a hand.
|
||||
#[error("not enough players to start a hand, need at least 2, got {0}")]
|
||||
NotEnoughPlayers(usize),
|
||||
|
||||
/// No hand is currently in progress.
|
||||
#[error("hand is not in progress")]
|
||||
NoHandInProgress,
|
||||
|
||||
/// A hand is already in progress; cannot start another.
|
||||
#[error("hand is already in progress")]
|
||||
HandAlreadyInProgress,
|
||||
|
||||
/// The player does not have enough chips for the action.
|
||||
#[error("player {0} has insufficient chips: needs {1}, has {2}")]
|
||||
InsufficientChips(PlayerId, u64, u64),
|
||||
|
||||
/// The raise amount is below the minimum raise for this betting round.
|
||||
#[error("raise amount {0} is below minimum raise {1}")]
|
||||
RaiseBelowMinimum(u64, u64),
|
||||
|
||||
/// The raise amount exceeds the maximum allowed.
|
||||
#[error("raise amount {0} exceeds maximum allowed {1}")]
|
||||
RaiseAboveMaximum(u64, u64),
|
||||
|
||||
/// A check is not allowed because there is an outstanding bet.
|
||||
#[error("check is not allowed: current bet is {0}")]
|
||||
CheckNotAllowed(u64),
|
||||
|
||||
/// An invalid state transition was attempted.
|
||||
#[error("invalid state transition from {from} to {to}")]
|
||||
InvalidStateTransition {
|
||||
/// The state being transitioned from.
|
||||
from: String,
|
||||
/// The state being transitioned to.
|
||||
to: String,
|
||||
},
|
||||
|
||||
/// The tournament has already completed.
|
||||
#[error("tournament is already complete")]
|
||||
TournamentComplete,
|
||||
|
||||
/// The player has already been eliminated from the tournament.
|
||||
#[error("player {0} is already eliminated")]
|
||||
AlreadyEliminated(PlayerId),
|
||||
|
||||
/// A custom error with a free-form message.
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
|
@ -63,6 +63,9 @@ pub mod errors;
|
|||
/// Core hand evaluation functionality with lookup tables
|
||||
pub mod evaluator;
|
||||
|
||||
/// Texas Hold'em simulation engine
|
||||
pub mod game;
|
||||
|
||||
/// Re-export holdem_core types for convenience
|
||||
pub use board::Board;
|
||||
pub use card::Card;
|
||||
|
|
@ -77,7 +80,7 @@ pub use board::Street;
|
|||
pub use errors::PokerError;
|
||||
|
||||
/// Re-export algorithmic evaluator for direct evaluation
|
||||
pub use evaluator::algorithmic_evaluator::{AlgorithmicEvaluator, EvaluationStats};
|
||||
pub use evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
/// Re-export evaluator types for convenience
|
||||
pub use evaluator::evaluator::{Evaluator, HandRank, HandValue};
|
||||
|
||||
|
|
@ -90,5 +93,11 @@ pub use evaluator::integration::{EvaluatorComparison, MathEvaluator};
|
|||
/// Re-export file I/O functionality
|
||||
pub use evaluator::file_io::{LutFileManager, TableInfo, TableType};
|
||||
|
||||
// Game module re-exports (top-level entry points; detailed types available via holdem_core::game::*)
|
||||
pub use game::table::{Table, TableConfig};
|
||||
pub use game::tournament::Tournament;
|
||||
pub use game::hand_runner::{HandRunner, HandState, HandResult};
|
||||
pub use game::config::GameConfig;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
||||
|
|
|
|||
|
|
@ -424,8 +424,8 @@ pub struct IntegrationTestResults {
|
|||
pub passed_tests: usize,
|
||||
/// Number of failed tests
|
||||
pub failed_tests: usize,
|
||||
/// Test execution time in milliseconds
|
||||
pub execution_time_ms: u64,
|
||||
/// Test execution time in microseconds
|
||||
pub execution_time_us: u64,
|
||||
}
|
||||
|
||||
impl IntegrationTestSuite {
|
||||
|
|
@ -479,7 +479,7 @@ impl IntegrationTestSuite {
|
|||
total_tests,
|
||||
passed_tests,
|
||||
failed_tests,
|
||||
execution_time_ms: elapsed.as_millis() as u64,
|
||||
execution_time_us: elapsed.as_micros() as u64,
|
||||
};
|
||||
|
||||
println!("Integration tests completed: {:?}", results);
|
||||
|
|
@ -733,6 +733,6 @@ mod tests {
|
|||
|
||||
let results = results.unwrap();
|
||||
assert!(results.total_tests > 0);
|
||||
assert!(results.execution_time_ms > 0);
|
||||
assert!(results.execution_time_us > 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue