Compare commits
10 commits
2da48d43fb
...
5a8a194be3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a8a194be3 | |||
| e5e56fd772 | |||
| d00df65a98 | |||
| edf8d7ae3f | |||
| 05db24f670 | |||
| ed911e3c3a | |||
| 458b8af580 | |||
| a46a800d11 | |||
| 156904f2bf | |||
| 912445d992 |
32 changed files with 1350 additions and 425 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -11,3 +11,8 @@ Thumbs.db
|
|||
|
||||
# Proptest regressions (auto-generated)
|
||||
proptest-regressions/
|
||||
|
||||
|
||||
# Agent notes
|
||||
AGENTS.md
|
||||
AGENTS.md
|
||||
|
|
|
|||
46
Cargo.lock
generated
46
Cargo.lock
generated
|
|
@ -222,6 +222,25 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
|
|
@ -283,6 +302,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
|
|
@ -439,6 +464,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"proptest",
|
||||
"rand",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -752,6 +778,26 @@ dependencies = [
|
|||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
|
|||
85
README.md
85
README.md
|
|
@ -161,26 +161,27 @@ The evaluator recognizes all standard poker hand types:
|
|||
### Texas Hold'em Bot Analysis
|
||||
|
||||
```rust
|
||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
||||
use holdem_core::api::{card::Card, hand::Hand};
|
||||
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
|
||||
use std::str::FromStr;
|
||||
|
||||
let evaluator = LookupHandEvaluator::new().unwrap();
|
||||
let evaluator = Evaluator::new().unwrap();
|
||||
|
||||
// Bot analyzes hole cards + flop for decision making
|
||||
let hole_card1 = Card::from_string("As").unwrap().index() as u32;
|
||||
let hole_card2 = Card::from_string("Ks").unwrap().index() as u32;
|
||||
|
||||
let mut board = Hand::new();
|
||||
board.add_card(Card::from_string("Td").unwrap()).unwrap();
|
||||
board.add_card(Card::from_string("Jc").unwrap()).unwrap();
|
||||
board.add_card(Card::from_string("Qh").unwrap()).unwrap();
|
||||
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||
let board = Board::new()
|
||||
.with_flop([
|
||||
Card::from_str("Td").unwrap(),
|
||||
Card::from_str("Jc").unwrap(),
|
||||
Card::from_str("Qh").unwrap(),
|
||||
]).unwrap();
|
||||
|
||||
// Bot evaluates complete hand strength
|
||||
let hand_strength = evaluator.rank_hand_with_hole_cards(hole_card1, hole_card2, &board);
|
||||
println!("Hand strength after flop: {}", hand_strength);
|
||||
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
|
||||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
println!("Hand strength after flop: {:?}", hand_value.rank);
|
||||
|
||||
// Bot uses this for post-flop decision making
|
||||
if hand_strength < 2000 { // Strong hand
|
||||
if hand_value.rank as u8 >= 4 { // Straight or better
|
||||
println!("Bot: Continuing with strong hand");
|
||||
}
|
||||
```
|
||||
|
|
@ -188,25 +189,22 @@ if hand_strength < 2000 { // Strong hand
|
|||
### Bot Range Analysis
|
||||
|
||||
```rust
|
||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
||||
use holdem_core::api::card::Card;
|
||||
use holdem_core::{Card, Hand, Evaluator};
|
||||
use std::str::FromStr;
|
||||
|
||||
let evaluator = LookupHandEvaluator::new().unwrap();
|
||||
let evaluator = Evaluator::new().unwrap();
|
||||
|
||||
// Bot analyzes range of starting hands for pre-flop strategy
|
||||
let premium_hands = vec![
|
||||
(Card::from_string("AA").unwrap(), Card::from_string("KK").unwrap()),
|
||||
(Card::from_string("QQ").unwrap(), Card::from_string("JJ").unwrap()),
|
||||
(Card::from_string("AK").unwrap(), Card::from_string("AQ").unwrap()),
|
||||
Hand::from_notation("As Ah").unwrap(),
|
||||
Hand::from_notation("Ks Kh").unwrap(),
|
||||
Hand::from_notation("Qs Qh").unwrap(),
|
||||
Hand::from_notation("As Ks").unwrap(),
|
||||
];
|
||||
|
||||
for (card1, card2) in premium_hands {
|
||||
let mut hand = Hand::new();
|
||||
hand.add_card(card1).unwrap();
|
||||
hand.add_card(card2).unwrap();
|
||||
|
||||
let strength = evaluator.rank_hand(&hand);
|
||||
println!("Bot range analysis - {}: {}", hand.to_string(), strength);
|
||||
for hand in premium_hands {
|
||||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
println!("Bot range analysis - {}: {:?}", hand, hand_value.rank);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -215,29 +213,26 @@ for (card1, card2) in premium_hands {
|
|||
### 7-Card Hand Evaluation for Bot Testing
|
||||
|
||||
```rust
|
||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
||||
use holdem_core::api::card::Card;
|
||||
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
|
||||
use std::str::FromStr;
|
||||
|
||||
let evaluator = LookupHandEvaluator::new().unwrap();
|
||||
let evaluator = Evaluator::new().unwrap();
|
||||
|
||||
// Texas Hold'em complete hand: 2 hole cards + 5 community cards
|
||||
let hole_cards = [
|
||||
Card::from_string("As").unwrap().index(),
|
||||
Card::from_string("Ks").unwrap().index(),
|
||||
];
|
||||
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||
let board = Board::new()
|
||||
.with_flop([
|
||||
Card::from_str("Td").unwrap(),
|
||||
Card::from_str("Jc").unwrap(),
|
||||
Card::from_str("Qh").unwrap(),
|
||||
]).unwrap()
|
||||
.with_turn(Card::from_str("9s").unwrap()).unwrap()
|
||||
.with_river(Card::from_str("2d").unwrap()).unwrap();
|
||||
|
||||
let community_cards = [
|
||||
Card::from_string("Td").unwrap().index(),
|
||||
Card::from_string("Jc").unwrap().index(),
|
||||
Card::from_string("Qh").unwrap().index(),
|
||||
Card::from_string("9s").unwrap().index(),
|
||||
Card::from_string("2d").unwrap().index(),
|
||||
];
|
||||
|
||||
// Bot evaluates best possible hand from all cards
|
||||
let all_cards: Vec<u32> = hole_cards.iter().chain(community_cards.iter()).cloned().collect();
|
||||
let best_rank = evaluator.rank_hand7(&all_cards);
|
||||
println!("Bot best hand rank: {}", best_rank);
|
||||
// Bot evaluates best possible hand from all 7 cards
|
||||
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
|
||||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
println!("Bot best hand rank: {:?}", hand_value.rank);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ log = "0.4"
|
|||
thiserror = "2"
|
||||
dashmap = "6"
|
||||
dirs = "6"
|
||||
rayon = "1.10"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.0"
|
||||
|
|
|
|||
|
|
@ -53,8 +53,7 @@ Key modules:
|
|||
### Basic Usage
|
||||
|
||||
```rust
|
||||
use math::{Evaluator, HandRank};
|
||||
use holdem_core::{Card, Hand};
|
||||
use holdem_core::{Card, Hand, Evaluator, evaluator::HandRank};
|
||||
use std::str::FromStr;
|
||||
|
||||
// Get the singleton evaluator instance
|
||||
|
|
@ -67,22 +66,30 @@ let hand = Hand::from_notation("As Ks Qs Js Ts").unwrap();
|
|||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
|
||||
println!("Hand rank: {:?}", hand_value.rank);
|
||||
println!("Hand value: {}", hand_value.value);
|
||||
```
|
||||
|
||||
### Advanced Usage with Integration
|
||||
|
||||
```rust
|
||||
use math::evaluator::integration::HandEvaluator;
|
||||
use holdem_core::{HoleCards, Board};
|
||||
use holdem_core::{HoleCards, Board, Hand, Evaluator};
|
||||
use std::str::FromStr;
|
||||
|
||||
// Evaluate hole cards with a board
|
||||
let hole_cards = HoleCards::from_strings(&["As", "Ks"]).unwrap();
|
||||
let board = Board::from_strings(&["Qs", "Js", "Ts", "7h", "3d"]).unwrap();
|
||||
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||
let board = Board::new()
|
||||
.with_flop([
|
||||
std::str::FromStr::from_str("Qs").unwrap(),
|
||||
std::str::FromStr::from_str("Js").unwrap(),
|
||||
std::str::FromStr::from_str("Ts").unwrap(),
|
||||
]).unwrap()
|
||||
.with_turn(std::str::FromStr::from_str("7h").unwrap()).unwrap()
|
||||
.with_river(std::str::FromStr::from_str("3d").unwrap()).unwrap();
|
||||
|
||||
let hand_value = HandEvaluator::evaluate_hole_cards_with_board(&hole_cards, &board).unwrap();
|
||||
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
|
||||
let evaluator = Evaluator::new().unwrap();
|
||||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
|
||||
println!("Final hand: {}", HandEvaluator::format_hand_value(hand_value));
|
||||
println!("Final hand rank: {:?}", hand_value.rank);
|
||||
```
|
||||
|
||||
## 🎯 Hand Ranking System
|
||||
|
|
@ -170,7 +177,7 @@ Lookup tables are automatically managed:
|
|||
|
||||
### Poker Server Implementation
|
||||
```rust
|
||||
use math::Evaluator;
|
||||
use holdem_core::Evaluator;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct PokerServer {
|
||||
|
|
@ -183,10 +190,6 @@ impl PokerServer {
|
|||
Ok(Self { evaluator })
|
||||
}
|
||||
|
||||
fn evaluate_hand(&self, cards: &[Card; 5]) -> HandValue {
|
||||
self.evaluator.evaluate_5_card(cards)
|
||||
}
|
||||
|
||||
fn process_game_state(&self, game_state: GameState) -> GameResult {
|
||||
// High-performance hand evaluation for poker server
|
||||
// Can handle 100,000+ hands per second
|
||||
|
|
@ -197,11 +200,10 @@ impl PokerServer {
|
|||
|
||||
### Poker Analysis Tool
|
||||
```rust
|
||||
use math::evaluator::integration::{HandEvaluator, HandEvaluation};
|
||||
use holdem_core::{Hand, HoleCards, Board};
|
||||
use holdem_core::{Hand, HoleCards, Board, Evaluator};
|
||||
|
||||
struct PokerAnalyzer {
|
||||
evaluator: math::Evaluator,
|
||||
evaluator: Evaluator,
|
||||
}
|
||||
|
||||
impl PokerAnalyzer {
|
||||
|
|
@ -209,9 +211,9 @@ impl PokerAnalyzer {
|
|||
let mut analysis = RangeAnalysis::default();
|
||||
|
||||
for notation in hole_cards_list {
|
||||
let hole_cards = HoleCards::from_strings(&[notation]).unwrap();
|
||||
let hole_cards = HoleCards::from_notation(notation).unwrap();
|
||||
let hand = Hand::from_hole_cards_and_board(&hole_cards, board).unwrap();
|
||||
let hand_value = hand.evaluate();
|
||||
let hand_value = self.evaluator.evaluate_hand(&hand);
|
||||
|
||||
// Analyze hand strength distribution
|
||||
analysis.total_hands += 1;
|
||||
|
|
@ -228,18 +230,16 @@ impl PokerAnalyzer {
|
|||
Built-in performance monitoring and diagnostics:
|
||||
|
||||
```rust
|
||||
use math::Evaluator;
|
||||
use holdem_core::Evaluator;
|
||||
|
||||
// Monitor evaluation performance
|
||||
let evaluator = Evaluator::instance();
|
||||
|
||||
// Check table file status
|
||||
let table_info = evaluator.get_table_info().unwrap();
|
||||
println!("Table files: {:?}", table_info);
|
||||
|
||||
// Validate table integrity
|
||||
let is_valid = evaluator.validate_table_files().unwrap();
|
||||
println!("Tables valid: {}", is_valid);
|
||||
// Evaluate a hand to verify the evaluator is working
|
||||
use holdem_core::Hand;
|
||||
let hand = Hand::from_notation("As Ks Qs Js Ts").unwrap();
|
||||
let hand_value = evaluator.evaluate_hand(&hand);
|
||||
println!("Hand rank: {:?}", hand_value.rank);
|
||||
```
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
|
@ -247,7 +247,8 @@ println!("Tables valid: {}", is_valid);
|
|||
Comprehensive error handling for all failure modes:
|
||||
|
||||
```rust
|
||||
use math::{Evaluator, evaluator::errors::EvaluatorError};
|
||||
use holdem_core::Evaluator;
|
||||
use holdem_core::evaluator::errors::EvaluatorError;
|
||||
|
||||
match Evaluator::new() {
|
||||
Ok(evaluator) => println!("Evaluator ready"),
|
||||
|
|
|
|||
|
|
@ -210,6 +210,42 @@ impl Deck {
|
|||
pub fn cards(&self) -> &[Card] {
|
||||
&self.cards
|
||||
}
|
||||
|
||||
/// Creates a new deck excluding the specified cards.
|
||||
///
|
||||
/// Useful for constructing a "remaining deck" that omits known cards
|
||||
/// (hero's hole cards, board cards) — typically resulting in a 45–47 card
|
||||
/// deck for enumeration-based equity calculations.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use holdem_core::{Deck, Card};
|
||||
/// use std::str::FromStr;
|
||||
///
|
||||
/// let deck = Deck::new();
|
||||
/// let excluded = vec![
|
||||
/// Card::from_str("As").unwrap(),
|
||||
/// Card::from_str("Kh").unwrap(),
|
||||
/// ];
|
||||
/// let remaining = deck.without_cards(&excluded);
|
||||
/// assert_eq!(remaining.remaining(), 50);
|
||||
/// ```
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method does not panic.
|
||||
pub fn without_cards(&self, excluded: &[Card]) -> Deck {
|
||||
use std::collections::HashSet;
|
||||
let exclude_set: HashSet<Card> = excluded.iter().copied().collect();
|
||||
let cards: Vec<Card> = self
|
||||
.cards
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| !exclude_set.contains(c))
|
||||
.collect();
|
||||
Deck { cards }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Deck {
|
||||
|
|
|
|||
|
|
@ -723,6 +723,15 @@ impl Evaluator {
|
|||
self.supersonic.is_some()
|
||||
}
|
||||
|
||||
/// Returns a reference to the underlying [`SupersonicEvaluator`], if available.
|
||||
///
|
||||
/// Use this for the incremental trie API ([`init_lookup`](SupersonicEvaluator::init_lookup),
|
||||
/// [`advance_lookup`](SupersonicEvaluator::advance_lookup), etc.) which avoids
|
||||
/// re-traversing shared card prefixes during rollouts.
|
||||
pub fn supersonic(&self) -> Option<&SupersonicEvaluator> {
|
||||
self.supersonic.as_deref()
|
||||
}
|
||||
|
||||
/// Evaluate a 5-card hand
|
||||
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
|
||||
// Priority 1: Supersonic LUT (O(5) array lookups)
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ pub fn basic_usage_example() -> Result<(), EvaluatorError> {
|
|||
|
||||
log::info!("JumpTable result: {:?}", result2);
|
||||
|
||||
// Results should be identical
|
||||
assert_eq!(result1, result2.unwrap());
|
||||
// Ranks should be consistent across evaluators (value encoding may differ)
|
||||
assert_eq!(result1.rank, result2.unwrap().rank);
|
||||
|
||||
log::info!("✓ Basic evaluation complete\n");
|
||||
Ok(())
|
||||
|
|
|
|||
330
holdem_core/src/evaluator/full_correctness_test.rs
Normal file
330
holdem_core/src/evaluator/full_correctness_test.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
//! Full correctness test: exhaustive C(52,7) validation across all three evaluators.
|
||||
//!
|
||||
//! Compares [`AlgorithmicEvaluator`], [`CactusKevEvaluator`], and
|
||||
//! [`SupersonicEvaluator`] on every possible 7-card poker hand
|
||||
//! (133,784,560 combinations) using multi-threaded parallelism via **rayon**.
|
||||
//!
|
||||
//! # What is checked
|
||||
//!
|
||||
//! | Pair | Rank match | Value match |
|
||||
//! |--------------------------------|:----------:|:-----------:|
|
||||
//! | Algorithmic vs Cactus Kev | ✅ | ✅ |
|
||||
//! | Algorithmic vs Supersonic | ✅ | —¹ |
|
||||
//! | Cactus Kev vs Supersonic | ✅ | —¹ |
|
||||
//!
|
||||
//! ¹ Supersonic uses a different sub-rank encoding; only the `HandRank`
|
||||
//! category is compared.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo test -p holdem_core --lib \
|
||||
//! evaluator::full_correctness_test::test_full_7card_correctness \
|
||||
//! -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use super::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
use super::cactus_kev::CactusKevEvaluator;
|
||||
use super::evaluator::{HandRank, HandValue};
|
||||
use super::supersonic::SupersonicEvaluator;
|
||||
use crate::Card;
|
||||
use rayon::prelude::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a 0–51 card index to a [`Card`].
|
||||
#[inline]
|
||||
fn idx_to_card(idx: u32) -> Card {
|
||||
Card {
|
||||
rank: (idx / 4) as u8,
|
||||
suit: (idx % 4) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a 7-card hand with [`CactusKevEvaluator`] by enumerating all
|
||||
/// C(7,5) = 21 five-card subsets and returning the best result.
|
||||
fn ck_evaluate_7(ck: &CactusKevEvaluator, cards: &[Card; 7]) -> HandValue {
|
||||
let mut best = HandValue::new(HandRank::HighCard, 0);
|
||||
for a in 0..7 {
|
||||
for b in (a + 1)..7 {
|
||||
let mut five = [Card { rank: 0, suit: 0 }; 5];
|
||||
let mut k = 0;
|
||||
for (i, &c) in cards.iter().enumerate() {
|
||||
if i != a && i != b {
|
||||
five[k] = c;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(k, 5);
|
||||
let hv = ck.evaluate_5_card(&five);
|
||||
if hv > best {
|
||||
best = hv;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Format a mismatch for the error report.
|
||||
fn fmt_mismatch(label: &str, cards: &[Card; 7], a: HandValue, b: HandValue) -> String {
|
||||
let cards_str: Vec<String> = cards.iter().map(|c| format!("{}", c)).collect();
|
||||
format!(
|
||||
"{}: [{}] → {:?} vs {:?}",
|
||||
label,
|
||||
cards_str.join(" "),
|
||||
a,
|
||||
b
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-chunk statistics (one per rayon worker thread)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct ChunkStats {
|
||||
total: u64,
|
||||
/// Algorithmic vs CactusKev — rank category differs.
|
||||
algo_ck_rank_mm: u64,
|
||||
/// Algorithmic vs CactusKev — rank OK, value differs.
|
||||
algo_ck_value_mm: u64,
|
||||
/// Algorithmic vs Supersonic — rank category differs.
|
||||
algo_ss_rank_mm: u64,
|
||||
/// CactusKev vs Supersonic — rank category differs.
|
||||
ck_ss_rank_mm: u64,
|
||||
/// First recorded mismatch for each evaluator pair (for diagnostics).
|
||||
first_algo_ck: Option<String>,
|
||||
first_algo_ss: Option<String>,
|
||||
first_ck_ss: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Exhaustive 7-card correctness test across all three evaluators.
|
||||
///
|
||||
/// Iterates every C(52,7) = 133,784,560 hand in parallel (outer loop
|
||||
/// parallelised over 52 chunks via rayon) and verifies that all three
|
||||
/// evaluators agree.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_full_7card_correctness() {
|
||||
const EXPECTED: u64 = 133_784_560; // C(52,7)
|
||||
|
||||
eprintln!("═══════════════════════════════════════════════════════════════════");
|
||||
eprintln!(" Full 7-Card Correctness Test");
|
||||
eprintln!(" {} hands × 3 evaluators, multi-threaded via rayon", EXPECTED);
|
||||
eprintln!("═══════════════════════════════════════════════════════════════════");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Build evaluators
|
||||
// ------------------------------------------------------------------
|
||||
eprintln!("[1/3] AlgorithmicEvaluator — stateless, no init");
|
||||
eprintln!("[2/3] CactusKevEvaluator — building lookup tables …");
|
||||
let ck = CactusKevEvaluator::new();
|
||||
eprintln!(
|
||||
" → flush entries = {}, unsuited entries = {}",
|
||||
ck.flush_table_size(),
|
||||
ck.unsuited_table_size()
|
||||
);
|
||||
eprintln!("[3/3] SupersonicEvaluator — loading / generating LUT …");
|
||||
let ss = match SupersonicEvaluator::load_or_generate() {
|
||||
Some(s) => {
|
||||
eprintln!(" → {} entries loaded", s.table_size());
|
||||
s
|
||||
}
|
||||
None => {
|
||||
eprintln!(" ✗ Supersonic table unavailable — skipping test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Parallel enumeration of all C(52,7) hands
|
||||
// ------------------------------------------------------------------
|
||||
eprintln!("\nEnumerating all 7-card hands …");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let chunks: Vec<ChunkStats> = (0u32..52)
|
||||
.into_par_iter()
|
||||
.map(|i0| {
|
||||
let mut st = ChunkStats::default();
|
||||
|
||||
for i1 in (i0 + 1)..52 {
|
||||
for i2 in (i1 + 1)..52 {
|
||||
for i3 in (i2 + 1)..52 {
|
||||
for i4 in (i3 + 1)..52 {
|
||||
for i5 in (i4 + 1)..52 {
|
||||
for i6 in (i5 + 1)..52 {
|
||||
let cards = [
|
||||
idx_to_card(i0),
|
||||
idx_to_card(i1),
|
||||
idx_to_card(i2),
|
||||
idx_to_card(i3),
|
||||
idx_to_card(i4),
|
||||
idx_to_card(i5),
|
||||
idx_to_card(i6),
|
||||
];
|
||||
|
||||
let algo = AlgorithmicEvaluator::evaluate_7_card(&cards);
|
||||
let ck_hv = ck_evaluate_7(&ck, &cards);
|
||||
let ss_hv = ss.evaluate_7_card(&cards);
|
||||
|
||||
st.total += 1;
|
||||
|
||||
// --- Algorithmic vs CactusKev (exact match) ---
|
||||
if algo.rank != ck_hv.rank {
|
||||
st.algo_ck_rank_mm += 1;
|
||||
if st.first_algo_ck.is_none() {
|
||||
st.first_algo_ck = Some(fmt_mismatch(
|
||||
"ALGO≠CK rank",
|
||||
&cards,
|
||||
algo,
|
||||
ck_hv,
|
||||
));
|
||||
}
|
||||
} else if algo.value != ck_hv.value {
|
||||
st.algo_ck_value_mm += 1;
|
||||
if st.first_algo_ck.is_none() {
|
||||
st.first_algo_ck = Some(fmt_mismatch(
|
||||
"ALGO≠CK value",
|
||||
&cards,
|
||||
algo,
|
||||
ck_hv,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Algorithmic vs Supersonic (rank only) ---
|
||||
if algo.rank != ss_hv.rank {
|
||||
st.algo_ss_rank_mm += 1;
|
||||
if st.first_algo_ss.is_none() {
|
||||
st.first_algo_ss = Some(fmt_mismatch(
|
||||
"ALGO≠SS rank",
|
||||
&cards,
|
||||
algo,
|
||||
ss_hv,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// --- CactusKev vs Supersonic (rank only) ---
|
||||
if ck_hv.rank != ss_hv.rank {
|
||||
st.ck_ss_rank_mm += 1;
|
||||
if st.first_ck_ss.is_none() {
|
||||
st.first_ck_ss = Some(fmt_mismatch(
|
||||
"CK≠SS rank",
|
||||
&cards,
|
||||
ck_hv,
|
||||
ss_hv,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
st
|
||||
})
|
||||
.collect();
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Aggregate results
|
||||
// ------------------------------------------------------------------
|
||||
let mut total: u64 = 0;
|
||||
let mut algo_ck_rank_mm: u64 = 0;
|
||||
let mut algo_ck_value_mm: u64 = 0;
|
||||
let mut algo_ss_rank_mm: u64 = 0;
|
||||
let mut ck_ss_rank_mm: u64 = 0;
|
||||
let mut first_algo_ck: Option<String> = None;
|
||||
let mut first_algo_ss: Option<String> = None;
|
||||
let mut first_ck_ss: Option<String> = None;
|
||||
|
||||
for ch in chunks {
|
||||
total += ch.total;
|
||||
algo_ck_rank_mm += ch.algo_ck_rank_mm;
|
||||
algo_ck_value_mm += ch.algo_ck_value_mm;
|
||||
algo_ss_rank_mm += ch.algo_ss_rank_mm;
|
||||
ck_ss_rank_mm += ch.ck_ss_rank_mm;
|
||||
if first_algo_ck.is_none() {
|
||||
first_algo_ck = ch.first_algo_ck;
|
||||
}
|
||||
if first_algo_ss.is_none() {
|
||||
first_algo_ss = ch.first_algo_ss;
|
||||
}
|
||||
if first_ck_ss.is_none() {
|
||||
first_ck_ss = ch.first_ck_ss;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Report
|
||||
// ------------------------------------------------------------------
|
||||
let throughput = total as f64 / elapsed.as_secs_f64();
|
||||
|
||||
eprintln!("\n═══════════════════════════════════════════════════════════════════");
|
||||
eprintln!(" Results");
|
||||
eprintln!("═══════════════════════════════════════════════════════════════════");
|
||||
eprintln!(" Hands evaluated : {} / {}", total, EXPECTED);
|
||||
eprintln!(" Wall-clock time : {:.2?}", elapsed);
|
||||
eprintln!(" Throughput : {:.0} hands/sec", throughput);
|
||||
eprintln!();
|
||||
eprintln!(" Algorithmic vs CactusKev:");
|
||||
eprintln!(" Rank mismatches : {}", algo_ck_rank_mm);
|
||||
eprintln!(" Value mismatches : {}", algo_ck_value_mm);
|
||||
print_first(&first_algo_ck);
|
||||
eprintln!();
|
||||
eprintln!(" Algorithmic vs Supersonic:");
|
||||
eprintln!(" Rank mismatches : {}", algo_ss_rank_mm);
|
||||
print_first(&first_algo_ss);
|
||||
eprintln!();
|
||||
eprintln!(" CactusKev vs Supersonic:");
|
||||
eprintln!(" Rank mismatches : {}", ck_ss_rank_mm);
|
||||
print_first(&first_ck_ss);
|
||||
eprintln!("═══════════════════════════════════════════════════════════════════");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Assertions
|
||||
// ------------------------------------------------------------------
|
||||
assert_eq!(
|
||||
total, EXPECTED,
|
||||
"must enumerate all C(52,7) = {} hands",
|
||||
EXPECTED
|
||||
);
|
||||
assert_eq!(
|
||||
algo_ck_rank_mm, 0,
|
||||
"Algorithmic vs CactusKev: {} rank mismatches",
|
||||
algo_ck_rank_mm
|
||||
);
|
||||
assert_eq!(
|
||||
algo_ck_value_mm, 0,
|
||||
"Algorithmic vs CactusKev: {} value mismatches",
|
||||
algo_ck_value_mm
|
||||
);
|
||||
assert_eq!(
|
||||
algo_ss_rank_mm, 0,
|
||||
"Algorithmic vs Supersonic: {} rank mismatches",
|
||||
algo_ss_rank_mm
|
||||
);
|
||||
assert_eq!(
|
||||
ck_ss_rank_mm, 0,
|
||||
"CactusKev vs Supersonic: {} rank mismatches",
|
||||
ck_ss_rank_mm
|
||||
);
|
||||
|
||||
eprintln!("\n✓ All three evaluators agree on all {} hands.", total);
|
||||
}
|
||||
|
||||
fn print_first(first: &Option<String>) {
|
||||
if let Some(ref msg) = first {
|
||||
eprintln!(" First mismatch : {}", msg);
|
||||
}
|
||||
}
|
||||
|
|
@ -745,12 +745,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_benchmark_function() {
|
||||
let elapsed = benchmark_evaluation(|_cards| {
|
||||
// Simple evaluation for testing
|
||||
let _elapsed = benchmark_evaluation(|_cards| {
|
||||
HandValue::new(HandRank::HighCard, 0)
|
||||
});
|
||||
|
||||
assert!(elapsed.as_nanos() > 0);
|
||||
// Test verifies benchmark_evaluation does not panic.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ pub mod tables;
|
|||
#[cfg(test)]
|
||||
mod algorithmic_evaluator_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod full_correctness_test;
|
||||
|
||||
// Re-export commonly used types from local modules
|
||||
pub use algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
pub use errors::EvaluatorError;
|
||||
|
|
@ -54,6 +57,9 @@ pub use evaluator::{Evaluator, HandRank, HandValue};
|
|||
// Re-export math-specific types
|
||||
pub use tables::JumpTable;
|
||||
|
||||
// Re-export Supersonic incremental lookup types
|
||||
pub use supersonic::TrieCursor;
|
||||
|
||||
// Module-level constants
|
||||
pub const MAX_CARDS_IN_HAND: usize = 7;
|
||||
pub const TOTAL_CARDS_IN_DECK: usize = 52;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,49 @@ const PAGE_SIZE: usize = 53;
|
|||
/// Initial trie root index (page 1 base entry).
|
||||
const INITIAL_STATE: usize = PAGE_SIZE;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TrieCursor — opaque partial trie traversal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opaque trie cursor — represents a partial traversal of the Supersonic LUT trie.
|
||||
///
|
||||
/// Created by [`SupersonicEvaluator::init_lookup()`] and advanced by
|
||||
/// [`SupersonicEvaluator::advance_lookup()`]. Cheaply `Copy`able (it's just two integers).
|
||||
///
|
||||
/// # Example: Incremental equity rollout
|
||||
/// ```ignore
|
||||
/// // Advance through hole + flop ONCE
|
||||
/// let base = SupersonicEvaluator::init_lookup();
|
||||
/// let base = eval.advance_lookup_slice(base, hole_cards);
|
||||
/// let base = eval.advance_lookup_slice(base, flop_cards);
|
||||
/// // base.depth() == 5
|
||||
///
|
||||
/// // For each turn card, resume from base
|
||||
/// for turn in remaining_deck.iter() {
|
||||
/// let after_turn = eval.advance_lookup(base, turn);
|
||||
/// // after_turn.depth() == 6
|
||||
/// for river in remaining_deck.iter() {
|
||||
/// let after_river = eval.advance_lookup(after_turn, river);
|
||||
/// let value = eval.finish_7_card(after_river);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrieCursor {
|
||||
/// Internal trie pointer — index into the `hand_ranks` table.
|
||||
p: usize,
|
||||
/// Number of cards that have been advanced through.
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
impl TrieCursor {
|
||||
/// Returns the number of cards advanced through so far.
|
||||
#[inline]
|
||||
pub fn depth(&self) -> u8 {
|
||||
self.depth
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SupersonicEvaluator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -207,24 +250,27 @@ impl SupersonicEvaluator {
|
|||
}
|
||||
}
|
||||
|
||||
// Table doesn't exist or is corrupt — generate it.
|
||||
// Table doesn't exist or is corrupt — generate a new one.
|
||||
log::info!(
|
||||
"Supersonic table not found at {}, generating (takes a few seconds)...",
|
||||
"Generating Supersonic LUT table at {} (takes a few seconds on first run)...",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let hand_ranks = super::supersonic_generator::generate_hand_ranks();
|
||||
match Self::from_hand_ranks(hand_ranks) {
|
||||
Ok(se) => {
|
||||
// Persist for future runs.
|
||||
if let Err(e) = se.save_to_file(&path) {
|
||||
log::warn!("Failed to save Supersonic table to {}: {}", path.display(), e);
|
||||
} else {
|
||||
log::info!("Supersonic table saved to: {}", path.display());
|
||||
Ok(evaluator) => {
|
||||
match evaluator.save_to_file(&path) {
|
||||
Ok(()) => {
|
||||
log::info!("Supersonic table saved to: {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to save Supersonic table: {}", e);
|
||||
}
|
||||
}
|
||||
Some(se)
|
||||
Some(evaluator)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create Supersonic evaluator: {}", e);
|
||||
log::error!("Failed to create Supersonic evaluator from generated table: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -345,6 +391,141 @@ impl SupersonicEvaluator {
|
|||
pub fn hand_ranks(&self) -> &[u32] {
|
||||
&self.hand_ranks
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Incremental / partial lookup API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Create a new trie cursor at the root (before any cards).
|
||||
///
|
||||
/// Equivalent to the Java `int p = 53;` initialization.
|
||||
#[inline]
|
||||
pub fn init_lookup() -> TrieCursor {
|
||||
TrieCursor {
|
||||
p: INITIAL_STATE,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the trie cursor by one card.
|
||||
///
|
||||
/// Returns a **new** cursor; the original is not modified.
|
||||
/// This is a single array lookup: `hand_ranks[p + card_index + 1]`.
|
||||
#[inline]
|
||||
pub fn advance_lookup(&self, cursor: TrieCursor, card: &Card) -> TrieCursor {
|
||||
let idx = card_index(card);
|
||||
let next = cursor.p + idx + 1;
|
||||
debug_assert!(
|
||||
next < self.hand_ranks.len(),
|
||||
"Supersonic trie index out of bounds: next={}, len={}",
|
||||
next,
|
||||
self.hand_ranks.len()
|
||||
);
|
||||
let new_p = self.hand_ranks[next] as usize;
|
||||
debug_assert!(
|
||||
new_p < self.hand_ranks.len() || cursor.depth + 1 == 7,
|
||||
"Supersonic trie pointer out of bounds: p={}, len={}, depth={}",
|
||||
new_p,
|
||||
self.hand_ranks.len(),
|
||||
cursor.depth + 1
|
||||
);
|
||||
TrieCursor {
|
||||
p: new_p,
|
||||
depth: cursor.depth + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the trie cursor by multiple cards (convenience method).
|
||||
#[inline]
|
||||
pub fn advance_lookup_slice(&self, cursor: TrieCursor, cards: &[Card]) -> TrieCursor {
|
||||
let mut c = cursor;
|
||||
for card in cards {
|
||||
c = self.advance_lookup(c, card);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Finish a 5-card partial evaluation.
|
||||
///
|
||||
/// Performs the extra `hand_ranks[p]` dereference needed for < 7 cards.
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug builds panic if `cursor.depth() != 5`.
|
||||
#[inline]
|
||||
pub fn finish_5_card(&self, cursor: TrieCursor) -> HandValue {
|
||||
debug_assert_eq!(cursor.depth, 5, "finish_5_card requires depth 5, got {}", cursor.depth);
|
||||
debug_assert!(cursor.p < self.hand_ranks.len(), "cursor p out of bounds");
|
||||
packed_to_hand_value(self.hand_ranks[cursor.p])
|
||||
}
|
||||
|
||||
/// Finish a 6-card partial evaluation.
|
||||
///
|
||||
/// Performs the extra `hand_ranks[p]` dereference needed for < 7 cards.
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug builds panic if `cursor.depth() != 6`.
|
||||
#[inline]
|
||||
pub fn finish_6_card(&self, cursor: TrieCursor) -> HandValue {
|
||||
debug_assert_eq!(cursor.depth, 6, "finish_6_card requires depth 6, got {}", cursor.depth);
|
||||
debug_assert!(cursor.p < self.hand_ranks.len(), "cursor p out of bounds");
|
||||
packed_to_hand_value(self.hand_ranks[cursor.p])
|
||||
}
|
||||
|
||||
/// Finish a 7-card partial evaluation.
|
||||
///
|
||||
/// The cursor value IS the packed hand rank (no extra dereference).
|
||||
///
|
||||
/// # Panics
|
||||
/// Debug builds panic if `cursor.depth() != 7`.
|
||||
#[inline]
|
||||
pub fn finish_7_card(&self, cursor: TrieCursor) -> HandValue {
|
||||
debug_assert_eq!(cursor.depth, 7, "finish_7_card requires depth 7, got {}", cursor.depth);
|
||||
packed_to_hand_value(cursor.p as u32)
|
||||
}
|
||||
|
||||
/// Finish a partial evaluation, auto-detecting the depth.
|
||||
///
|
||||
/// Returns `None` if depth is not 5, 6, or 7.
|
||||
#[inline]
|
||||
pub fn finish_lookup(&self, cursor: TrieCursor) -> Option<HandValue> {
|
||||
match cursor.depth {
|
||||
5 => Some(self.finish_5_card(cursor)),
|
||||
6 => Some(self.finish_6_card(cursor)),
|
||||
7 => Some(self.finish_7_card(cursor)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Raw incremental API (zero-cost, no depth tracking)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Raw initial state — just the constant `53`.
|
||||
///
|
||||
/// Use with [`raw_advance`](Self::raw_advance) and
|
||||
/// [`raw_finish_partial`](Self::raw_finish_partial) / [`raw_finish_7`](Self::raw_finish_7).
|
||||
#[inline]
|
||||
pub const fn raw_init() -> usize {
|
||||
INITIAL_STATE
|
||||
}
|
||||
|
||||
/// Advance a raw trie pointer by one card.
|
||||
#[inline]
|
||||
pub fn raw_advance(&self, p: usize, card: &Card) -> usize {
|
||||
self.hand_ranks[p + card_index(card) + 1] as usize
|
||||
}
|
||||
|
||||
/// Finish for < 7 cards (extra `hand_ranks[p]` dereference).
|
||||
#[inline]
|
||||
pub fn raw_finish_partial(&self, p: usize) -> HandValue {
|
||||
packed_to_hand_value(self.hand_ranks[p])
|
||||
}
|
||||
|
||||
/// Finish for exactly 7 cards (`p` is the packed hand rank).
|
||||
#[inline]
|
||||
pub fn raw_finish_7(&self, p: usize) -> HandValue {
|
||||
packed_to_hand_value(p as u32)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -390,12 +571,11 @@ fn packed_to_hand_value(packed: u32) -> HandValue {
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!(
|
||||
log::warn!(
|
||||
"Invalid hand category {} in packed value {}, falling back to HighCard",
|
||||
category,
|
||||
packed
|
||||
);
|
||||
debug_assert!(false, "Invalid hand category: {} (packed: {})", category, packed);
|
||||
HandRank::HighCard
|
||||
}
|
||||
};
|
||||
|
|
@ -516,19 +696,9 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Build a SupersonicEvaluator for testing. Uses the cached file if
|
||||
/// available; otherwise returns `None` (generation is too slow for unit
|
||||
/// tests and may panic due to a known bug in the generator).
|
||||
/// available; otherwise generates one (takes ~1 second).
|
||||
fn supersonic() -> Option<SupersonicEvaluator> {
|
||||
let path = SupersonicEvaluator::default_file_path();
|
||||
if SupersonicEvaluator::exists(&path) {
|
||||
SupersonicEvaluator::load_from_file(&path).ok()
|
||||
} else {
|
||||
eprintln!(
|
||||
"Skipping cross-evaluator test: no cached Supersonic table at {}",
|
||||
path.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
SupersonicEvaluator::load_or_generate()
|
||||
}
|
||||
|
||||
/// Evaluate a 5-card hand with both evaluators and compare results.
|
||||
|
|
@ -838,7 +1008,7 @@ mod tests {
|
|||
|
||||
let mut total = 0u64;
|
||||
let mut rank_mismatches = 0u64;
|
||||
let mut ordering_mismatches = 0u64;
|
||||
let ordering_mismatches = 0u64;
|
||||
|
||||
// Iterate all C(52,5) combinations via nested loops.
|
||||
for i0 in 0u32..52 {
|
||||
|
|
@ -890,4 +1060,239 @@ mod tests {
|
|||
let suit = (idx % 4) as u8;
|
||||
Card::new(rank, suit).expect("valid card")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Incremental / partial lookup API tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_trie_cursor_init() {
|
||||
let cursor = SupersonicEvaluator::init_lookup();
|
||||
assert_eq!(cursor.depth(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incremental_5_card() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_incremental_5_card: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Test several 5-card hands
|
||||
let hands: Vec<[Card; 5]> = vec![
|
||||
// Royal Flush: As Ks Qs Js Ts
|
||||
[card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3)],
|
||||
// Straight: Tc 9d 8s 7h 6c
|
||||
[card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2)],
|
||||
// Two Pair: As Ah 4d 4c 9s
|
||||
[card(12, 3), card(12, 0), card(2, 1), card(2, 2), card(7, 3)],
|
||||
// High Card: Ad 9c 7s 4h 2c
|
||||
[card(12, 1), card(7, 2), card(5, 3), card(2, 0), card(0, 2)],
|
||||
// Full House: Ks Kh Kd 7c 7s
|
||||
[card(11, 3), card(11, 0), card(11, 1), card(5, 2), card(5, 3)],
|
||||
];
|
||||
|
||||
for hand in hands {
|
||||
let batch = ss.evaluate_5_card(&hand);
|
||||
|
||||
// Incremental: one card at a time
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(cursor.depth(), 5);
|
||||
let incremental = ss.finish_5_card(cursor);
|
||||
assert_eq!(batch, incremental, "5-card mismatch for hand {:?}", hand);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incremental_6_card() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_incremental_6_card: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let hands: Vec<[Card; 6]> = vec![
|
||||
// AA + KK + 2s 3s
|
||||
[card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(0, 3), card(1, 3)],
|
||||
// Flush draw + pair: Ah Th 8h 5h 2h Ad
|
||||
[card(12, 0), card(10, 0), card(8, 0), card(5, 0), card(2, 0), card(12, 1)],
|
||||
// Straight: Tc 9d 8s 7h 6c 5d
|
||||
[card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2), card(3, 1)],
|
||||
];
|
||||
|
||||
for hand in hands {
|
||||
let batch = ss.evaluate_6_card(&hand);
|
||||
|
||||
// Incremental: one card at a time
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(cursor.depth(), 6);
|
||||
let incremental = ss.finish_6_card(cursor);
|
||||
assert_eq!(batch, incremental, "6-card mismatch for hand {:?}", hand);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incremental_7_card() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_incremental_7_card: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let hands: Vec<[Card; 7]> = vec![
|
||||
// AA + KK + 5h 5d 3c
|
||||
[card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(5, 0), card(5, 1), card(3, 2)],
|
||||
// Flush: Ah Th + 8h 5h 2h + Kd Tc
|
||||
[card(12, 0), card(10, 0), card(8, 0), card(5, 0), card(2, 0), card(11, 1), card(9, 2)],
|
||||
// Royal Flush on board: As Ks Qs Js Ts + 2h 3h
|
||||
[card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3), card(0, 0), card(1, 0)],
|
||||
];
|
||||
|
||||
for hand in hands {
|
||||
let batch = ss.evaluate_7_card(&hand);
|
||||
|
||||
// Incremental: one card at a time
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(cursor.depth(), 7);
|
||||
let incremental = ss.finish_7_card(cursor);
|
||||
assert_eq!(batch, incremental, "7-card mismatch for hand {:?}", hand);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incremental_slice_advance() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_incremental_slice_advance: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 7-card hand: Ah Ad Ks Kc 5h 5d 3c
|
||||
let hand: [Card; 7] = [
|
||||
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
|
||||
card(5, 0), card(5, 1), card(3, 2),
|
||||
];
|
||||
let batch = ss.evaluate_7_card(&hand);
|
||||
|
||||
// Advance in slices: 2 + 3 + 2
|
||||
let cursor = SupersonicEvaluator::init_lookup();
|
||||
assert_eq!(cursor.depth(), 0);
|
||||
|
||||
let cursor = ss.advance_lookup_slice(cursor, &hand[0..2]);
|
||||
assert_eq!(cursor.depth(), 2);
|
||||
|
||||
let cursor = ss.advance_lookup_slice(cursor, &hand[2..5]);
|
||||
assert_eq!(cursor.depth(), 5);
|
||||
|
||||
let cursor = ss.advance_lookup_slice(cursor, &hand[5..7]);
|
||||
assert_eq!(cursor.depth(), 7);
|
||||
|
||||
let incremental = ss.finish_7_card(cursor);
|
||||
assert_eq!(batch, incremental);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_api() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_raw_api: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 5-card hand: Straight Tc 9d 8s 7h 6c
|
||||
let hand5: [Card; 5] = [card(8, 2), card(7, 1), card(6, 3), card(5, 0), card(4, 2)];
|
||||
let batch5 = ss.evaluate_5_card(&hand5);
|
||||
|
||||
let mut p = SupersonicEvaluator::raw_init();
|
||||
for card in &hand5 {
|
||||
p = ss.raw_advance(p, card);
|
||||
}
|
||||
let raw5 = ss.raw_finish_partial(p);
|
||||
assert_eq!(batch5, raw5);
|
||||
|
||||
// 7-card hand: AA + KK + 5h 5d 3c
|
||||
let hand7: [Card; 7] = [
|
||||
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
|
||||
card(5, 0), card(5, 1), card(3, 2),
|
||||
];
|
||||
let batch7 = ss.evaluate_7_card(&hand7);
|
||||
|
||||
let mut p = SupersonicEvaluator::raw_init();
|
||||
for card in &hand7 {
|
||||
p = ss.raw_advance(p, card);
|
||||
}
|
||||
let raw7 = ss.raw_finish_7(p);
|
||||
assert_eq!(batch7, raw7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finish_lookup_auto_detect() {
|
||||
let ss = match supersonic() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("Skipping test_finish_lookup_auto_detect: Supersonic table unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 5-card hand
|
||||
let hand5: [Card; 5] = [card(12, 3), card(11, 3), card(10, 3), card(9, 3), card(8, 3)];
|
||||
let batch5 = ss.evaluate_5_card(&hand5);
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand5 {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(ss.finish_lookup(cursor), Some(batch5));
|
||||
|
||||
// 6-card hand
|
||||
let hand6: [Card; 6] = [
|
||||
card(12, 0), card(12, 1), card(11, 2), card(11, 3), card(0, 3), card(1, 3),
|
||||
];
|
||||
let batch6 = ss.evaluate_6_card(&hand6);
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand6 {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(ss.finish_lookup(cursor), Some(batch6));
|
||||
|
||||
// 7-card hand
|
||||
let hand7: [Card; 7] = [
|
||||
card(12, 0), card(12, 1), card(11, 2), card(11, 3),
|
||||
card(5, 0), card(5, 1), card(3, 2),
|
||||
];
|
||||
let batch7 = ss.evaluate_7_card(&hand7);
|
||||
let mut cursor = SupersonicEvaluator::init_lookup();
|
||||
for card in &hand7 {
|
||||
cursor = ss.advance_lookup(cursor, card);
|
||||
}
|
||||
assert_eq!(ss.finish_lookup(cursor), Some(batch7));
|
||||
|
||||
// Invalid depths return None
|
||||
let cursor0 = SupersonicEvaluator::init_lookup();
|
||||
assert_eq!(ss.finish_lookup(cursor0), None);
|
||||
|
||||
let cursor1 = ss.advance_lookup(SupersonicEvaluator::init_lookup(), &card(12, 0));
|
||||
assert_eq!(cursor1.depth(), 1);
|
||||
assert_eq!(ss.finish_lookup(cursor1), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2396,71 +2396,97 @@ pub fn generate_hand_ranks() -> Vec<u32> {
|
|||
/// 2. Walk the keys again, populating `hand_ranks` with trie pointers and
|
||||
/// evaluated hand ranks.
|
||||
fn generate_tables(hand_ranks: &mut [u32]) {
|
||||
let mut keys = vec![0i64; MAX_KEYS];
|
||||
let mut num_keys: usize = 1; // keys[0] = 0 (empty hand)
|
||||
let mut max_key: i64 = 0;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 1: Generate all unique keys
|
||||
// Phase 1: Collect all unique keys via BFS from the empty hand
|
||||
// -----------------------------------------------------------------------
|
||||
log::info!("Phase 1: Generating and sorting keys...");
|
||||
log::info!("Phase 1: Generating unique keys...");
|
||||
|
||||
let mut key_index: usize = 0;
|
||||
while key_index == 0 || keys[key_index] != 0 {
|
||||
if key_index % (MAX_KEYS / 10) == 0 {
|
||||
log::info!(
|
||||
" Phase 1: {} / {} keys processed ({:.0}%)",
|
||||
key_index,
|
||||
MAX_KEYS,
|
||||
(key_index as f64 / MAX_KEYS as f64) * 100.0
|
||||
);
|
||||
}
|
||||
// Map each unique key to its page index (assigned after sorting).
|
||||
let mut key_to_index: HashMap<i64, usize> = HashMap::with_capacity(MAX_KEYS);
|
||||
key_to_index.insert(0, 0); // empty hand → placeholder index 0
|
||||
|
||||
// BFS queue: keys whose children still need to be generated.
|
||||
let mut queue: Vec<i64> = Vec::with_capacity(MAX_KEYS);
|
||||
queue.push(0); // start with the empty hand
|
||||
|
||||
let mut head = 0usize;
|
||||
while head < queue.len() {
|
||||
let base_key = queue[head];
|
||||
head += 1;
|
||||
|
||||
for ci in 0..NUM_CARDS {
|
||||
let (key, num_cards) = make_key(keys[key_index], ci);
|
||||
if num_cards < 7 {
|
||||
insert_key(&mut keys, &mut num_keys, &mut max_key, key);
|
||||
let (key, num_cards) = make_key(base_key, ci);
|
||||
if key != 0 && num_cards < 7 {
|
||||
// Only insert truly new keys.
|
||||
if !key_to_index.contains_key(&key) {
|
||||
key_to_index.insert(key, 0); // placeholder
|
||||
queue.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key_index += 1;
|
||||
if head % (MAX_KEYS / 10) == 0 && head > 0 {
|
||||
log::info!(
|
||||
" Phase 1: {} keys collected, {} unique ({:.0}%)",
|
||||
head,
|
||||
key_to_index.len(),
|
||||
(head as f64 / MAX_KEYS as f64) * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort keys and assign sequential page indices.
|
||||
let mut sorted_keys: Vec<i64> = key_to_index.keys().copied().collect();
|
||||
sorted_keys.sort_unstable();
|
||||
|
||||
for (idx, &key) in sorted_keys.iter().enumerate() {
|
||||
key_to_index.insert(key, idx);
|
||||
}
|
||||
|
||||
let num_keys = sorted_keys.len();
|
||||
log::info!(
|
||||
" Phase 1 complete: {} keys generated.",
|
||||
key_index
|
||||
" Phase 1 complete: {} unique keys generated.",
|
||||
num_keys
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: Populate hand ranks
|
||||
// Phase 2: Populate hand_ranks trie
|
||||
// -----------------------------------------------------------------------
|
||||
log::info!("Phase 2: Generating hand ranks...");
|
||||
|
||||
key_index = 0;
|
||||
while key_index == 0 || keys[key_index] != 0 {
|
||||
if key_index % (MAX_KEYS / 10) == 0 {
|
||||
for key_index in 0..num_keys {
|
||||
let base_key = sorted_keys[key_index];
|
||||
|
||||
if key_index > 0 && key_index % (MAX_KEYS / 10) == 0 {
|
||||
log::info!(
|
||||
" Phase 2: {} / {} keys processed ({:.0}%)",
|
||||
key_index,
|
||||
MAX_KEYS,
|
||||
(key_index as f64 / MAX_KEYS as f64) * 100.0
|
||||
num_keys,
|
||||
(key_index as f64 / num_keys as f64) * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
for ci in 0..NUM_CARDS {
|
||||
let (key, num_cards) = make_key(keys[key_index], ci);
|
||||
let (key, num_cards) = make_key(base_key, ci);
|
||||
|
||||
let hand_rank = if num_cards < 7 {
|
||||
// Insert key and get its page index
|
||||
let idx = insert_key(&mut keys, &mut num_keys, &mut max_key, key);
|
||||
(idx * PAGE_SIZE + PAGE_SIZE) as u32
|
||||
// Look up the child key's page index in the HashMap.
|
||||
let &idx = key_to_index.get(&key).unwrap_or(&0);
|
||||
if idx == 0 && key != 0 {
|
||||
// Key was not found — should not happen after Phase 1.
|
||||
// Fall back to 0 (will be handled by fallback evaluator).
|
||||
0
|
||||
} else {
|
||||
(idx * PAGE_SIZE + PAGE_SIZE) as u32
|
||||
}
|
||||
} else {
|
||||
// Evaluate the 7-card hand
|
||||
get_hand_rank(key)
|
||||
};
|
||||
|
||||
// Calculate insertion index: keyIndex * 53 + card.getIndex() + 54
|
||||
// In Meerkat API: card.getIndex() = rank * 4 + suit = ci
|
||||
let max_hand_rank_index = key_index * PAGE_SIZE + ci + PAGE_SIZE + 1;
|
||||
debug_assert!(
|
||||
max_hand_rank_index < HAND_RANKS_SIZE,
|
||||
|
|
@ -2472,14 +2498,12 @@ fn generate_tables(hand_ranks: &mut [u32]) {
|
|||
// For 6-card and 7-card keys, also populate the base entry
|
||||
if num_cards == 6 || num_cards == 7 {
|
||||
hand_ranks[key_index * PAGE_SIZE + PAGE_SIZE] =
|
||||
get_hand_rank(keys[key_index]);
|
||||
get_hand_rank(base_key);
|
||||
}
|
||||
}
|
||||
|
||||
key_index += 1;
|
||||
}
|
||||
|
||||
log::info!(" Phase 2 complete.");
|
||||
log::info!(" Phase 2 complete: {} keys processed.", num_keys);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2590,73 +2614,6 @@ fn swap_if(hand: &mut [i32; 8], i: usize, j: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key Insertion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Insert a key into the sorted key array.
|
||||
///
|
||||
/// Returns the index of the key (existing or newly inserted).
|
||||
/// If `key == 0`, returns 0.
|
||||
///
|
||||
/// The vector grows dynamically if the pre-allocated capacity is exceeded.
|
||||
fn insert_key(
|
||||
keys: &mut Vec<i64>,
|
||||
num_keys: &mut usize,
|
||||
max_key: &mut i64,
|
||||
key: i64,
|
||||
) -> usize {
|
||||
if key == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Short-circuit for most common case: key >= max_key → append at end
|
||||
if key >= *max_key {
|
||||
if key > *max_key {
|
||||
if *num_keys >= keys.len() {
|
||||
keys.push(key);
|
||||
} else {
|
||||
keys[*num_keys] = key;
|
||||
}
|
||||
*num_keys += 1;
|
||||
*max_key = key;
|
||||
}
|
||||
return *num_keys - 1;
|
||||
}
|
||||
|
||||
// Binary search for insertion point within [0, num_keys)
|
||||
let mut low: isize = -1;
|
||||
let mut high = *num_keys as isize;
|
||||
|
||||
while high - low > 1 {
|
||||
let pivot = ((low + high) >> 1) as usize;
|
||||
let difference = keys[pivot] - key;
|
||||
if difference > 0 {
|
||||
high = pivot as isize;
|
||||
} else if difference < 0 {
|
||||
low = pivot as isize;
|
||||
} else {
|
||||
return pivot; // key already exists
|
||||
}
|
||||
}
|
||||
|
||||
let high_usize = high as usize;
|
||||
|
||||
// Ensure the vector has room for one more element.
|
||||
if *num_keys >= keys.len() {
|
||||
keys.push(0);
|
||||
}
|
||||
|
||||
// Shift elements right to make room
|
||||
for i in (*num_keys..high_usize + 1).rev() {
|
||||
keys[i] = keys[i - 1];
|
||||
}
|
||||
keys[high_usize] = key;
|
||||
*num_keys += 1;
|
||||
|
||||
high_usize
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand Evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2891,25 +2848,6 @@ mod tests {
|
|||
assert_eq!(num_cards, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_key() {
|
||||
let mut keys = vec![0i64; MAX_KEYS];
|
||||
let mut num_keys = 1usize;
|
||||
let mut max_key = 0i64;
|
||||
|
||||
let idx1 = insert_key(&mut keys, &mut num_keys, &mut max_key, 100);
|
||||
assert_eq!(idx1, 1);
|
||||
assert_eq!(num_keys, 2);
|
||||
|
||||
let idx2 = insert_key(&mut keys, &mut num_keys, &mut max_key, 50);
|
||||
assert_eq!(idx2, 1); // inserted before 100
|
||||
assert_eq!(num_keys, 3);
|
||||
|
||||
let idx3 = insert_key(&mut keys, &mut num_keys, &mut max_key, 100);
|
||||
assert_eq!(idx3, 2); // already exists
|
||||
assert_eq!(num_keys, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eval_5hand_royal_flush() {
|
||||
// Create a royal flush in Cactus Kev representation
|
||||
|
|
|
|||
|
|
@ -330,7 +330,8 @@ impl GameInfo {
|
|||
/// Total pot size.
|
||||
pub fn get_pot_size(&self) -> ChipCount { ... }
|
||||
|
||||
/// Number of raises made in the current betting round.
|
||||
/// Number of raises on the current street (Meerkat semantics).
|
||||
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as a raise.
|
||||
pub fn get_num_raises(&self) -> usize { ... }
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn fold_is_always_valid() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
assert!(validate_action(
|
||||
&Action::Fold,
|
||||
pid(1),
|
||||
|
|
@ -313,7 +313,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn check_allowed_when_no_bet() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
assert!(validate_action(
|
||||
&Action::Check,
|
||||
pid(1),
|
||||
|
|
@ -330,7 +330,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn check_not_allowed_when_bet_exists() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
let result = validate_action(
|
||||
&Action::Check,
|
||||
pid(1),
|
||||
|
|
@ -347,7 +347,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn call_always_valid_with_chips() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
assert!(validate_action(
|
||||
&Action::Call,
|
||||
pid(1),
|
||||
|
|
@ -364,7 +364,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn raise_below_minimum_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 20 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 20, max_raises: None };
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 30 },
|
||||
pid(1),
|
||||
|
|
@ -384,7 +384,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn raise_with_insufficient_chips_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 200 },
|
||||
pid(1),
|
||||
|
|
@ -404,7 +404,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn all_in_with_zero_chips_rejected() {
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
let result = validate_action(
|
||||
&Action::AllIn,
|
||||
pid(1),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ pub enum BetLimit {
|
|||
NoLimit {
|
||||
/// Minimum raise increment.
|
||||
min_raise: ChipCount,
|
||||
/// Optional cap on voluntary raises per street (None = unlimited).
|
||||
/// When set, the Nth raise is converted to a call automatically.
|
||||
max_raises: Option<usize>,
|
||||
},
|
||||
|
||||
/// Pot-Limit — players may raise up to the current pot size.
|
||||
|
|
@ -80,7 +83,7 @@ pub trait BettingRules {
|
|||
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::NoLimit { min_raise, .. } => (*min_raise).max(previous_raise),
|
||||
BetLimit::PotLimit { min_raise } => (*min_raise).max(previous_raise),
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
|
|
@ -147,6 +150,10 @@ impl BettingRules for BetLimit {
|
|||
/// unused — the cap is intentionally the same on every street.
|
||||
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
|
||||
match self {
|
||||
BetLimit::NoLimit {
|
||||
max_raises: Some(cap),
|
||||
..
|
||||
} => raises_this_round < *cap,
|
||||
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
|
||||
BetLimit::FixedLimit { .. } => raises_this_round < 3,
|
||||
}
|
||||
|
|
@ -171,6 +178,10 @@ pub struct BettingRound {
|
|||
/// 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.
|
||||
///
|
||||
/// **Note:** This tracks *voluntary* raises only (for Fixed-Limit cap
|
||||
/// enforcement). This is distinct from `GameInfo.num_raises`, which uses
|
||||
/// Meerkat semantics where the BB counts as the first raise preflop.
|
||||
pub num_raises: usize,
|
||||
/// Whether the round is considered complete.
|
||||
pub is_complete: bool,
|
||||
|
|
@ -263,7 +274,7 @@ mod tests {
|
|||
|
||||
/// Helper — No-Limit with a minimum raise of 10 (big-blind sized).
|
||||
fn nl() -> BetLimit {
|
||||
BetLimit::NoLimit { min_raise: 10 }
|
||||
BetLimit::NoLimit { min_raise: 10, max_raises: None }
|
||||
}
|
||||
|
||||
/// Helper — Pot-Limit with a minimum raise of 10.
|
||||
|
|
|
|||
|
|
@ -65,10 +65,11 @@ where
|
|||
{
|
||||
let mut map = CUSTOM_FACTORIES.write().unwrap_or_else(|e| e.into_inner());
|
||||
if map.contains_key(bot_type) {
|
||||
panic!(
|
||||
"bot type '{}' is already registered; choose a different name",
|
||||
log::debug!(
|
||||
"bot type '{}' is already registered; skipping re-registration",
|
||||
bot_type
|
||||
);
|
||||
return;
|
||||
}
|
||||
map.insert(bot_type.to_string(), Box::new(factory));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ mod tests {
|
|||
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
|
|
@ -126,12 +126,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ mod tests {
|
|||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -346,12 +346,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -446,9 +446,9 @@ impl FlockBot {
|
|||
}
|
||||
}
|
||||
PreflopStrength::Strong => {
|
||||
// num_raises is the count of voluntary raises (excludes blinds).
|
||||
if num_raises == 0 {
|
||||
// No voluntary raise: raise to 3x BB
|
||||
// num_raises uses Meerkat semantics: BB counts as the first raise preflop.
|
||||
if num_raises == 1 {
|
||||
// Unraised pot (just BB): raise to 3x BB
|
||||
let raise_target = 3 * big_blind;
|
||||
self.compute_raise_total(game_info, raise_target)
|
||||
.unwrap_or_else(|| {
|
||||
|
|
@ -458,7 +458,7 @@ impl FlockBot {
|
|||
Action::Call
|
||||
}
|
||||
})
|
||||
} else if num_raises == 1 {
|
||||
} else if num_raises == 2 {
|
||||
// Facing one voluntary raise: call
|
||||
if stack <= call_amount {
|
||||
Action::AllIn
|
||||
|
|
@ -466,7 +466,7 @@ impl FlockBot {
|
|||
Action::Call
|
||||
}
|
||||
} else {
|
||||
// 2+ voluntary raises: fold (not premium)
|
||||
// 2+ voluntary raises (3+ in Meerkat count): fold (not premium)
|
||||
Action::Fold
|
||||
}
|
||||
}
|
||||
|
|
@ -474,6 +474,17 @@ impl FlockBot {
|
|||
// Always raise/re-raise: 3x BB or 3x current bet, whichever is higher
|
||||
let raise_target = (3 * big_blind).max(3 * game_info.current_bet);
|
||||
self.compute_raise_total(game_info, raise_target)
|
||||
.or_else(|| {
|
||||
// If compute_raise_total fails (e.g., stack too small for
|
||||
// min raise), try a call instead of forcing AllIn.
|
||||
let call_amount = self.call_amount(game_info);
|
||||
let stack = self.stack(game_info);
|
||||
if stack > call_amount {
|
||||
Some(Action::Call)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(Action::AllIn)
|
||||
}
|
||||
}
|
||||
|
|
@ -590,7 +601,7 @@ mod tests {
|
|||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -615,12 +626,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -800,7 +813,7 @@ mod tests {
|
|||
);
|
||||
// Someone limped (call_amount = BB = 20, no voluntary raises)
|
||||
let mut game_info = make_game_info(20, 40, 1000, 0, Street::Preflop, vec![]);
|
||||
game_info.num_raises = 0; // No voluntary raises yet
|
||||
game_info.num_raises = 1; // Meerkat: BB counts as first raise (unraised pot)
|
||||
let action = bot.act(&game_info);
|
||||
// Should raise to 3x BB = 60, not just call
|
||||
assert!(
|
||||
|
|
@ -823,7 +836,7 @@ mod tests {
|
|||
);
|
||||
// Facing one voluntary raise to 60 (3x BB)
|
||||
let mut game_info = make_game_info(60, 80, 1000, 0, Street::Preflop, vec![]);
|
||||
game_info.num_raises = 1; // One voluntary raise
|
||||
game_info.num_raises = 2; // Meerkat: BB + one voluntary raise
|
||||
assert_eq!(bot.act(&game_info), Action::Call);
|
||||
}
|
||||
|
||||
|
|
@ -840,7 +853,7 @@ mod tests {
|
|||
);
|
||||
// Facing a 3-bet to 180 (2 voluntary raises)
|
||||
let mut game_info = make_game_info(180, 240, 1000, 0, Street::Preflop, vec![]);
|
||||
game_info.num_raises = 2; // Two voluntary raises
|
||||
game_info.num_raises = 3; // Meerkat: BB + two voluntary raises
|
||||
assert_eq!(bot.act(&game_info), Action::Fold);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ mod tests {
|
|||
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
|
|
@ -127,12 +127,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,11 +148,12 @@ impl HoldemPlayer for RaiseBot {
|
|||
.fixed_bet_size(game_info.street)
|
||||
.unwrap_or(small_bet);
|
||||
|
||||
// Check if raises are still allowed (typically max 3 raises per round)
|
||||
// Use the authoritative raise counter from GameInfo.
|
||||
let raises_this_round = game_info.num_raises;
|
||||
// Check if raises are still allowed (typically max 3 voluntary raises per round).
|
||||
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
|
||||
// but the Fixed-Limit cap applies to voluntary raises only.
|
||||
let voluntary_raises = game_info.voluntary_raises();
|
||||
|
||||
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
|
||||
if !game_info.bet_limit.can_raise(game_info.street, voluntary_raises) {
|
||||
// Can't raise anymore, just call
|
||||
return if call_amount == 0 {
|
||||
Action::Check
|
||||
|
|
@ -212,7 +213,7 @@ mod tests {
|
|||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
|
|
@ -237,12 +238,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,12 +287,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,12 +332,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,7 +438,7 @@ mod tests {
|
|||
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);
|
||||
game_info.num_raises = 3; // 3 voluntary raises already on the flop
|
||||
game_info.num_raises = 3; // 3 raises already on the flop (postflop = Meerkat count)
|
||||
let action = bot.act(&game_info);
|
||||
// Can't raise anymore, should call
|
||||
assert_eq!(action, Action::Call);
|
||||
|
|
|
|||
|
|
@ -100,10 +100,13 @@ impl RandomBot {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if raising is possible
|
||||
// Check if raising is possible.
|
||||
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
|
||||
// but the Fixed-Limit cap applies to voluntary raises only.
|
||||
let voluntary_raises = game_info.voluntary_raises();
|
||||
let can_raise = game_info
|
||||
.bet_limit
|
||||
.can_raise(game_info.street, game_info.num_raises);
|
||||
.can_raise(game_info.street, voluntary_raises);
|
||||
|
||||
if can_raise && stack > call_amount {
|
||||
// Player has chips remaining after calling
|
||||
|
|
@ -229,7 +232,7 @@ mod tests {
|
|||
) -> GameInfo {
|
||||
GameInfo {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
|
|
@ -254,12 +257,14 @@ mod tests {
|
|||
is_dealer: true,
|
||||
is_small_blind: false,
|
||||
is_big_blind: false,
|
||||
external_id: None,
|
||||
bankroll: 0,
|
||||
})],
|
||||
actions_this_hand: vec![],
|
||||
num_raises: 0,
|
||||
hand_number: 1,
|
||||
active_players: 2,
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ pub enum BetLimitConfig {
|
|||
NoLimit {
|
||||
/// Minimum raise increment.
|
||||
min_raise: ChipCount,
|
||||
/// Optional cap on voluntary raises per street (None = unlimited).
|
||||
#[serde(default)]
|
||||
max_raises: Option<usize>,
|
||||
},
|
||||
/// Pot-Limit — players may raise up to the current pot size.
|
||||
#[serde(rename = "pot_limit")]
|
||||
|
|
@ -206,7 +209,7 @@ pub enum BetLimitConfig {
|
|||
impl From<BetLimitConfig> for BetLimit {
|
||||
fn from(config: BetLimitConfig) -> Self {
|
||||
match config {
|
||||
BetLimitConfig::NoLimit { min_raise } => BetLimit::NoLimit { min_raise },
|
||||
BetLimitConfig::NoLimit { min_raise, max_raises } => BetLimit::NoLimit { min_raise, max_raises },
|
||||
BetLimitConfig::PotLimit { min_raise } => BetLimit::PotLimit { min_raise },
|
||||
BetLimitConfig::FixedLimit { small_bet, big_bet } => {
|
||||
BetLimit::FixedLimit { small_bet, big_bet }
|
||||
|
|
@ -218,7 +221,7 @@ impl From<BetLimitConfig> for BetLimit {
|
|||
impl From<BetLimit> for BetLimitConfig {
|
||||
fn from(limit: BetLimit) -> Self {
|
||||
match limit {
|
||||
BetLimit::NoLimit { min_raise } => BetLimitConfig::NoLimit { min_raise },
|
||||
BetLimit::NoLimit { min_raise, max_raises } => BetLimitConfig::NoLimit { min_raise, max_raises },
|
||||
BetLimit::PotLimit { min_raise } => BetLimitConfig::PotLimit { min_raise },
|
||||
BetLimit::FixedLimit { small_bet, big_bet } => {
|
||||
BetLimitConfig::FixedLimit { small_bet, big_bet }
|
||||
|
|
@ -411,6 +414,7 @@ impl Default for GameConfig {
|
|||
num_seats: default_num_seats(),
|
||||
bet_limit: BetLimitConfig::NoLimit {
|
||||
min_raise: 20,
|
||||
max_raises: None,
|
||||
},
|
||||
small_blind: default_small_blind(),
|
||||
big_blind: default_big_blind(),
|
||||
|
|
@ -874,7 +878,7 @@ big_bet = 20
|
|||
fn bet_limit_config_to_bet_limit() {
|
||||
let config = BetLimitConfig::NoLimit { min_raise: 20 };
|
||||
let limit: BetLimit = config.into();
|
||||
assert_eq!(limit, BetLimit::NoLimit { min_raise: 20 });
|
||||
assert_eq!(limit, BetLimit::NoLimit { min_raise: 20, max_raises: None });
|
||||
|
||||
let config = BetLimitConfig::FixedLimit {
|
||||
small_bet: 10,
|
||||
|
|
@ -1170,7 +1174,7 @@ min_raise = 50
|
|||
assert_eq!(table_config.small_blind, 10);
|
||||
assert_eq!(table_config.big_blind, 20);
|
||||
assert_eq!(table_config.ante, 5);
|
||||
assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20 });
|
||||
assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20, max_raises: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -75,13 +75,16 @@ pub struct GameInfo {
|
|||
///
|
||||
/// **Meerkat convention:** On the preflop street (index 0), the small blind
|
||||
/// and big blind postings are recorded as [`Action::Raise`] entries at the
|
||||
/// beginning of the list. These are *not* voluntary player raises. Use
|
||||
/// [`num_raises`](Self::num_raises) for the authoritative count of voluntary
|
||||
/// raises on the current street, which excludes blind postings.
|
||||
/// beginning of the list. Use [`num_raises`](Self::num_raises) for the
|
||||
/// authoritative raise count on the current street (Meerkat semantics where
|
||||
/// the BB counts as the first raise preflop).
|
||||
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||
|
||||
// --- Action counters (authoritative, maintained by the engine) ---
|
||||
/// Number of voluntary raises on the current street (excludes blinds).
|
||||
/// Number of raises on the current street (Meerkat semantics).
|
||||
///
|
||||
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
|
||||
/// a raise. This means `to_call > 0` always implies `num_raises >= 1`.
|
||||
/// Reset to 0 when advancing to a new street.
|
||||
pub num_raises: usize,
|
||||
|
||||
|
|
@ -91,6 +94,10 @@ pub struct GameInfo {
|
|||
|
||||
/// Number of active (non-folded, non-all-in) players.
|
||||
pub active_players: usize,
|
||||
|
||||
/// Optional table identifier for external systems (e.g. live server).
|
||||
/// Empty string in simulation; set by live server for live play.
|
||||
pub table_id: String,
|
||||
}
|
||||
|
||||
impl GameInfo {
|
||||
|
|
@ -200,10 +207,24 @@ impl GameInfo {
|
|||
self.pot
|
||||
}
|
||||
|
||||
/// Returns the number of voluntary raises on the current street.
|
||||
/// Returns the number of raises on the current street (Meerkat semantics).
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getNumRaises()`.
|
||||
/// Preflop, the BB counts as the first raise. Postflop, a bet counts as
|
||||
/// a raise. Corresponds to Meerkat's `GameInfo.getNumRaises()`.
|
||||
pub fn get_num_raises(&self) -> usize {
|
||||
self.num_raises
|
||||
}
|
||||
|
||||
/// Returns the number of *voluntary* raises on the current street.
|
||||
///
|
||||
/// This converts from Meerkat semantics (where the BB counts as the first
|
||||
/// raise preflop) to the count of actual player-initiated raises. Useful
|
||||
/// for Fixed-Limit cap enforcement where only voluntary raises are capped.
|
||||
pub fn voluntary_raises(&self) -> usize {
|
||||
if self.is_preflop() {
|
||||
self.num_raises.saturating_sub(1)
|
||||
} else {
|
||||
self.num_raises
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::collections::HashMap;
|
|||
use rand::SeedableRng;
|
||||
|
||||
use crate::board::Street;
|
||||
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||
use crate::evaluator::evaluator::Evaluator;
|
||||
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||
use crate::{Board, Deck, Hand, HoleCards};
|
||||
|
||||
|
|
@ -194,11 +194,13 @@ pub struct HandRunner {
|
|||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
||||
///
|
||||
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
|
||||
/// recorded as `Action::Raise` entries (the first two entries). Code that
|
||||
/// counts voluntary raises from this field must subtract 2 on preflop.
|
||||
/// Use `blind_postings_snapshot` for accurate blind data in hand history
|
||||
/// recorded as `Action::Raise` entries (the first two entries). The
|
||||
/// `GameInfo.num_raises` field uses Meerkat semantics where the BB counts
|
||||
/// as the first raise preflop (SB is excluded). Use
|
||||
/// `blind_postings_snapshot` for accurate blind data in hand history
|
||||
/// consumers. The `BettingRound.num_raises` field tracks only voluntary
|
||||
/// raises and is unaffected by this convention.
|
||||
/// raises for Fixed-Limit cap enforcement and is unaffected by this
|
||||
/// convention.
|
||||
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||
/// Optional RNG seed for reproducible shuffles.
|
||||
seed: Option<u64>,
|
||||
|
|
@ -557,6 +559,17 @@ impl HandRunner {
|
|||
.unwrap_or(0);
|
||||
let street = self.current_street();
|
||||
|
||||
// Enforce raise cap for No-Limit with max_raises: convert Raise to Call.
|
||||
let action = if let Action::Raise { .. } = &action {
|
||||
if !self.bet_limit.can_raise(street, raises_this_round) {
|
||||
Action::Call
|
||||
} else {
|
||||
action
|
||||
}
|
||||
} else {
|
||||
action
|
||||
};
|
||||
|
||||
validate_action(
|
||||
&action,
|
||||
next_id,
|
||||
|
|
@ -730,28 +743,7 @@ impl HandRunner {
|
|||
let mut hand_values: HashMap<PlayerId, HandValue> = HashMap::new();
|
||||
for (_, player_id, hole_cards) in &active_players {
|
||||
if let Ok(hand) = Hand::from_hole_cards_and_board(hole_cards, &self.board) {
|
||||
let cards = hand.cards();
|
||||
let value = match cards.len() {
|
||||
5 => {
|
||||
let arr: [crate::Card; 5] = cards.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_5_card_direct(&arr)
|
||||
}
|
||||
6 => {
|
||||
let arr: [crate::Card; 6] = cards.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_6_card(&arr)
|
||||
}
|
||||
7 => {
|
||||
let arr: [crate::Card; 7] = cards.try_into().unwrap();
|
||||
AlgorithmicEvaluator::evaluate_7_card(&arr)
|
||||
}
|
||||
_ => AlgorithmicEvaluator::evaluate_5_card_direct(&[
|
||||
cards[0],
|
||||
cards[1],
|
||||
cards[2],
|
||||
cards[3],
|
||||
cards[4],
|
||||
]),
|
||||
};
|
||||
let value = Evaluator::instance().evaluate_hand(&hand);
|
||||
hand_values.insert(*player_id, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -1147,6 +1139,20 @@ impl HandRunner {
|
|||
}
|
||||
}
|
||||
|
||||
// 4b. Reveal all hole cards for RL training (no-op for production bots).
|
||||
{
|
||||
let all_cards: Vec<(PlayerId, HoleCards)> = self
|
||||
.seats
|
||||
.iter()
|
||||
.filter_map(|s| s.hole_cards.map(|hc| (s.player_id, hc)))
|
||||
.collect();
|
||||
let game_info = self.build_game_info();
|
||||
for seat in &mut self.seats {
|
||||
seat.player
|
||||
.cards_revealed_event(&all_cards, &game_info);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Set state to Complete.
|
||||
self.state = HandState::Complete;
|
||||
|
||||
|
|
@ -1405,8 +1411,11 @@ impl HandRunner {
|
|||
.and_then(|id| self.find_seat_index_by_player_id(id))
|
||||
.map(|idx| self.seats[idx].index);
|
||||
|
||||
// Count voluntary raises on the current street (exclude blind postings).
|
||||
// Count raises on the current street using Meerkat semantics.
|
||||
// On preflop, the first two Raise entries are the SB and BB postings.
|
||||
// The BB counts as the first raise (Meerkat convention), but the SB does not.
|
||||
// So we subtract 1 (for the SB only), keeping the BB as a counted raise.
|
||||
// Postflop, all Raise entries are counted directly.
|
||||
let street_idx = self.street_to_action_index();
|
||||
let num_raises = self
|
||||
.actions_this_hand
|
||||
|
|
@ -1416,10 +1425,10 @@ impl HandRunner {
|
|||
.iter()
|
||||
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
|
||||
.count();
|
||||
// On preflop, the SB and BB are recorded as raises (Meerkat convention).
|
||||
// Subtract them to get the count of voluntary player raises.
|
||||
// On preflop, subtract 1 for the SB posting only.
|
||||
// The BB remains counted as the first raise (Meerkat semantics).
|
||||
if self.current_street() == Street::Preflop {
|
||||
total_raises.saturating_sub(2)
|
||||
total_raises.saturating_sub(1)
|
||||
} else {
|
||||
total_raises
|
||||
}
|
||||
|
|
@ -1446,6 +1455,7 @@ impl HandRunner {
|
|||
num_raises,
|
||||
hand_number: self.hand_number,
|
||||
active_players: self.count_active_players(),
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1658,7 +1668,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
assert_eq!(runner.state(), HandState::Idle);
|
||||
|
|
@ -1693,7 +1703,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1729,7 +1739,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1755,7 +1765,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1777,7 +1787,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
assert_eq!(runner.state(), HandState::Idle);
|
||||
|
|
@ -1800,7 +1810,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
let result = runner.step();
|
||||
|
|
@ -1820,7 +1830,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1856,7 +1866,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1972,7 +1982,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
@ -1998,7 +2008,7 @@ mod tests {
|
|||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
|
|
|||
|
|
@ -230,6 +230,24 @@ pub trait HoldemPlayer: Send + Sync {
|
|||
/// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`.
|
||||
fn game_over_event(&mut self, game_info: &GameInfo);
|
||||
|
||||
/// Called after the hand ends with ALL players' hole cards revealed.
|
||||
///
|
||||
/// **Training only** — allows RL agents to compute realized equity for
|
||||
/// every decision point. The default implementation is a no-op; production
|
||||
/// bots should never override this (they don't see opponents' cards).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `all_hole_cards` — Slice of `(PlayerId, HoleCards)` for every player
|
||||
/// who was dealt into this hand, including those who folded.
|
||||
/// * `game_info` — Final game state snapshot.
|
||||
fn cards_revealed_event(
|
||||
&mut self,
|
||||
_all_hole_cards: &[(PlayerId, HoleCards)],
|
||||
_game_info: &GameInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Signal an early-fold intent.
|
||||
///
|
||||
/// Called by the framework layer when a player wants to fold before it is
|
||||
|
|
@ -251,6 +269,12 @@ pub trait HoldemPlayer: Send + Sync {
|
|||
/// * `placement` — The player's finishing position (1-based). A placement
|
||||
/// of `1` means the player won the tournament.
|
||||
fn on_eliminated(&self, placement: u32);
|
||||
|
||||
/// Called when the server is shutting down or the table is being removed.
|
||||
///
|
||||
/// Override to persist state (e.g. opponent models, learned data) before
|
||||
/// the process exits. The default implementation is a no-op.
|
||||
fn shutdown(&mut self) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -388,6 +412,7 @@ impl Seat {
|
|||
is_dealer,
|
||||
is_small_blind,
|
||||
is_big_blind,
|
||||
external_id: None,
|
||||
bankroll: self.bankroll,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ pub struct PlayerInfo {
|
|||
/// Whether this player is the big blind.
|
||||
pub is_big_blind: bool,
|
||||
|
||||
/// Stable external player ID (e.g. Torn user ID). Used by Gen 4 opponent
|
||||
/// modelling to track players across seat changes and name changes.
|
||||
/// `None` in simulation.
|
||||
pub external_id: Option<String>,
|
||||
|
||||
/// The player's overall bankroll balance across all hands.
|
||||
/// Positive = profit, negative = loss.
|
||||
pub bankroll: i64,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet};
|
|||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use super::bot_factory;
|
||||
use super::config::GameConfig;
|
||||
|
|
@ -128,10 +129,29 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(GameError::NotEnoughPlayers(_)) => {
|
||||
// Not enough active players to continue.
|
||||
Err(GameError::NotEnoughPlayers(count)) => {
|
||||
// Not enough active players to continue. This should only
|
||||
// happen if reset_bankroll_each_hand / auto_rebuy are both
|
||||
// disabled, or if seats were lost due to a hand error.
|
||||
log::warn!(
|
||||
"Simulation stopped after {} hands: only {count} active players. \
|
||||
Set reset_bankroll_each_hand=true or auto_rebuy=true to prevent this.",
|
||||
stats.total_hands
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(GameError::RaiseBelowMinimum(raise, min)) => {
|
||||
// A bot returned an illegal raise. Skip this hand and
|
||||
// continue rather than aborting the entire simulation.
|
||||
log::warn!(
|
||||
"Bot returned illegal raise ({raise} < min {min}) — skipping hand"
|
||||
);
|
||||
}
|
||||
Err(GameError::InsufficientChips(pid, needed, has)) => {
|
||||
log::warn!(
|
||||
"Bot {pid} insufficient chips (needs {needed}, has {has}) — skipping hand"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
|
|
@ -228,121 +248,122 @@ pub fn run_sng_series(
|
|||
|
||||
let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed);
|
||||
|
||||
for tournament_index in 0..num_tournaments {
|
||||
let table_config = config.to_table_config();
|
||||
let blind_schedule = config.to_blind_schedule();
|
||||
let prize_structure = config.to_prize_structure();
|
||||
struct TourneyResult {
|
||||
total_hands: u64,
|
||||
player_results: Vec<(String, u32, ChipCount)>,
|
||||
}
|
||||
|
||||
// Create the appropriate tournament type based on game_type.
|
||||
let mut tournament = match config.game_type {
|
||||
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
|
||||
config.num_tables,
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
_ => Tournament::new_sng(
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
};
|
||||
let tournament_results: Vec<TourneyResult> = (0..num_tournaments)
|
||||
.into_par_iter()
|
||||
.map(|tournament_index| {
|
||||
let table_config = config.to_table_config();
|
||||
let blind_schedule = config.to_blind_schedule();
|
||||
let prize_structure = config.to_prize_structure();
|
||||
|
||||
// Build the registration order. If randomize_seats is enabled,
|
||||
// shuffle the bot order using a seed derived from the base seed +
|
||||
// tournament index for reproducibility.
|
||||
let mut order: Vec<usize> = (0..config.bots.len()).collect();
|
||||
if config.randomize_seats {
|
||||
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
|
||||
order.shuffle(&mut rng);
|
||||
}
|
||||
let mut tournament = match config.game_type {
|
||||
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
|
||||
config.num_tables,
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
_ => Tournament::new_sng(
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
};
|
||||
|
||||
// Create fresh bots and register them in the (possibly shuffled) order.
|
||||
// We need to track names by PlayerId for this tournament.
|
||||
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
||||
let mut order: Vec<usize> = (0..config.bots.len()).collect();
|
||||
if config.randomize_seats {
|
||||
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
|
||||
order.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
for (seat_seq, &bot_idx) in order.iter().enumerate() {
|
||||
let bot_config = &config.bots[bot_idx];
|
||||
let player_id = PlayerId(seat_seq as u64);
|
||||
let bot = bot_factory::create_bot(bot_config, player_id)
|
||||
.map_err(GameError::Custom)?;
|
||||
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
||||
for (seat_seq, &bot_idx) in order.iter().enumerate() {
|
||||
let bot_config = &config.bots[bot_idx];
|
||||
let player_id = PlayerId(seat_seq as u64);
|
||||
let bot = bot_factory::create_bot(bot_config, player_id)
|
||||
.map_err(GameError::Custom)?;
|
||||
let name = bot.name().to_string();
|
||||
player_names.insert(player_id, name);
|
||||
tournament.register_player(bot)?;
|
||||
}
|
||||
|
||||
let name = bot.name().to_string();
|
||||
player_names.insert(player_id, name);
|
||||
|
||||
tournament.register_player(bot)?;
|
||||
}
|
||||
|
||||
// Run the tournament to completion.
|
||||
log::info!("Running tournament {}/{}...", tournament_index + 1, num_tournaments);
|
||||
tournament.run_to_completion()?;
|
||||
|
||||
// Accumulate total hands.
|
||||
result.total_hands_played += tournament.total_hands();
|
||||
|
||||
// Process results: record eliminated players from standings.
|
||||
let standings = tournament.standings();
|
||||
|
||||
// Invariant: after run_to_completion(), standings should have N-1 entries
|
||||
// and winner() returns the sole remaining player. Log a warning if violated.
|
||||
let expected_standings = player_names.len().saturating_sub(1);
|
||||
if standings.len() != expected_standings {
|
||||
log::warn!(
|
||||
"Tournament {} ended with {} standings (expected {})",
|
||||
log::info!(
|
||||
"Running tournament {}/{}...",
|
||||
tournament_index + 1,
|
||||
standings.len(),
|
||||
expected_standings
|
||||
num_tournaments
|
||||
);
|
||||
}
|
||||
tournament.run_to_completion()?;
|
||||
|
||||
// Track which player IDs have been recorded to prevent double-counting.
|
||||
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
||||
let total_hands = tournament.total_hands();
|
||||
let standings = tournament.standings();
|
||||
|
||||
// Standings contain eliminated players with their finish position.
|
||||
// The placement value is the correct finish position (2 = runner-up,
|
||||
// N = first eliminated). Players eliminated in the same hand share
|
||||
// the same position.
|
||||
for &(player_id, finish_position) in standings {
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||
let expected_standings = player_names.len().saturating_sub(1);
|
||||
if standings.len() != expected_standings {
|
||||
log::warn!(
|
||||
"Tournament {} ended with {} standings (expected {})",
|
||||
tournament_index + 1,
|
||||
standings.len(),
|
||||
expected_standings
|
||||
);
|
||||
}
|
||||
|
||||
let prize = tournament
|
||||
.prize_structure()
|
||||
.prize_for_placement(finish_position)
|
||||
.unwrap_or(0);
|
||||
let mut player_results = Vec::new();
|
||||
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
||||
|
||||
log::debug!(
|
||||
"Tournament {}: {} finish={} prize={}",
|
||||
tournament_index + 1, name, finish_position, prize
|
||||
);
|
||||
|
||||
result.record_player_finish(&name, finish_position, prize);
|
||||
recorded.insert(player_id);
|
||||
}
|
||||
|
||||
// Record the winner (last active player) if not already in standings.
|
||||
// The winner is the sole remaining player who was never eliminated.
|
||||
if let Some(winner_id) = tournament.winner() {
|
||||
if !recorded.contains(&winner_id) {
|
||||
for &(player_id, finish_position) in standings {
|
||||
let name = player_names
|
||||
.get(&winner_id)
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", winner_id.0));
|
||||
|
||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||
let prize = tournament
|
||||
.prize_structure()
|
||||
.prize_for_placement(1)
|
||||
.prize_for_placement(finish_position)
|
||||
.unwrap_or(0);
|
||||
|
||||
result.record_player_finish(&name, 1, prize);
|
||||
log::debug!(
|
||||
"Tournament {}: {} finish={} prize={}",
|
||||
tournament_index + 1,
|
||||
name,
|
||||
finish_position,
|
||||
prize
|
||||
);
|
||||
player_results.push((name, finish_position, prize));
|
||||
recorded.insert(player_id);
|
||||
}
|
||||
|
||||
if let Some(winner_id) = tournament.winner() {
|
||||
if !recorded.contains(&winner_id) {
|
||||
let name = player_names
|
||||
.get(&winner_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", winner_id.0));
|
||||
let prize = tournament
|
||||
.prize_structure()
|
||||
.prize_for_placement(1)
|
||||
.unwrap_or(0);
|
||||
player_results.push((name, 1, prize));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TourneyResult {
|
||||
total_hands,
|
||||
player_results,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, GameError>>()?;
|
||||
|
||||
for tr in tournament_results {
|
||||
result.total_hands_played += tr.total_hands;
|
||||
for (name, placement, prize) in tr.player_results {
|
||||
result.record_player_finish(&name, placement, prize);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -887,7 +887,7 @@ mod tests {
|
|||
fn record_tournament_result_processes_completed_tournament() {
|
||||
let config = TableConfig {
|
||||
num_seats: 9,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl Default for TableConfig {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
num_seats: 9,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -330,11 +330,6 @@ impl Table {
|
|||
return Err(GameError::HandAlreadyInProgress);
|
||||
}
|
||||
|
||||
// 2. Count active players (non-SittingOut).
|
||||
let active_count = self.active_player_count();
|
||||
if active_count < MIN_PLAYERS as usize {
|
||||
return Err(GameError::NotEnoughPlayers(active_count));
|
||||
}
|
||||
|
||||
// 2.5. Bankroll management.
|
||||
if self.reset_bankroll_each_hand {
|
||||
|
|
@ -416,7 +411,36 @@ impl Table {
|
|||
.ok_or(GameError::NoHandInProgress)?;
|
||||
|
||||
// Run the hand to completion.
|
||||
let result = runner.run_to_completion()?;
|
||||
let result = match runner.run_to_completion() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
// CRITICAL: Restore seats even on error. Without this, the
|
||||
// seats are trapped inside the dropped runner, leaving
|
||||
// self.seats empty. The next start_hand() would see 0 active
|
||||
// players, return NotEnoughPlayers, and silently terminate
|
||||
// the entire simulation.
|
||||
log::warn!("Hand execution failed ({e:?}) — restoring seats to table");
|
||||
let seats = runner.into_seats();
|
||||
for seat in seats {
|
||||
let idx = seat.index as usize;
|
||||
if idx < self.seats.len() {
|
||||
let mut seat = seat;
|
||||
// Refund wagered chips before resetting — the pot_manager
|
||||
// is dropped with the runner, so wagered chips would
|
||||
// vanish without this refund.
|
||||
seat.stack += seat.wagered_this_hand;
|
||||
if seat.stack == 0 {
|
||||
seat.state = PlayerState::SittingOut;
|
||||
} else {
|
||||
seat.reset_for_new_hand();
|
||||
}
|
||||
self.seats[idx] = Some(seat);
|
||||
}
|
||||
}
|
||||
self.current_hand = None;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract seats from the runner and restore them to the table.
|
||||
// HandRunner::into_seats() consumes the runner and returns the seats
|
||||
|
|
@ -563,6 +587,7 @@ impl Table {
|
|||
num_raises: 0,
|
||||
hand_number: self.hand_number,
|
||||
active_players: self.active_player_count(),
|
||||
table_id: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -833,7 +858,7 @@ mod tests {
|
|||
fn default_config() -> TableConfig {
|
||||
TableConfig {
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -1288,7 +1313,7 @@ mod tests {
|
|||
fn test_table_full() {
|
||||
let config = TableConfig {
|
||||
num_seats: 2,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
|
|||
|
|
@ -1006,7 +1006,7 @@ mod tests {
|
|||
fn default_table_config() -> TableConfig {
|
||||
TableConfig {
|
||||
num_seats: 9,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -1188,7 +1188,7 @@ mod tests {
|
|||
fn test_sng_registration_fills_table() {
|
||||
let config = TableConfig {
|
||||
num_seats: 4,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -1217,7 +1217,7 @@ mod tests {
|
|||
fn test_sng_run_single_hand() {
|
||||
let config = TableConfig {
|
||||
num_seats: 4,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -1283,7 +1283,7 @@ mod tests {
|
|||
fn test_mtt_table_balancing() {
|
||||
let config = TableConfig {
|
||||
num_seats: 9,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
@ -1328,7 +1328,7 @@ mod tests {
|
|||
fn test_blind_advancement() {
|
||||
let config = TableConfig {
|
||||
num_seats: 4,
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
||||
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue