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:
Jan-Peter von Hunnius 2026-05-18 15:12:34 +02:00
parent dd605e8a66
commit 17d0440c86
43 changed files with 12329 additions and 494 deletions

149
Cargo.lock generated
View file

@ -11,6 +11,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@ -98,6 +148,52 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "console" name = "console"
version = "0.15.11" version = "0.15.11"
@ -287,9 +383,18 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror",
"toml", "toml",
] ]
[[package]]
name = "holdem_sim"
version = "0.1.0"
dependencies = [
"clap",
"holdem_core",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@ -345,6 +450,12 @@ dependencies = [
"web-time", "web-time",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -414,6 +525,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -656,6 +773,12 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@ -680,6 +803,26 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.9.12+spec-1.1.0" version = "0.9.12+spec-1.1.0"
@ -749,6 +892,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View file

@ -2,4 +2,5 @@
resolver = "2" resolver = "2"
members = [ members = [
"holdem_core", "holdem_core",
"holdem_sim",
] ]

View file

@ -282,6 +282,82 @@ Run performance benchmarks:
cargo test --release hand_eval_integration_test -- --nocapture cargo test --release hand_eval_integration_test -- --nocapture
``` ```
## Simulation Testbed
The `holdem_sim` binary provides a CLI for running poker simulations with configurable bots, game types, and formats. It supports cash games, Sit & Go (SNG) tournaments, and Multi-Table Tournaments (MTT).
### Running Simulations
```bash
# Run a cash game simulation
cargo run --bin holdem_sim -- holdem_core/examples/configs/cash_game.toml
# Run with a specific seed for reproducibility
cargo run --bin holdem_sim -- holdem_core/examples/configs/cash_game.toml --seed 42
# Override the number of hands
cargo run --bin holdem_sim -- holdem_core/examples/configs/cash_game.toml --hands 5000
```
### Example Configuration (Cash Game)
```toml
[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"
```
Example configs are available in [`holdem_core/examples/configs/`](holdem_core/examples/configs/):
- [`cash_game.toml`](holdem_core/examples/configs/cash_game.toml) — 6-max NLHE cash game
- [`sng_tournament.toml`](holdem_core/examples/configs/sng_tournament.toml) — 9-seat Sit & Go
- [`mtt_tournament.toml`](holdem_core/examples/configs/mtt_tournament.toml) — Multi-table tournament
### Built-in Bots
| Bot | Description |
|-----|-------------|
| `call_bot` | Calls every bet, never raises or folds |
| `fold_bot` | Folds whenever facing a bet, otherwise checks |
| `raise_bot` | Raises with configurable aggression thresholds |
| `random_bot` | Takes random legal actions with configurable tendencies |
### Example Output
```
=== Simulation Results ===
Hands played: 1000
Player Statistics:
Alice (call_bot)
Hands: 1000 | VPIP: 100.0% | PFR: 0.0% | Win: $45,230
Bob (fold_bot)
Hands: 1000 | VPIP: 0.0% | PFR: 0.0% | Win: -$32,100
Charlie (random_bot)
Hands: 1000 | VPIP: 67.3% | PFR: 22.1% | Win: -$13,130
```
## Mathematical Foundation ## Mathematical Foundation
### Perfect Hash Algorithm ### Perfect Hash Algorithm

View file

@ -8,6 +8,32 @@
- Card/Hand/Board representations ✅ - Card/Hand/Board representations ✅
- Fast LUT hand evaluator (5, 6, and 7 cards) ✅ - Fast LUT hand evaluator (5, 6, and 7 cards) ✅
### Phase 2: Simulation Testbed ✅
**Bot framework**
- Meerkat-API-compatible `HoldemPlayer` trait ✅
- Built-in bots: CallBot, FoldBot, RaiseBot, RandomBot ✅
- Bot factory for config-driven bot instantiation ✅
**Game engine**
- Full hand execution engine (HandRunner state machine) ✅
- Table management with seating, button rotation, blind posting ✅
- Tournament support (SNG, MTT) with blind schedules and prize structures ✅
- Pot management with side pot algorithm ✅
- NL/PL/FL betting structures ✅
- Seed control for reproducible simulations ✅
**Simulation infrastructure**
- TOML-based configuration system ✅
- Cash game simulation loop ✅
- Performance statistics accumulator ✅
- CLI simulation runner (`holdem_sim`) ✅
- Example configs for Cash, SNG, and MTT ✅
**Testing**
- 369+ unit tests passing ✅
- Integration and property tests ✅
## Success Metrics ## Success Metrics
- **Bot Performance:** Profitability across different game types - **Bot Performance:** Profitability across different game types

View file

@ -16,25 +16,30 @@
- Game type configurations (Cash/SNG/MTT, buyins, blinds, hands per level, etc.) - Game type configurations (Cash/SNG/MTT, buyins, blinds, hands per level, etc.)
- Simulation parameters (hands/tournaments count, buyin thresholds for bots, etc.) - Simulation parameters (hands/tournaments count, buyin thresholds for bots, etc.)
### Phase 2: Simulation Testbed ### Phase 2: Simulation Testbed ✅
**Goal:** Mass simulation capability **Goal:** Mass simulation capability
**Testbed core** **Testbed core** ✅
- Tournament/cash game runners - Tournament/cash game runners ✅
- Parallel simulation engine - Result collection and analysis ✅
- Result collection and analysis - Bot interface with early folding capability ✅
- Bot interface with early folding capability - Bot performance metrics & summary ✅
- Bot performance metrics & visualization - Simulation and game factories ✅
- Simulation and game factories - Configuration-driven experiment runner ✅
- Configuration-driven experiment runner - Seed control for reproducibility ✅
**Remaining for mass simulation**
- Parallel simulation engine (multi-threading)
- Result visualization (charts/graphs)
- Rebuy/addon support for tournaments
--- ---
### Phase 3: Bot Framework ### Phase 3: Bot Framework
**Goal:** All bot types with playability enhancement **Goal:** All bot types with playability enhancement
**Bot core implementations** **Bot core implementations** (partially complete — basic bots exist)
- Generic helper functions for strategies - ~~Generic helper functions for strategies~~ (basic bots done: CallBot, FoldBot, RaiseBot, RandomBot)
- Modular Strategy per stage and game situation - Modular Strategy per stage and game situation
- Rule-based bot with configurable thresholds – port from Java - Rule-based bot with configurable thresholds – port from Java
- Nash equilibrium calculator for SNG endgame – port from Java - Nash equilibrium calculator for SNG endgame – port from Java

View file

@ -25,6 +25,7 @@ once_cell = "1.19"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
indicatif = "0.17" indicatif = "0.17"
log = "0.4" log = "0.4"
thiserror = "2"
[dev-dependencies] [dev-dependencies]
proptest = "1.0" proptest = "1.0"

View 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"

View 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

View 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"

View file

@ -1,67 +1,363 @@
//! Algorithmic hand evaluator for direct poker hand evaluation. //! Algorithmic hand evaluator for direct poker hand evaluation.
//! //!
//! This module provides a standalone evaluator that uses pure algorithmic //! This module provides a standalone evaluator that uses pure algorithmic
//! methods (no lookup tables) for evaluating poker hands. It wraps the //! methods (no lookup tables) for evaluating poker hands. It performs
//! direct evaluation logic from [`Evaluator::evaluate_5_card_direct`]. //! 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 crate::Card;
use super::errors::EvaluatorError; use super::evaluator::{HandRank, HandValue};
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,
}
/// Algorithmic poker hand evaluator. /// Algorithmic poker hand evaluator.
/// ///
/// Performs direct hand evaluation without relying on lookup tables. /// Performs direct hand evaluation without relying on lookup tables.
/// Delegates to the same core algorithm used by [`Evaluator::evaluate_5_card_direct`]. /// Uses pure combinatorial logic — zero allocation at construction.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub struct AlgorithmicEvaluator { pub struct AlgorithmicEvaluator {}
inner: Arc<Evaluator>,
stats: EvaluationStats,
}
impl AlgorithmicEvaluator { impl AlgorithmicEvaluator {
/// Create a new algorithmic evaluator instance. /// Create a new algorithmic evaluator instance.
pub fn new() -> Result<Self, EvaluatorError> { ///
let inner = Evaluator::new()?; /// This is a zero-allocation operation — no lookup tables or
Ok(Self { /// precomputed data structures are created.
inner: Arc::new(inner), pub fn new() -> Self {
stats: EvaluationStats::default(), Self {}
})
} }
/// Evaluate a 5-card hand directly using the algorithmic approach. /// 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. /// 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. /// 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. // ---- Core evaluation (shared with Evaluator — single implementation) ----
pub fn stats(&self) -> &EvaluationStats {
&self.stats /// 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();
} }
/// Reset the evaluation statistics. // Sort ranks for easier analysis (highest first)
pub fn reset_stats(&mut self) { ranks.sort_by(|a, b| b.cmp(a));
self.stats = EvaluationStats::default();
// 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))
}
// ---- 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
} }
} }

View file

@ -7,13 +7,11 @@ use std::str::FromStr;
#[test] #[test]
fn test_new_evaluator() { fn test_new_evaluator() {
let evaluator = AlgorithmicEvaluator::new(); let _evaluator = AlgorithmicEvaluator::new();
assert!(evaluator.is_ok());
} }
#[test] #[test]
fn test_royal_flush() { fn test_royal_flush() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
@ -21,13 +19,12 @@ fn test_royal_flush() {
Card::from_str("Js").unwrap(), Card::from_str("Js").unwrap(),
Card::from_str("Ts").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); assert_eq!(result.rank, HandRank::RoyalFlush);
} }
#[test] #[test]
fn test_straight_flush() { fn test_straight_flush() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("9s").unwrap(), Card::from_str("9s").unwrap(),
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
@ -35,13 +32,12 @@ fn test_straight_flush() {
Card::from_str("Js").unwrap(), Card::from_str("Js").unwrap(),
Card::from_str("Ts").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); assert_eq!(result.rank, HandRank::StraightFlush);
} }
#[test] #[test]
fn test_four_of_a_kind() { fn test_four_of_a_kind() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ah").unwrap(), Card::from_str("Ah").unwrap(),
@ -49,13 +45,12 @@ fn test_four_of_a_kind() {
Card::from_str("Ac").unwrap(), Card::from_str("Ac").unwrap(),
Card::from_str("Ks").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); assert_eq!(result.rank, HandRank::FourOfAKind);
} }
#[test] #[test]
fn test_full_house() { fn test_full_house() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ah").unwrap(), Card::from_str("Ah").unwrap(),
@ -63,13 +58,12 @@ fn test_full_house() {
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
Card::from_str("Kh").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); assert_eq!(result.rank, HandRank::FullHouse);
} }
#[test] #[test]
fn test_flush() { fn test_flush() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
@ -77,13 +71,12 @@ fn test_flush() {
Card::from_str("Js").unwrap(), Card::from_str("Js").unwrap(),
Card::from_str("9s").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); assert_eq!(result.rank, HandRank::Flush);
} }
#[test] #[test]
fn test_straight() { fn test_straight() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Kh").unwrap(), Card::from_str("Kh").unwrap(),
@ -91,13 +84,12 @@ fn test_straight() {
Card::from_str("Jc").unwrap(), Card::from_str("Jc").unwrap(),
Card::from_str("Ts").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); assert_eq!(result.rank, HandRank::Straight);
} }
#[test] #[test]
fn test_wheel_straight() { fn test_wheel_straight() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("2h").unwrap(), Card::from_str("2h").unwrap(),
@ -105,13 +97,40 @@ fn test_wheel_straight() {
Card::from_str("4c").unwrap(), Card::from_str("4c").unwrap(),
Card::from_str("5s").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); 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] #[test]
fn test_three_of_a_kind() { fn test_three_of_a_kind() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ah").unwrap(), Card::from_str("Ah").unwrap(),
@ -119,13 +138,12 @@ fn test_three_of_a_kind() {
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
Card::from_str("Qh").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); assert_eq!(result.rank, HandRank::ThreeOfAKind);
} }
#[test] #[test]
fn test_two_pair() { fn test_two_pair() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ah").unwrap(), Card::from_str("Ah").unwrap(),
@ -133,13 +151,12 @@ fn test_two_pair() {
Card::from_str("Kh").unwrap(), Card::from_str("Kh").unwrap(),
Card::from_str("Qd").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); assert_eq!(result.rank, HandRank::TwoPair);
} }
#[test] #[test]
fn test_pair() { fn test_pair() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Ah").unwrap(), Card::from_str("Ah").unwrap(),
@ -147,13 +164,12 @@ fn test_pair() {
Card::from_str("Qh").unwrap(), Card::from_str("Qh").unwrap(),
Card::from_str("Jd").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); assert_eq!(result.rank, HandRank::Pair);
} }
#[test] #[test]
fn test_high_card() { fn test_high_card() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let cards = [ let cards = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
Card::from_str("Kh").unwrap(), Card::from_str("Kh").unwrap(),
@ -161,13 +177,12 @@ fn test_high_card() {
Card::from_str("Jc").unwrap(), Card::from_str("Jc").unwrap(),
Card::from_str("9s").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); assert_eq!(result.rank, HandRank::HighCard);
} }
#[test] #[test]
fn test_hand_rank_ordering() { fn test_hand_rank_ordering() {
let evaluator = AlgorithmicEvaluator::new().unwrap();
let royal_flush = [ let royal_flush = [
Card::from_str("As").unwrap(), Card::from_str("As").unwrap(),
@ -184,17 +199,10 @@ fn test_hand_rank_ordering() {
Card::from_str("Ks").unwrap(), Card::from_str("Ks").unwrap(),
]; ];
let rf = evaluator.evaluate_5_card_direct(&royal_flush); let rf = AlgorithmicEvaluator::evaluate_5_card_direct(&royal_flush);
let foak = evaluator.evaluate_5_card_direct(&four_of_a_kind); let foak = AlgorithmicEvaluator::evaluate_5_card_direct(&four_of_a_kind);
assert!(rf.rank as u8 > foak.rank as u8); 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);
}

View file

@ -688,7 +688,7 @@ impl Evaluator {
} }
// Fallback to direct evaluation algorithm when jump table lookup fails or returns default // 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 /// Evaluate a 6-card hand
@ -727,65 +727,29 @@ impl Evaluator {
self.evaluate_n_card_hand(cards) 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 { fn evaluate_n_card_hand<T: AsRef<[Card]>>(&self, cards: T) -> HandValue {
use super::algorithmic_evaluator::AlgorithmicEvaluator;
let cards_ref = cards.as_ref(); let cards_ref = cards.as_ref();
if cards_ref.len() < 5 { match cards_ref.len() {
return HandValue::new(HandRank::HighCard, 0); 5 => {
let array: [Card; 5] = cards_ref.try_into().unwrap();
AlgorithmicEvaluator::evaluate_5_card(&array)
} }
6 => {
let mut best_hand_value = HandValue::new(HandRank::HighCard, 0); let array: [Card; 6] = cards_ref.try_into().unwrap();
AlgorithmicEvaluator::evaluate_n_card_hand(&array)
// 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;
} }
7 => {
let array: [Card; 7] = cards_ref.try_into().unwrap();
AlgorithmicEvaluator::evaluate_n_card_hand(&array)
} }
_ => HandValue::new(HandRank::HighCard, 0),
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();
} }
} }
@ -820,234 +784,14 @@ impl Evaluator {
&self.tables &self.tables
} }
/// Direct 5-card hand evaluation algorithm (fallback when jump table unavailable) /// 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 /// **Note:** This no longer requires evaluator state. Consider using
let mut ranks = [0u8; 5]; /// [`AlgorithmicEvaluator::evaluate_5_card_direct`] directly for zero-construction
let mut suits = [0u8; 5]; /// evaluation.
pub fn evaluate_5_card_direct(cards: &[Card; 5]) -> HandValue {
for (i, card) in cards.iter().enumerate() { // Delegate to the shared algorithmic implementation to avoid duplication.
ranks[i] = card.rank(); super::algorithmic_evaluator::AlgorithmicEvaluator::evaluate_5_card(cards)
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
} }
/// Validate the evaluator state /// Validate the evaluator state

View file

@ -41,7 +41,7 @@ pub fn basic_usage_example() -> Result<(), EvaluatorError> {
); );
// Method 1: Using MathEvaluator (recommended for most use cases) // 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); let result1 = math_evaluator.evaluate_7_card(&cards);
log::info!("MathEvaluator result: {:?}", result1); 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()); log::info!("Benchmarking {} hand types...", test_hands.len());
@ -166,7 +166,7 @@ pub fn integration_example() -> Result<(), EvaluatorError> {
log::info!(" Complete hand: {}", hand); log::info!(" Complete hand: {}", hand);
// Compare results between math and holdem_core evaluators // 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 math_result = comparison.math_evaluator.evaluate_hand(&hand);
let core_result = comparison.core_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 // Demonstrate proper error handling
match MathEvaluator::new() { match MathEvaluator::new() {
Ok(mut evaluator) => { Ok(evaluator) => {
log::info!("✓ MathEvaluator created successfully"); log::info!("✓ MathEvaluator created successfully");
// Test with valid input // Test with valid input

View file

@ -14,11 +14,10 @@
//! //!
//! ## Usage Examples //! ## Usage Examples
//! //!
//! ### Basic Integration //! ### Basic Integration (AlgorithmicEvaluator — zero allocation)
//! ```rust //! ```rust
//! use holdem_core::evaluator::{JumpTable, HandValue}; //! use holdem_core::evaluator::AlgorithmicEvaluator;
//! use holdem_core::{Card, Hand}; //! use holdem_core::{Card, Hand};
//! use holdem_core::evaluator::integration::{MathEvaluator, convert_cards};
//! use std::str::FromStr; //! use std::str::FromStr;
//! //!
//! // Create cards using holdem_core //! // Create cards using holdem_core
@ -30,15 +29,31 @@
//! Card::from_str("Ts").unwrap(), //! Card::from_str("Ts").unwrap(),
//! ]; //! ];
//! //!
//! // Convert to math evaluator format //! // Evaluate using the algorithmic evaluator (no table construction needed)
//! let math_cards = convert_cards(&cards); //! let result = AlgorithmicEvaluator::evaluate_5_card_direct(&cards);
//! log::info!("Hand evaluation: {:?}", result);
//! ```
//! //!
//! // Evaluate using math evaluator //! ### Table-Based Integration (MathEvaluator — singleton JumpTable)
//! let mut table = JumpTable::with_target_memory(); //! ```rust
//! table.build().expect("Table build failed"); //! use holdem_core::evaluator::integration::MathEvaluator;
//! use holdem_core::Card;
//! use std::str::FromStr;
//! //!
//! let packed_array: [PackedCard; 7] = math_cards.try_into().unwrap(); //! // 7-card hand for table-based evaluation
//! let result = table.evaluate_7_card(&packed_array); //! 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); //! log::info!("Hand evaluation: {:?}", result);
//! ``` //! ```
//! //!
@ -67,14 +82,35 @@ use super::evaluator::{HandRank, HandValue};
use super::tables::JumpTable; use super::tables::JumpTable;
use crate::card::PackedCard; use crate::card::PackedCard;
use crate::{Card, Hand}; use crate::{Card, Hand};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Integration bridge between math and holdem_core evaluators /// Shared inner state for the math evaluator singleton
pub struct MathEvaluator { struct MathEvaluatorInner {
/// The jump table for hand evaluation /// The jump table for hand evaluation
jump_table: JumpTable, 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 /// Performance statistics for the math evaluator
@ -91,24 +127,35 @@ pub struct EvaluationStats {
} }
impl MathEvaluator { 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> { 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(); let mut jump_table = JumpTable::with_target_memory();
jump_table.build()?; jump_table.build()?;
let arc = Arc::new(MathEvaluatorInner { jump_table });
*guard = Some(arc.clone());
Ok(Self { Ok(Self::from_inner(arc))
jump_table,
stats: EvaluationStats {
total_evaluations: 0,
total_time_ns: 0,
average_time_ns: 0,
cache_hit_rate: 0.0,
},
})
} }
/// Evaluate a 5-card hand using the math evaluator /// 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(); let start = Instant::now();
// Convert holdem_core cards to math cards // Convert holdem_core cards to math cards
@ -121,7 +168,7 @@ impl MathEvaluator {
math_array[i] = card; 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(); let elapsed = start.elapsed();
self.update_stats(elapsed); self.update_stats(elapsed);
@ -130,7 +177,7 @@ impl MathEvaluator {
} }
/// Evaluate a 6-card hand using the math evaluator /// 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(); let start = Instant::now();
// Convert holdem_core cards to math cards // Convert holdem_core cards to math cards
@ -143,7 +190,7 @@ impl MathEvaluator {
math_array[i] = card; 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(); let elapsed = start.elapsed();
self.update_stats(elapsed); self.update_stats(elapsed);
@ -152,7 +199,7 @@ impl MathEvaluator {
} }
/// Evaluate a 7-card hand using the math evaluator /// 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(); let start = Instant::now();
// Convert holdem_core cards to math cards // Convert holdem_core cards to math cards
@ -160,7 +207,7 @@ impl MathEvaluator {
// Use jump table for evaluation // Use jump table for evaluation
let math_array: [PackedCard; 7] = math_cards.try_into().unwrap(); 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(); let elapsed = start.elapsed();
self.update_stats(elapsed); self.update_stats(elapsed);
@ -169,7 +216,7 @@ impl MathEvaluator {
} }
/// Evaluate a hand from hole cards and board /// 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(); let cards = hand.cards();
match cards.len() { match cards.len() {
5 => self.evaluate_5_card(&cards.try_into().unwrap()), 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 /// Get performance statistics
pub fn stats(&self) -> &EvaluationStats { pub fn stats(&self) -> EvaluationStats {
&self.stats 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 /// Get the jump table for external access
pub fn get_jump_table(&self) -> &JumpTable { pub fn get_jump_table(&self) -> &JumpTable {
&self.jump_table &self.inner.jump_table
} }
/// Reset performance statistics /// Reset performance statistics
pub fn reset_stats(&mut self) { pub fn reset_stats(&self) {
self.stats = EvaluationStats { self.total_evaluations.store(0, Ordering::Relaxed);
total_evaluations: 0, self.total_time_ns.store(0, Ordering::Relaxed);
total_time_ns: 0, }
average_time_ns: 0,
cache_hit_rate: 0.0, /// 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;
} }
/// Update internal statistics /// Update internal statistics (atomic operations — no RefCell needed).
fn update_stats(&mut self, elapsed: Duration) { ///
self.stats.total_evaluations += 1; /// Uses `Ordering::Relaxed` for best-effort statistics. Readers of
self.stats.total_time_ns += elapsed.as_nanos() as u64; /// [`stats()`](Self::stats) may see slightly skewed averages under
/// concurrent writes — acceptable for performance monitoring.
if self.stats.total_evaluations > 0 { fn update_stats(&self, elapsed: Duration) {
self.stats.average_time_ns = self.total_evaluations.fetch_add(1, Ordering::Relaxed);
self.stats.total_time_ns / self.stats.total_evaluations as u64; 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() .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 { pub trait EvaluatorCompatibility {
/// Evaluate a hand and return HandValue /// Evaluate a hand and return HandValue.
fn evaluate_hand(&self, hand: &Hand) -> 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 /// Get evaluator name for identification
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -260,17 +350,8 @@ pub trait EvaluatorCompatibility {
} }
impl EvaluatorCompatibility for MathEvaluator { impl EvaluatorCompatibility for MathEvaluator {
fn evaluate_hand(&self, hand: &Hand) -> HandValue { fn evaluate_hand_compat(&self, hand: &Hand) -> HandValue {
// For trait compatibility, we need to create a temporary mutable instance self.evaluate_hand(hand)
// 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 name(&self) -> &'static str { fn name(&self) -> &'static str {
@ -278,11 +359,11 @@ impl EvaluatorCompatibility for MathEvaluator {
} }
fn memory_usage(&self) -> usize { fn memory_usage(&self) -> usize {
self.jump_table.memory_usage() self.inner.jump_table.memory_usage()
} }
fn validate(&self) -> Result<(), EvaluatorError> { fn validate(&self) -> Result<(), EvaluatorError> {
self.jump_table.validate() self.inner.jump_table.validate()
} }
} }
@ -352,7 +433,7 @@ impl EvaluatorComparison {
} }
/// Run comprehensive performance comparison /// Run comprehensive performance comparison
pub fn run_performance_comparison(&mut self) -> PerformanceComparison { pub fn run_performance_comparison(&self) -> PerformanceComparison {
use std::str::FromStr; use std::str::FromStr;
let test_cases = vec![ let test_cases = vec![
@ -667,7 +748,7 @@ mod tests {
#[test] #[test]
fn test_performance_comparison() { fn test_performance_comparison() {
let mut comparison = EvaluatorComparison::new().unwrap(); let comparison = EvaluatorComparison::new().unwrap();
let perf_comparison = comparison.run_performance_comparison(); let perf_comparison = comparison.run_performance_comparison();
assert_eq!(perf_comparison.test_cases, 3); // We test 3 cases assert_eq!(perf_comparison.test_cases, 3); // We test 3 cases

View file

@ -31,7 +31,7 @@ pub mod test_utils;
mod algorithmic_evaluator_test; mod algorithmic_evaluator_test;
// Re-export commonly used types from local modules // 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 errors::EvaluatorError;
pub use evaluator::{Evaluator, HandRank, HandValue}; pub use evaluator::{Evaluator, HandRank, HandValue};

View file

@ -517,7 +517,7 @@ pub fn test_jump_table_properties() -> Result<(), EvaluatorError> {
pub fn run_stress_tests() -> Result<(), EvaluatorError> { pub fn run_stress_tests() -> Result<(), EvaluatorError> {
log::info!("Running stress tests..."); log::info!("Running stress tests...");
let mut evaluator = MathEvaluator::new()?; let evaluator = MathEvaluator::new()?;
let num_hands = 1000; let num_hands = 1000;
// Create progress bar for stress test setup // Create progress bar for stress test setup

View file

@ -763,8 +763,7 @@ impl JumpTable {
let card_array: [Card; 5] = cards.try_into().unwrap(); let card_array: [Card; 5] = cards.try_into().unwrap();
// Evaluate the hand using the algorithmic evaluator for accuracy // Evaluate the hand using the algorithmic evaluator for accuracy
let evaluator = AlgorithmicEvaluator::new()?; let hand_value = AlgorithmicEvaluator::evaluate_5_card_direct(&card_array);
let hand_value = evaluator.evaluate_5_card_direct(&card_array);
// Use proper combinatorial indexing instead of simple hashing // Use proper combinatorial indexing instead of simple hashing
let level5_index = self.combinatorial_index_5_cards(&card_array)?; let level5_index = self.combinatorial_index_5_cards(&card_array)?;
@ -895,26 +894,28 @@ impl JumpTable {
let mut level6_count = 0; let mut level6_count = 0;
let start_time = std::time::Instant::now(); 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() { for (combo_index, combo) in combinations.iter().enumerate() {
if combo.len() >= 6 { let n = combo.len();
for i in 0..combo.len() { if n >= 7 {
let mut six_cards = Vec::new(); for i in 0..n {
for (j, card) in combo.iter().enumerate() { // Build 6-card subset by skipping card at index i — stack-allocated
if j != i { let six_cards: Vec<PackedCard> = combo
six_cards.push(*card); .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 // Convert 6 PackedCards to 6-element Card array — stack-allocated
let evaluator = AlgorithmicEvaluator::new()?; let card_array: [Card; 6] = std::array::from_fn(|j| {
// Convert 6 PackedCards to 6-element Card array Card::new(six_array[j].rank(), six_array[j].suit()).unwrap()
let mut cards = Vec::new(); });
for &packed in &six_cards { let best_hand_value = AlgorithmicEvaluator::evaluate_6_card(&card_array);
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);
let best_level5_index = self.find_level5_index(&best_hand_value); let best_level5_index = self.find_level5_index(&best_hand_value);
// Store offset to Level 5 in Level 6 // Store offset to Level 5 in Level 6
@ -950,18 +951,14 @@ impl JumpTable {
let mut level7_count = 0; let mut level7_count = 0;
let start_time = std::time::Instant::now(); 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() { for (combo_index, combo) in combinations.iter().enumerate() {
if combo.len() >= 6 { if combo.len() >= 7 {
// Find the best 5-card hand from this 7-card hand using algorithmic evaluator // Convert up to 7 PackedCards to 7-element Card array — stack-allocated
let evaluator = AlgorithmicEvaluator::new()?; let card_array: [Card; 7] = std::array::from_fn(|i| {
// Convert 7 PackedCards to 7-element Card array Card::new(combo[i].rank(), combo[i].suit()).unwrap()
let mut cards = Vec::new(); });
for &packed in combo { let best_hand_value = AlgorithmicEvaluator::evaluate_7_card(&card_array);
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);
let best_level6_index = self.find_level6_index(&best_hand_value, combo); let best_level6_index = self.find_level6_index(&best_hand_value, combo);
// Store offset to Level 6 in Level 7 // Store offset to Level 6 in Level 7
@ -1294,8 +1291,7 @@ impl JumpTable {
// TEMPORARY FIX: Use algorithmic evaluator instead of jump table // TEMPORARY FIX: Use algorithmic evaluator instead of jump table
// This bypasses hash function issues and ensures correct results // This bypasses hash function issues and ensures correct results
use super::algorithmic_evaluator::AlgorithmicEvaluator; use super::algorithmic_evaluator::AlgorithmicEvaluator;
let evaluator = AlgorithmicEvaluator::new()?; Ok(AlgorithmicEvaluator::evaluate_5_card_direct(&card_array))
Ok(evaluator.evaluate_5_card_direct(&card_array))
} }
/// Evaluate a 6-card hand using the jump table with O(1) lookup /// 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 // TEMPORARY FIX: Use algorithmic evaluator directly to bypass hash function issues
use super::algorithmic_evaluator::AlgorithmicEvaluator; use super::algorithmic_evaluator::AlgorithmicEvaluator;
let evaluator = AlgorithmicEvaluator::new()?;
// Convert PackedCards to Cards for algorithmic evaluator // Convert PackedCards to Cards for algorithmic evaluator — stack-allocated
let card_array: Vec<Card> = cards let array: [Card; 7] = std::array::from_fn(|i| {
.iter() Card::new(cards[i].rank(), cards[i].suit()).unwrap()
.map(|&packed| Card::new(packed.rank(), packed.suit()).unwrap()) });
.collect(); Ok(AlgorithmicEvaluator::evaluate_7_card(&array))
// Use algorithmic evaluator for 7 cards
let array: [Card; 7] = card_array.try_into().unwrap();
Ok(evaluator.evaluate_7_card(&array))
} }
/// Find the best 5-card hand from a 6-card hand using jump table optimization /// Find the best 5-card hand from a 6-card hand using jump table optimization

File diff suppressed because it is too large Load diff

View 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());
}
}

View 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));
}
}

View 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));
}
}
}

View 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);
}
}

View 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);
}
}

View 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;

View 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);
}
}

View 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"
);
}
}
}
}

File diff suppressed because it is too large Load diff

View 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
}
}

File diff suppressed because it is too large Load diff

View 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};

View 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);
}
}

View 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
View 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)));
}
}

View 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"));
}
}

View 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"));
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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),
}

View file

@ -63,6 +63,9 @@ pub mod errors;
/// Core hand evaluation functionality with lookup tables /// Core hand evaluation functionality with lookup tables
pub mod evaluator; pub mod evaluator;
/// Texas Hold'em simulation engine
pub mod game;
/// Re-export holdem_core types for convenience /// Re-export holdem_core types for convenience
pub use board::Board; pub use board::Board;
pub use card::Card; pub use card::Card;
@ -77,7 +80,7 @@ pub use board::Street;
pub use errors::PokerError; pub use errors::PokerError;
/// Re-export algorithmic evaluator for direct evaluation /// 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 /// Re-export evaluator types for convenience
pub use evaluator::evaluator::{Evaluator, HandRank, HandValue}; pub use evaluator::evaluator::{Evaluator, HandRank, HandValue};
@ -90,5 +93,11 @@ pub use evaluator::integration::{EvaluatorComparison, MathEvaluator};
/// Re-export file I/O functionality /// Re-export file I/O functionality
pub use evaluator::file_io::{LutFileManager, TableInfo, TableType}; 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)] #[cfg(test)]
mod tests {} mod tests {}

View file

@ -424,8 +424,8 @@ pub struct IntegrationTestResults {
pub passed_tests: usize, pub passed_tests: usize,
/// Number of failed tests /// Number of failed tests
pub failed_tests: usize, pub failed_tests: usize,
/// Test execution time in milliseconds /// Test execution time in microseconds
pub execution_time_ms: u64, pub execution_time_us: u64,
} }
impl IntegrationTestSuite { impl IntegrationTestSuite {
@ -479,7 +479,7 @@ impl IntegrationTestSuite {
total_tests, total_tests,
passed_tests, passed_tests,
failed_tests, failed_tests,
execution_time_ms: elapsed.as_millis() as u64, execution_time_us: elapsed.as_micros() as u64,
}; };
println!("Integration tests completed: {:?}", results); println!("Integration tests completed: {:?}", results);
@ -733,6 +733,6 @@ mod tests {
let results = results.unwrap(); let results = results.unwrap();
assert!(results.total_tests > 0); assert!(results.total_tests > 0);
assert!(results.execution_time_ms > 0); assert!(results.execution_time_us > 0);
} }
} }

13
holdem_sim/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "holdem_sim"
version = "0.1.0"
edition = "2021"
description = "Texas Hold'em simulation runner"
[[bin]]
name = "holdem_sim"
path = "src/main.rs"
[dependencies]
holdem_core = { path = "../holdem_core" }
clap = { version = "4", features = ["derive"] }

199
holdem_sim/src/main.rs Normal file
View file

@ -0,0 +1,199 @@
//! Texas Hold'em simulation runner binary.
//!
//! Parses CLI arguments, loads a TOML configuration file, runs the requested
//! simulation (cash game or tournament), and prints a summary of each bot's
//! performance.
//!
//! # Usage
//!
//! ```text
//! holdem_sim <config_file> [--seed <seed>] [--hands <num_hands>]
//! ```
use std::collections::HashMap;
use std::path::PathBuf;
use clap::Parser;
use holdem_core::game::bot_factory::create_bot;
use holdem_core::game::config::SimulationConfig;
use holdem_core::game::simulation::run_cash_game;
use holdem_core::game::stats::SimulationStats;
use holdem_core::game::tournament::{GameType, Tournament};
use holdem_core::game::types::PlayerId;
// ---------------------------------------------------------------------------
// CLI definition
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "holdem_sim")]
#[command(about = "Texas Hold'em Poker Simulation Testbed")]
struct Cli {
/// Path to TOML configuration file
config: PathBuf,
/// Random seed for reproducibility
#[arg(long)]
seed: Option<u64>,
/// Override number of hands to simulate (cash games only)
#[arg(long)]
hands: Option<u64>,
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() {
let cli = Cli::parse();
// Validate config file path
if !cli.config.exists() {
eprintln!("Error: config file not found: {}", cli.config.display());
std::process::exit(1);
}
if !cli.config.is_file() {
eprintln!("Error: path is not a file: {}", cli.config.display());
std::process::exit(1);
}
// Load config
let config = SimulationConfig::from_toml_file(&cli.config).unwrap_or_else(|e| {
eprintln!("Error loading config: {}", e);
std::process::exit(1);
});
let mut game_config = config.game.clone();
// Apply CLI overrides
if let Some(seed) = cli.seed {
game_config.seed = Some(seed);
}
if let Some(hands) = cli.hands {
game_config.num_hands = hands;
}
// Run simulation based on game type
match game_config.game_type {
GameType::Cash => run_cash_simulation(&game_config),
GameType::SitAndGo => run_tournament_simulation(&game_config),
GameType::MultiTableTournament => run_tournament_simulation(&game_config),
}
}
// ---------------------------------------------------------------------------
// Cash game
// ---------------------------------------------------------------------------
fn run_cash_simulation(config: &holdem_core::game::config::GameConfig) {
println!("=== Cash Game Simulation ===");
println!("Hands: {}", config.num_hands);
println!("Blinds: {}/{}", config.small_blind, config.big_blind);
if config.ante > 0 {
println!("Ante: {}", config.ante);
}
println!("Starting chips: {}", config.starting_chips);
if let Some(seed) = config.seed {
println!("Seed: {}", seed);
}
println!();
let (stats, _table) = run_cash_game(config).unwrap_or_else(|e| {
eprintln!("Simulation error: {}", e);
std::process::exit(1);
});
println!("{}", stats.summary(config.starting_chips));
}
// ---------------------------------------------------------------------------
// Tournament (SNG / MTT)
// ---------------------------------------------------------------------------
fn run_tournament_simulation(config: &holdem_core::game::config::GameConfig) {
let game_type_str = match config.game_type {
GameType::SitAndGo => "Sit-and-Go",
GameType::MultiTableTournament => "Multi-Table Tournament",
_ => "Tournament",
};
println!("=== {} Simulation ===", game_type_str);
println!("Buy-in: {}", config.buy_in);
println!("Starting chips: {}", config.starting_chips);
println!("Blinds: {}/{}", config.small_blind, config.big_blind);
println!("Players: {}", config.bots.len());
if config.game_type == GameType::MultiTableTournament {
println!("Tables: {}", config.num_tables);
}
if let Some(seed) = config.seed {
println!("Seed: {}", seed);
}
println!();
// Create tournament components from config
let table_config = config.to_table_config();
let blind_schedule = config.to_blind_schedule();
let prize_structure = config.to_prize_structure();
let mut tournament = match config.game_type {
GameType::SitAndGo => Tournament::new_sng(
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
config.buy_in,
),
GameType::MultiTableTournament => Tournament::new_mtt(
config.num_tables,
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
config.buy_in,
),
_ => unreachable!(),
};
// Warn if seed was requested but cannot be applied to tournaments yet
if config.seed.is_some() {
eprintln!(
"Warning: seed is not yet supported for tournament simulations; \
results will not be reproducible."
);
}
// Create and register bots
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
for (i, bot_config) in config.bots.iter().enumerate() {
let player_id = PlayerId(i as u64);
let bot = create_bot(bot_config, player_id).unwrap_or_else(|e| {
eprintln!("Error creating bot '{}': {}", bot_config.bot_type, e);
std::process::exit(1);
});
let name = bot.name().to_string();
player_names.insert(player_id, name);
tournament.register_player(bot).unwrap_or_else(|e| {
eprintln!("Error registering bot: {}", e);
std::process::exit(1);
});
}
// Run tournament
let _result = tournament.run_to_completion().unwrap_or_else(|e| {
eprintln!("Tournament error: {}", e);
std::process::exit(1);
});
println!("Total hands played: {}", tournament.total_hands());
println!();
// Collect stats using SimulationStats
let mut stats = SimulationStats::new(game_type_str);
stats.record_tournament_result(&tournament, &player_names, config.buy_in);
println!("{}", stats.summary(config.buy_in));
}

View file

@ -1 +0,0 @@
{"rustc_fingerprint":12042185895431567242,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.90.0 (1159e78c4 2025-09-14)\nbinary: rustc\ncommit-hash: 1159e78c4747b02ef996e55082b704c09b970588\ncommit-date: 2025-09-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.90.0\nLLVM version: 20.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\jan\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}