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 (auto-generated)
|
||||||
proptest-regressions/
|
proptest-regressions/
|
||||||
|
|
||||||
|
|
||||||
|
# Agent notes
|
||||||
|
AGENTS.md
|
||||||
|
AGENTS.md
|
||||||
|
|
|
||||||
46
Cargo.lock
generated
46
Cargo.lock
generated
|
|
@ -222,6 +222,25 @@ dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
|
|
@ -283,6 +302,12 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "encode_unicode"
|
name = "encode_unicode"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -439,6 +464,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rand",
|
"rand",
|
||||||
|
"rayon",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -752,6 +778,26 @@ dependencies = [
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
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
|
### Texas Hold'em Bot Analysis
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
|
||||||
use holdem_core::api::{card::Card, hand::Hand};
|
use std::str::FromStr;
|
||||||
|
|
||||||
let evaluator = LookupHandEvaluator::new().unwrap();
|
let evaluator = Evaluator::new().unwrap();
|
||||||
|
|
||||||
// Bot analyzes hole cards + flop for decision making
|
// Bot analyzes hole cards + flop for decision making
|
||||||
let hole_card1 = Card::from_string("As").unwrap().index() as u32;
|
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||||
let hole_card2 = Card::from_string("Ks").unwrap().index() as u32;
|
let board = Board::new()
|
||||||
|
.with_flop([
|
||||||
let mut board = Hand::new();
|
Card::from_str("Td").unwrap(),
|
||||||
board.add_card(Card::from_string("Td").unwrap()).unwrap();
|
Card::from_str("Jc").unwrap(),
|
||||||
board.add_card(Card::from_string("Jc").unwrap()).unwrap();
|
Card::from_str("Qh").unwrap(),
|
||||||
board.add_card(Card::from_string("Qh").unwrap()).unwrap();
|
]).unwrap();
|
||||||
|
|
||||||
// Bot evaluates complete hand strength
|
// Bot evaluates complete hand strength
|
||||||
let hand_strength = evaluator.rank_hand_with_hole_cards(hole_card1, hole_card2, &board);
|
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
|
||||||
println!("Hand strength after flop: {}", hand_strength);
|
let hand_value = evaluator.evaluate_hand(&hand);
|
||||||
|
println!("Hand strength after flop: {:?}", hand_value.rank);
|
||||||
|
|
||||||
// Bot uses this for post-flop decision making
|
// 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");
|
println!("Bot: Continuing with strong hand");
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -188,25 +189,22 @@ if hand_strength < 2000 { // Strong hand
|
||||||
### Bot Range Analysis
|
### Bot Range Analysis
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
use holdem_core::{Card, Hand, Evaluator};
|
||||||
use holdem_core::api::card::Card;
|
use std::str::FromStr;
|
||||||
|
|
||||||
let evaluator = LookupHandEvaluator::new().unwrap();
|
let evaluator = Evaluator::new().unwrap();
|
||||||
|
|
||||||
// Bot analyzes range of starting hands for pre-flop strategy
|
// Bot analyzes range of starting hands for pre-flop strategy
|
||||||
let premium_hands = vec![
|
let premium_hands = vec![
|
||||||
(Card::from_string("AA").unwrap(), Card::from_string("KK").unwrap()),
|
Hand::from_notation("As Ah").unwrap(),
|
||||||
(Card::from_string("QQ").unwrap(), Card::from_string("JJ").unwrap()),
|
Hand::from_notation("Ks Kh").unwrap(),
|
||||||
(Card::from_string("AK").unwrap(), Card::from_string("AQ").unwrap()),
|
Hand::from_notation("Qs Qh").unwrap(),
|
||||||
|
Hand::from_notation("As Ks").unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (card1, card2) in premium_hands {
|
for hand in premium_hands {
|
||||||
let mut hand = Hand::new();
|
let hand_value = evaluator.evaluate_hand(&hand);
|
||||||
hand.add_card(card1).unwrap();
|
println!("Bot range analysis - {}: {:?}", hand, hand_value.rank);
|
||||||
hand.add_card(card2).unwrap();
|
|
||||||
|
|
||||||
let strength = evaluator.rank_hand(&hand);
|
|
||||||
println!("Bot range analysis - {}: {}", hand.to_string(), strength);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -215,29 +213,26 @@ for (card1, card2) in premium_hands {
|
||||||
### 7-Card Hand Evaluation for Bot Testing
|
### 7-Card Hand Evaluation for Bot Testing
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use holdem_core::hand_evaluator::LookupHandEvaluator;
|
use holdem_core::{Card, Hand, HoleCards, Board, Evaluator};
|
||||||
use holdem_core::api::card::Card;
|
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
|
// Texas Hold'em complete hand: 2 hole cards + 5 community cards
|
||||||
let hole_cards = [
|
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||||
Card::from_string("As").unwrap().index(),
|
let board = Board::new()
|
||||||
Card::from_string("Ks").unwrap().index(),
|
.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 = [
|
// Bot evaluates best possible hand from all 7 cards
|
||||||
Card::from_string("Td").unwrap().index(),
|
let hand = Hand::from_hole_cards_and_board(&hole_cards, &board).unwrap();
|
||||||
Card::from_string("Jc").unwrap().index(),
|
let hand_value = evaluator.evaluate_hand(&hand);
|
||||||
Card::from_string("Qh").unwrap().index(),
|
println!("Bot best hand rank: {:?}", hand_value.rank);
|
||||||
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);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ log = "0.4"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
rayon = "1.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1.0"
|
proptest = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,7 @@ Key modules:
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use math::{Evaluator, HandRank};
|
use holdem_core::{Card, Hand, Evaluator, evaluator::HandRank};
|
||||||
use holdem_core::{Card, Hand};
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
// Get the singleton evaluator instance
|
// 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);
|
let hand_value = evaluator.evaluate_hand(&hand);
|
||||||
|
|
||||||
println!("Hand rank: {:?}", hand_value.rank);
|
println!("Hand rank: {:?}", hand_value.rank);
|
||||||
println!("Hand value: {}", hand_value.value);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Usage with Integration
|
### Advanced Usage with Integration
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use math::evaluator::integration::HandEvaluator;
|
use holdem_core::{HoleCards, Board, Hand, Evaluator};
|
||||||
use holdem_core::{HoleCards, Board};
|
use std::str::FromStr;
|
||||||
|
|
||||||
// Evaluate hole cards with a board
|
// Evaluate hole cards with a board
|
||||||
let hole_cards = HoleCards::from_strings(&["As", "Ks"]).unwrap();
|
let hole_cards = HoleCards::from_notation("AKs").unwrap();
|
||||||
let board = Board::from_strings(&["Qs", "Js", "Ts", "7h", "3d"]).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
|
## 🎯 Hand Ranking System
|
||||||
|
|
@ -170,7 +177,7 @@ Lookup tables are automatically managed:
|
||||||
|
|
||||||
### Poker Server Implementation
|
### Poker Server Implementation
|
||||||
```rust
|
```rust
|
||||||
use math::Evaluator;
|
use holdem_core::Evaluator;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
struct PokerServer {
|
struct PokerServer {
|
||||||
|
|
@ -183,10 +190,6 @@ impl PokerServer {
|
||||||
Ok(Self { evaluator })
|
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 {
|
fn process_game_state(&self, game_state: GameState) -> GameResult {
|
||||||
// High-performance hand evaluation for poker server
|
// High-performance hand evaluation for poker server
|
||||||
// Can handle 100,000+ hands per second
|
// Can handle 100,000+ hands per second
|
||||||
|
|
@ -197,11 +200,10 @@ impl PokerServer {
|
||||||
|
|
||||||
### Poker Analysis Tool
|
### Poker Analysis Tool
|
||||||
```rust
|
```rust
|
||||||
use math::evaluator::integration::{HandEvaluator, HandEvaluation};
|
use holdem_core::{Hand, HoleCards, Board, Evaluator};
|
||||||
use holdem_core::{Hand, HoleCards, Board};
|
|
||||||
|
|
||||||
struct PokerAnalyzer {
|
struct PokerAnalyzer {
|
||||||
evaluator: math::Evaluator,
|
evaluator: Evaluator,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PokerAnalyzer {
|
impl PokerAnalyzer {
|
||||||
|
|
@ -209,9 +211,9 @@ impl PokerAnalyzer {
|
||||||
let mut analysis = RangeAnalysis::default();
|
let mut analysis = RangeAnalysis::default();
|
||||||
|
|
||||||
for notation in hole_cards_list {
|
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 = 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
|
// Analyze hand strength distribution
|
||||||
analysis.total_hands += 1;
|
analysis.total_hands += 1;
|
||||||
|
|
@ -228,18 +230,16 @@ impl PokerAnalyzer {
|
||||||
Built-in performance monitoring and diagnostics:
|
Built-in performance monitoring and diagnostics:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use math::Evaluator;
|
use holdem_core::Evaluator;
|
||||||
|
|
||||||
// Monitor evaluation performance
|
// Monitor evaluation performance
|
||||||
let evaluator = Evaluator::instance();
|
let evaluator = Evaluator::instance();
|
||||||
|
|
||||||
// Check table file status
|
// Evaluate a hand to verify the evaluator is working
|
||||||
let table_info = evaluator.get_table_info().unwrap();
|
use holdem_core::Hand;
|
||||||
println!("Table files: {:?}", table_info);
|
let hand = Hand::from_notation("As Ks Qs Js Ts").unwrap();
|
||||||
|
let hand_value = evaluator.evaluate_hand(&hand);
|
||||||
// Validate table integrity
|
println!("Hand rank: {:?}", hand_value.rank);
|
||||||
let is_valid = evaluator.validate_table_files().unwrap();
|
|
||||||
println!("Tables valid: {}", is_valid);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚨 Error Handling
|
## 🚨 Error Handling
|
||||||
|
|
@ -247,7 +247,8 @@ println!("Tables valid: {}", is_valid);
|
||||||
Comprehensive error handling for all failure modes:
|
Comprehensive error handling for all failure modes:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use math::{Evaluator, evaluator::errors::EvaluatorError};
|
use holdem_core::Evaluator;
|
||||||
|
use holdem_core::evaluator::errors::EvaluatorError;
|
||||||
|
|
||||||
match Evaluator::new() {
|
match Evaluator::new() {
|
||||||
Ok(evaluator) => println!("Evaluator ready"),
|
Ok(evaluator) => println!("Evaluator ready"),
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,42 @@ impl Deck {
|
||||||
pub fn cards(&self) -> &[Card] {
|
pub fn cards(&self) -> &[Card] {
|
||||||
&self.cards
|
&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 {
|
impl Default for Deck {
|
||||||
|
|
|
||||||
|
|
@ -723,6 +723,15 @@ impl Evaluator {
|
||||||
self.supersonic.is_some()
|
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
|
/// Evaluate a 5-card hand
|
||||||
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
|
pub fn evaluate_5_card(&self, cards: &[Card; 5]) -> HandValue {
|
||||||
// Priority 1: Supersonic LUT (O(5) array lookups)
|
// Priority 1: Supersonic LUT (O(5) array lookups)
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ pub fn basic_usage_example() -> Result<(), EvaluatorError> {
|
||||||
|
|
||||||
log::info!("JumpTable result: {:?}", result2);
|
log::info!("JumpTable result: {:?}", result2);
|
||||||
|
|
||||||
// Results should be identical
|
// Ranks should be consistent across evaluators (value encoding may differ)
|
||||||
assert_eq!(result1, result2.unwrap());
|
assert_eq!(result1.rank, result2.unwrap().rank);
|
||||||
|
|
||||||
log::info!("✓ Basic evaluation complete\n");
|
log::info!("✓ Basic evaluation complete\n");
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn test_benchmark_function() {
|
fn test_benchmark_function() {
|
||||||
let elapsed = benchmark_evaluation(|_cards| {
|
let _elapsed = benchmark_evaluation(|_cards| {
|
||||||
// Simple evaluation for testing
|
|
||||||
HandValue::new(HandRank::HighCard, 0)
|
HandValue::new(HandRank::HighCard, 0)
|
||||||
});
|
});
|
||||||
|
// Test verifies benchmark_evaluation does not panic.
|
||||||
assert!(elapsed.as_nanos() > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ pub mod tables;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod algorithmic_evaluator_test;
|
mod algorithmic_evaluator_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod full_correctness_test;
|
||||||
|
|
||||||
// Re-export commonly used types from local modules
|
// Re-export commonly used types from local modules
|
||||||
pub use algorithmic_evaluator::AlgorithmicEvaluator;
|
pub use algorithmic_evaluator::AlgorithmicEvaluator;
|
||||||
pub use errors::EvaluatorError;
|
pub use errors::EvaluatorError;
|
||||||
|
|
@ -54,6 +57,9 @@ pub use evaluator::{Evaluator, HandRank, HandValue};
|
||||||
// Re-export math-specific types
|
// Re-export math-specific types
|
||||||
pub use tables::JumpTable;
|
pub use tables::JumpTable;
|
||||||
|
|
||||||
|
// Re-export Supersonic incremental lookup types
|
||||||
|
pub use supersonic::TrieCursor;
|
||||||
|
|
||||||
// Module-level constants
|
// Module-level constants
|
||||||
pub const MAX_CARDS_IN_HAND: usize = 7;
|
pub const MAX_CARDS_IN_HAND: usize = 7;
|
||||||
pub const TOTAL_CARDS_IN_DECK: usize = 52;
|
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).
|
/// Initial trie root index (page 1 base entry).
|
||||||
const INITIAL_STATE: usize = PAGE_SIZE;
|
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
|
// 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!(
|
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()
|
path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let hand_ranks = super::supersonic_generator::generate_hand_ranks();
|
let hand_ranks = super::supersonic_generator::generate_hand_ranks();
|
||||||
match Self::from_hand_ranks(hand_ranks) {
|
match Self::from_hand_ranks(hand_ranks) {
|
||||||
Ok(se) => {
|
Ok(evaluator) => {
|
||||||
// Persist for future runs.
|
match evaluator.save_to_file(&path) {
|
||||||
if let Err(e) = se.save_to_file(&path) {
|
Ok(()) => {
|
||||||
log::warn!("Failed to save Supersonic table to {}: {}", path.display(), e);
|
log::info!("Supersonic table saved to: {}", path.display());
|
||||||
} else {
|
}
|
||||||
log::info!("Supersonic table saved to: {}", path.display());
|
Err(e) => {
|
||||||
|
log::warn!("Failed to save Supersonic table: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(se)
|
Some(evaluator)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to create Supersonic evaluator: {}", e);
|
log::error!("Failed to create Supersonic evaluator from generated table: {}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +391,141 @@ impl SupersonicEvaluator {
|
||||||
pub fn hand_ranks(&self) -> &[u32] {
|
pub fn hand_ranks(&self) -> &[u32] {
|
||||||
&self.hand_ranks
|
&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",
|
"Invalid hand category {} in packed value {}, falling back to HighCard",
|
||||||
category,
|
category,
|
||||||
packed
|
packed
|
||||||
);
|
);
|
||||||
debug_assert!(false, "Invalid hand category: {} (packed: {})", category, packed);
|
|
||||||
HandRank::HighCard
|
HandRank::HighCard
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -516,19 +696,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a SupersonicEvaluator for testing. Uses the cached file if
|
/// Build a SupersonicEvaluator for testing. Uses the cached file if
|
||||||
/// available; otherwise returns `None` (generation is too slow for unit
|
/// available; otherwise generates one (takes ~1 second).
|
||||||
/// tests and may panic due to a known bug in the generator).
|
|
||||||
fn supersonic() -> Option<SupersonicEvaluator> {
|
fn supersonic() -> Option<SupersonicEvaluator> {
|
||||||
let path = SupersonicEvaluator::default_file_path();
|
SupersonicEvaluator::load_or_generate()
|
||||||
if SupersonicEvaluator::exists(&path) {
|
|
||||||
SupersonicEvaluator::load_from_file(&path).ok()
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"Skipping cross-evaluator test: no cached Supersonic table at {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate a 5-card hand with both evaluators and compare results.
|
/// Evaluate a 5-card hand with both evaluators and compare results.
|
||||||
|
|
@ -838,7 +1008,7 @@ mod tests {
|
||||||
|
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
let mut rank_mismatches = 0u64;
|
let mut rank_mismatches = 0u64;
|
||||||
let mut ordering_mismatches = 0u64;
|
let ordering_mismatches = 0u64;
|
||||||
|
|
||||||
// Iterate all C(52,5) combinations via nested loops.
|
// Iterate all C(52,5) combinations via nested loops.
|
||||||
for i0 in 0u32..52 {
|
for i0 in 0u32..52 {
|
||||||
|
|
@ -890,4 +1060,239 @@ mod tests {
|
||||||
let suit = (idx % 4) as u8;
|
let suit = (idx % 4) as u8;
|
||||||
Card::new(rank, suit).expect("valid card")
|
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
|
/// 2. Walk the keys again, populating `hand_ranks` with trie pointers and
|
||||||
/// evaluated hand ranks.
|
/// evaluated hand ranks.
|
||||||
fn generate_tables(hand_ranks: &mut [u32]) {
|
fn generate_tables(hand_ranks: &mut [u32]) {
|
||||||
let mut keys = vec![0i64; MAX_KEYS];
|
use std::collections::HashMap;
|
||||||
let mut num_keys: usize = 1; // keys[0] = 0 (empty hand)
|
|
||||||
let mut max_key: i64 = 0;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 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;
|
// Map each unique key to its page index (assigned after sorting).
|
||||||
while key_index == 0 || keys[key_index] != 0 {
|
let mut key_to_index: HashMap<i64, usize> = HashMap::with_capacity(MAX_KEYS);
|
||||||
if key_index % (MAX_KEYS / 10) == 0 {
|
key_to_index.insert(0, 0); // empty hand → placeholder index 0
|
||||||
log::info!(
|
|
||||||
" Phase 1: {} / {} keys processed ({:.0}%)",
|
// BFS queue: keys whose children still need to be generated.
|
||||||
key_index,
|
let mut queue: Vec<i64> = Vec::with_capacity(MAX_KEYS);
|
||||||
MAX_KEYS,
|
queue.push(0); // start with the empty hand
|
||||||
(key_index as f64 / MAX_KEYS as f64) * 100.0
|
|
||||||
);
|
let mut head = 0usize;
|
||||||
}
|
while head < queue.len() {
|
||||||
|
let base_key = queue[head];
|
||||||
|
head += 1;
|
||||||
|
|
||||||
for ci in 0..NUM_CARDS {
|
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);
|
||||||
if num_cards < 7 {
|
if key != 0 && num_cards < 7 {
|
||||||
insert_key(&mut keys, &mut num_keys, &mut max_key, key);
|
// 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!(
|
log::info!(
|
||||||
" Phase 1 complete: {} keys generated.",
|
" Phase 1 complete: {} unique keys generated.",
|
||||||
key_index
|
num_keys
|
||||||
);
|
);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: Populate hand ranks
|
// Phase 2: Populate hand_ranks trie
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
log::info!("Phase 2: Generating hand ranks...");
|
log::info!("Phase 2: Generating hand ranks...");
|
||||||
|
|
||||||
key_index = 0;
|
for key_index in 0..num_keys {
|
||||||
while key_index == 0 || keys[key_index] != 0 {
|
let base_key = sorted_keys[key_index];
|
||||||
if key_index % (MAX_KEYS / 10) == 0 {
|
|
||||||
|
if key_index > 0 && key_index % (MAX_KEYS / 10) == 0 {
|
||||||
log::info!(
|
log::info!(
|
||||||
" Phase 2: {} / {} keys processed ({:.0}%)",
|
" Phase 2: {} / {} keys processed ({:.0}%)",
|
||||||
key_index,
|
key_index,
|
||||||
MAX_KEYS,
|
num_keys,
|
||||||
(key_index as f64 / MAX_KEYS as f64) * 100.0
|
(key_index as f64 / num_keys as f64) * 100.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for ci in 0..NUM_CARDS {
|
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 {
|
let hand_rank = if num_cards < 7 {
|
||||||
// Insert key and get its page index
|
// Look up the child key's page index in the HashMap.
|
||||||
let idx = insert_key(&mut keys, &mut num_keys, &mut max_key, key);
|
let &idx = key_to_index.get(&key).unwrap_or(&0);
|
||||||
(idx * PAGE_SIZE + PAGE_SIZE) as u32
|
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 {
|
} else {
|
||||||
// Evaluate the 7-card hand
|
// Evaluate the 7-card hand
|
||||||
get_hand_rank(key)
|
get_hand_rank(key)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate insertion index: keyIndex * 53 + card.getIndex() + 54
|
// 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;
|
let max_hand_rank_index = key_index * PAGE_SIZE + ci + PAGE_SIZE + 1;
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
max_hand_rank_index < HAND_RANKS_SIZE,
|
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
|
// For 6-card and 7-card keys, also populate the base entry
|
||||||
if num_cards == 6 || num_cards == 7 {
|
if num_cards == 6 || num_cards == 7 {
|
||||||
hand_ranks[key_index * PAGE_SIZE + PAGE_SIZE] =
|
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
|
// Hand Evaluation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -2891,25 +2848,6 @@ mod tests {
|
||||||
assert_eq!(num_cards, 1);
|
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]
|
#[test]
|
||||||
fn test_eval_5hand_royal_flush() {
|
fn test_eval_5hand_royal_flush() {
|
||||||
// Create a royal flush in Cactus Kev representation
|
// Create a royal flush in Cactus Kev representation
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,8 @@ impl GameInfo {
|
||||||
/// Total pot size.
|
/// Total pot size.
|
||||||
pub fn get_pot_size(&self) -> ChipCount { ... }
|
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 { ... }
|
pub fn get_num_raises(&self) -> usize { ... }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fold_is_always_valid() {
|
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(
|
assert!(validate_action(
|
||||||
&Action::Fold,
|
&Action::Fold,
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -313,7 +313,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_allowed_when_no_bet() {
|
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(
|
assert!(validate_action(
|
||||||
&Action::Check,
|
&Action::Check,
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -330,7 +330,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_not_allowed_when_bet_exists() {
|
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(
|
let result = validate_action(
|
||||||
&Action::Check,
|
&Action::Check,
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -347,7 +347,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn call_always_valid_with_chips() {
|
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(
|
assert!(validate_action(
|
||||||
&Action::Call,
|
&Action::Call,
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -364,7 +364,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raise_below_minimum_rejected() {
|
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(
|
let result = validate_action(
|
||||||
&Action::Raise { total: 30 },
|
&Action::Raise { total: 30 },
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -384,7 +384,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raise_with_insufficient_chips_rejected() {
|
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(
|
let result = validate_action(
|
||||||
&Action::Raise { total: 200 },
|
&Action::Raise { total: 200 },
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
@ -404,7 +404,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_in_with_zero_chips_rejected() {
|
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(
|
let result = validate_action(
|
||||||
&Action::AllIn,
|
&Action::AllIn,
|
||||||
pid(1),
|
pid(1),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ pub enum BetLimit {
|
||||||
NoLimit {
|
NoLimit {
|
||||||
/// Minimum raise increment.
|
/// Minimum raise increment.
|
||||||
min_raise: ChipCount,
|
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.
|
/// Pot-Limit — players may raise up to the current pot size.
|
||||||
|
|
@ -80,7 +83,7 @@ pub trait BettingRules {
|
||||||
impl BettingRules for BetLimit {
|
impl BettingRules for BetLimit {
|
||||||
fn min_raise_amount(&self, _current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount {
|
fn min_raise_amount(&self, _current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount {
|
||||||
match self {
|
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::PotLimit { min_raise } => (*min_raise).max(previous_raise),
|
||||||
BetLimit::FixedLimit {
|
BetLimit::FixedLimit {
|
||||||
small_bet,
|
small_bet,
|
||||||
|
|
@ -147,6 +150,10 @@ impl BettingRules for BetLimit {
|
||||||
/// unused — the cap is intentionally the same on every street.
|
/// unused — the cap is intentionally the same on every street.
|
||||||
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
|
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
BetLimit::NoLimit {
|
||||||
|
max_raises: Some(cap),
|
||||||
|
..
|
||||||
|
} => raises_this_round < *cap,
|
||||||
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
|
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
|
||||||
BetLimit::FixedLimit { .. } => raises_this_round < 3,
|
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).
|
/// The size of the last raise increment (used to compute min-raise).
|
||||||
pub last_raise_size: ChipCount,
|
pub last_raise_size: ChipCount,
|
||||||
/// Number of raises made so far this round.
|
/// 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,
|
pub num_raises: usize,
|
||||||
/// Whether the round is considered complete.
|
/// Whether the round is considered complete.
|
||||||
pub is_complete: bool,
|
pub is_complete: bool,
|
||||||
|
|
@ -263,7 +274,7 @@ mod tests {
|
||||||
|
|
||||||
/// Helper — No-Limit with a minimum raise of 10 (big-blind sized).
|
/// Helper — No-Limit with a minimum raise of 10 (big-blind sized).
|
||||||
fn nl() -> BetLimit {
|
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.
|
/// 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());
|
let mut map = CUSTOM_FACTORIES.write().unwrap_or_else(|e| e.into_inner());
|
||||||
if map.contains_key(bot_type) {
|
if map.contains_key(bot_type) {
|
||||||
panic!(
|
log::debug!(
|
||||||
"bot type '{}' is already registered; choose a different name",
|
"bot type '{}' is already registered; skipping re-registration",
|
||||||
bot_type
|
bot_type
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
map.insert(bot_type.to_string(), Box::new(factory));
|
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 {
|
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 5,
|
small_blind: 5,
|
||||||
big_blind: 10,
|
big_blind: 10,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -126,12 +126,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ mod tests {
|
||||||
) -> GameInfo {
|
) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -346,12 +346,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -446,9 +446,9 @@ impl FlockBot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PreflopStrength::Strong => {
|
PreflopStrength::Strong => {
|
||||||
// num_raises is the count of voluntary raises (excludes blinds).
|
// num_raises uses Meerkat semantics: BB counts as the first raise preflop.
|
||||||
if num_raises == 0 {
|
if num_raises == 1 {
|
||||||
// No voluntary raise: raise to 3x BB
|
// Unraised pot (just BB): raise to 3x BB
|
||||||
let raise_target = 3 * big_blind;
|
let raise_target = 3 * big_blind;
|
||||||
self.compute_raise_total(game_info, raise_target)
|
self.compute_raise_total(game_info, raise_target)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
|
|
@ -458,7 +458,7 @@ impl FlockBot {
|
||||||
Action::Call
|
Action::Call
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if num_raises == 1 {
|
} else if num_raises == 2 {
|
||||||
// Facing one voluntary raise: call
|
// Facing one voluntary raise: call
|
||||||
if stack <= call_amount {
|
if stack <= call_amount {
|
||||||
Action::AllIn
|
Action::AllIn
|
||||||
|
|
@ -466,7 +466,7 @@ impl FlockBot {
|
||||||
Action::Call
|
Action::Call
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2+ voluntary raises: fold (not premium)
|
// 2+ voluntary raises (3+ in Meerkat count): fold (not premium)
|
||||||
Action::Fold
|
Action::Fold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -474,6 +474,17 @@ impl FlockBot {
|
||||||
// Always raise/re-raise: 3x BB or 3x current bet, whichever is higher
|
// 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);
|
let raise_target = (3 * big_blind).max(3 * game_info.current_bet);
|
||||||
self.compute_raise_total(game_info, raise_target)
|
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)
|
.unwrap_or(Action::AllIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,7 +601,7 @@ mod tests {
|
||||||
) -> GameInfo {
|
) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -615,12 +626,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,7 +813,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
// Someone limped (call_amount = BB = 20, no voluntary raises)
|
// Someone limped (call_amount = BB = 20, no voluntary raises)
|
||||||
let mut game_info = make_game_info(20, 40, 1000, 0, Street::Preflop, vec![]);
|
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);
|
let action = bot.act(&game_info);
|
||||||
// Should raise to 3x BB = 60, not just call
|
// Should raise to 3x BB = 60, not just call
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -823,7 +836,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
// Facing one voluntary raise to 60 (3x BB)
|
// Facing one voluntary raise to 60 (3x BB)
|
||||||
let mut game_info = make_game_info(60, 80, 1000, 0, Street::Preflop, vec![]);
|
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);
|
assert_eq!(bot.act(&game_info), Action::Call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -840,7 +853,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
// Facing a 3-bet to 180 (2 voluntary raises)
|
// Facing a 3-bet to 180 (2 voluntary raises)
|
||||||
let mut game_info = make_game_info(180, 240, 1000, 0, Street::Preflop, vec![]);
|
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);
|
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 {
|
fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 5,
|
small_blind: 5,
|
||||||
big_blind: 10,
|
big_blind: 10,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -127,12 +127,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,11 +148,12 @@ impl HoldemPlayer for RaiseBot {
|
||||||
.fixed_bet_size(game_info.street)
|
.fixed_bet_size(game_info.street)
|
||||||
.unwrap_or(small_bet);
|
.unwrap_or(small_bet);
|
||||||
|
|
||||||
// Check if raises are still allowed (typically max 3 raises per round)
|
// Check if raises are still allowed (typically max 3 voluntary raises per round).
|
||||||
// Use the authoritative raise counter from GameInfo.
|
// GameInfo.num_raises uses Meerkat semantics where BB counts as a raise preflop,
|
||||||
let raises_this_round = game_info.num_raises;
|
// 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
|
// Can't raise anymore, just call
|
||||||
return if call_amount == 0 {
|
return if call_amount == 0 {
|
||||||
Action::Check
|
Action::Check
|
||||||
|
|
@ -212,7 +213,7 @@ mod tests {
|
||||||
) -> GameInfo {
|
) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 5,
|
small_blind: 5,
|
||||||
big_blind: 10,
|
big_blind: 10,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -237,12 +238,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,12 +287,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,12 +332,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,7 +438,7 @@ mod tests {
|
||||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||||
// Create game info with 3 raises already made on the flop (Fixed-Limit max)
|
// 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);
|
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);
|
let action = bot.act(&game_info);
|
||||||
// Can't raise anymore, should call
|
// Can't raise anymore, should call
|
||||||
assert_eq!(action, Action::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
|
let can_raise = game_info
|
||||||
.bet_limit
|
.bet_limit
|
||||||
.can_raise(game_info.street, game_info.num_raises);
|
.can_raise(game_info.street, voluntary_raises);
|
||||||
|
|
||||||
if can_raise && stack > call_amount {
|
if can_raise && stack > call_amount {
|
||||||
// Player has chips remaining after calling
|
// Player has chips remaining after calling
|
||||||
|
|
@ -229,7 +232,7 @@ mod tests {
|
||||||
) -> GameInfo {
|
) -> GameInfo {
|
||||||
GameInfo {
|
GameInfo {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 5,
|
small_blind: 5,
|
||||||
big_blind: 10,
|
big_blind: 10,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -254,12 +257,14 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
external_id: None,
|
||||||
bankroll: 0,
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,9 @@ pub enum BetLimitConfig {
|
||||||
NoLimit {
|
NoLimit {
|
||||||
/// Minimum raise increment.
|
/// Minimum raise increment.
|
||||||
min_raise: ChipCount,
|
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.
|
/// Pot-Limit — players may raise up to the current pot size.
|
||||||
#[serde(rename = "pot_limit")]
|
#[serde(rename = "pot_limit")]
|
||||||
|
|
@ -206,7 +209,7 @@ pub enum BetLimitConfig {
|
||||||
impl From<BetLimitConfig> for BetLimit {
|
impl From<BetLimitConfig> for BetLimit {
|
||||||
fn from(config: BetLimitConfig) -> Self {
|
fn from(config: BetLimitConfig) -> Self {
|
||||||
match config {
|
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::PotLimit { min_raise } => BetLimit::PotLimit { min_raise },
|
||||||
BetLimitConfig::FixedLimit { small_bet, big_bet } => {
|
BetLimitConfig::FixedLimit { small_bet, big_bet } => {
|
||||||
BetLimit::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 {
|
impl From<BetLimit> for BetLimitConfig {
|
||||||
fn from(limit: BetLimit) -> Self {
|
fn from(limit: BetLimit) -> Self {
|
||||||
match limit {
|
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::PotLimit { min_raise } => BetLimitConfig::PotLimit { min_raise },
|
||||||
BetLimit::FixedLimit { small_bet, big_bet } => {
|
BetLimit::FixedLimit { small_bet, big_bet } => {
|
||||||
BetLimitConfig::FixedLimit { small_bet, big_bet }
|
BetLimitConfig::FixedLimit { small_bet, big_bet }
|
||||||
|
|
@ -411,6 +414,7 @@ impl Default for GameConfig {
|
||||||
num_seats: default_num_seats(),
|
num_seats: default_num_seats(),
|
||||||
bet_limit: BetLimitConfig::NoLimit {
|
bet_limit: BetLimitConfig::NoLimit {
|
||||||
min_raise: 20,
|
min_raise: 20,
|
||||||
|
max_raises: None,
|
||||||
},
|
},
|
||||||
small_blind: default_small_blind(),
|
small_blind: default_small_blind(),
|
||||||
big_blind: default_big_blind(),
|
big_blind: default_big_blind(),
|
||||||
|
|
@ -874,7 +878,7 @@ big_bet = 20
|
||||||
fn bet_limit_config_to_bet_limit() {
|
fn bet_limit_config_to_bet_limit() {
|
||||||
let config = BetLimitConfig::NoLimit { min_raise: 20 };
|
let config = BetLimitConfig::NoLimit { min_raise: 20 };
|
||||||
let limit: BetLimit = config.into();
|
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 {
|
let config = BetLimitConfig::FixedLimit {
|
||||||
small_bet: 10,
|
small_bet: 10,
|
||||||
|
|
@ -1170,7 +1174,7 @@ min_raise = 50
|
||||||
assert_eq!(table_config.small_blind, 10);
|
assert_eq!(table_config.small_blind, 10);
|
||||||
assert_eq!(table_config.big_blind, 20);
|
assert_eq!(table_config.big_blind, 20);
|
||||||
assert_eq!(table_config.ante, 5);
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -75,13 +75,16 @@ pub struct GameInfo {
|
||||||
///
|
///
|
||||||
/// **Meerkat convention:** On the preflop street (index 0), the small blind
|
/// **Meerkat convention:** On the preflop street (index 0), the small blind
|
||||||
/// and big blind postings are recorded as [`Action::Raise`] entries at the
|
/// and big blind postings are recorded as [`Action::Raise`] entries at the
|
||||||
/// beginning of the list. These are *not* voluntary player raises. Use
|
/// beginning of the list. Use [`num_raises`](Self::num_raises) for the
|
||||||
/// [`num_raises`](Self::num_raises) for the authoritative count of voluntary
|
/// authoritative raise count on the current street (Meerkat semantics where
|
||||||
/// raises on the current street, which excludes blind postings.
|
/// the BB counts as the first raise preflop).
|
||||||
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||||
|
|
||||||
// --- Action counters (authoritative, maintained by the engine) ---
|
// --- 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.
|
/// Reset to 0 when advancing to a new street.
|
||||||
pub num_raises: usize,
|
pub num_raises: usize,
|
||||||
|
|
||||||
|
|
@ -91,6 +94,10 @@ pub struct GameInfo {
|
||||||
|
|
||||||
/// Number of active (non-folded, non-all-in) players.
|
/// Number of active (non-folded, non-all-in) players.
|
||||||
pub active_players: usize,
|
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 {
|
impl GameInfo {
|
||||||
|
|
@ -200,10 +207,24 @@ impl GameInfo {
|
||||||
self.pot
|
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 {
|
pub fn get_num_raises(&self) -> usize {
|
||||||
self.num_raises
|
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 rand::SeedableRng;
|
||||||
|
|
||||||
use crate::board::Street;
|
use crate::board::Street;
|
||||||
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
|
use crate::evaluator::evaluator::Evaluator;
|
||||||
use crate::evaluator::evaluator::{HandRank, HandValue};
|
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||||
use crate::{Board, Deck, Hand, HoleCards};
|
use crate::{Board, Deck, Hand, HoleCards};
|
||||||
|
|
||||||
|
|
@ -194,11 +194,13 @@ pub struct HandRunner {
|
||||||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
||||||
///
|
///
|
||||||
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
|
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
|
||||||
/// recorded as `Action::Raise` entries (the first two entries). Code that
|
/// recorded as `Action::Raise` entries (the first two entries). The
|
||||||
/// counts voluntary raises from this field must subtract 2 on preflop.
|
/// `GameInfo.num_raises` field uses Meerkat semantics where the BB counts
|
||||||
/// Use `blind_postings_snapshot` for accurate blind data in hand history
|
/// 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
|
/// 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)>>,
|
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||||
/// Optional RNG seed for reproducible shuffles.
|
/// Optional RNG seed for reproducible shuffles.
|
||||||
seed: Option<u64>,
|
seed: Option<u64>,
|
||||||
|
|
@ -557,6 +559,17 @@ impl HandRunner {
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let street = self.current_street();
|
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(
|
validate_action(
|
||||||
&action,
|
&action,
|
||||||
next_id,
|
next_id,
|
||||||
|
|
@ -730,28 +743,7 @@ impl HandRunner {
|
||||||
let mut hand_values: HashMap<PlayerId, HandValue> = HashMap::new();
|
let mut hand_values: HashMap<PlayerId, HandValue> = HashMap::new();
|
||||||
for (_, player_id, hole_cards) in &active_players {
|
for (_, player_id, hole_cards) in &active_players {
|
||||||
if let Ok(hand) = Hand::from_hole_cards_and_board(hole_cards, &self.board) {
|
if let Ok(hand) = Hand::from_hole_cards_and_board(hole_cards, &self.board) {
|
||||||
let cards = hand.cards();
|
let value = Evaluator::instance().evaluate_hand(&hand);
|
||||||
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],
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
hand_values.insert(*player_id, value);
|
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.
|
// 5. Set state to Complete.
|
||||||
self.state = HandState::Complete;
|
self.state = HandState::Complete;
|
||||||
|
|
||||||
|
|
@ -1405,8 +1411,11 @@ impl HandRunner {
|
||||||
.and_then(|id| self.find_seat_index_by_player_id(id))
|
.and_then(|id| self.find_seat_index_by_player_id(id))
|
||||||
.map(|idx| self.seats[idx].index);
|
.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.
|
// 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 street_idx = self.street_to_action_index();
|
||||||
let num_raises = self
|
let num_raises = self
|
||||||
.actions_this_hand
|
.actions_this_hand
|
||||||
|
|
@ -1416,10 +1425,10 @@ impl HandRunner {
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
|
.filter(|(_, a)| matches!(a, Action::Raise { .. }))
|
||||||
.count();
|
.count();
|
||||||
// On preflop, the SB and BB are recorded as raises (Meerkat convention).
|
// On preflop, subtract 1 for the SB posting only.
|
||||||
// Subtract them to get the count of voluntary player raises.
|
// The BB remains counted as the first raise (Meerkat semantics).
|
||||||
if self.current_street() == Street::Preflop {
|
if self.current_street() == Street::Preflop {
|
||||||
total_raises.saturating_sub(2)
|
total_raises.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
total_raises
|
total_raises
|
||||||
}
|
}
|
||||||
|
|
@ -1446,6 +1455,7 @@ impl HandRunner {
|
||||||
num_raises,
|
num_raises,
|
||||||
hand_number: self.hand_number,
|
hand_number: self.hand_number,
|
||||||
active_players: self.count_active_players(),
|
active_players: self.count_active_players(),
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1658,7 +1668,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
assert_eq!(runner.state(), HandState::Idle);
|
assert_eq!(runner.state(), HandState::Idle);
|
||||||
|
|
@ -1693,7 +1703,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1729,7 +1739,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1, seat2];
|
let seats = vec![seat0, seat1, seat2];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1755,7 +1765,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1777,7 +1787,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
assert_eq!(runner.state(), HandState::Idle);
|
assert_eq!(runner.state(), HandState::Idle);
|
||||||
|
|
@ -1800,7 +1810,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
let result = runner.step();
|
let result = runner.step();
|
||||||
|
|
@ -1820,7 +1830,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1856,7 +1866,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1, seat2];
|
let seats = vec![seat0, seat1, seat2];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1972,7 +1982,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1, seat2];
|
let seats = vec![seat0, seat1, seat2];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
@ -1998,7 +2008,7 @@ mod tests {
|
||||||
|
|
||||||
let seats = vec![seat0, seat1];
|
let seats = vec![seat0, seat1];
|
||||||
let deck = Deck::new();
|
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 mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||||
runner.start(10, 20, 0);
|
runner.start(10, 20, 0);
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,24 @@ pub trait HoldemPlayer: Send + Sync {
|
||||||
/// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`.
|
/// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`.
|
||||||
fn game_over_event(&mut self, game_info: &GameInfo);
|
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.
|
/// Signal an early-fold intent.
|
||||||
///
|
///
|
||||||
/// Called by the framework layer when a player wants to fold before it is
|
/// 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
|
/// * `placement` — The player's finishing position (1-based). A placement
|
||||||
/// of `1` means the player won the tournament.
|
/// of `1` means the player won the tournament.
|
||||||
fn on_eliminated(&self, placement: u32);
|
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_dealer,
|
||||||
is_small_blind,
|
is_small_blind,
|
||||||
is_big_blind,
|
is_big_blind,
|
||||||
|
external_id: None,
|
||||||
bankroll: self.bankroll,
|
bankroll: self.bankroll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ pub struct PlayerInfo {
|
||||||
/// Whether this player is the big blind.
|
/// Whether this player is the big blind.
|
||||||
pub is_big_blind: bool,
|
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.
|
/// The player's overall bankroll balance across all hands.
|
||||||
/// Positive = profit, negative = loss.
|
/// Positive = profit, negative = loss.
|
||||||
pub bankroll: i64,
|
pub bankroll: i64,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
use super::bot_factory;
|
use super::bot_factory;
|
||||||
use super::config::GameConfig;
|
use super::config::GameConfig;
|
||||||
|
|
@ -128,10 +129,29 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(GameError::NotEnoughPlayers(_)) => {
|
Err(GameError::NotEnoughPlayers(count)) => {
|
||||||
// Not enough active players to continue.
|
// 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;
|
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) => {
|
Err(e) => {
|
||||||
return 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);
|
let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed);
|
||||||
|
|
||||||
for tournament_index in 0..num_tournaments {
|
struct TourneyResult {
|
||||||
let table_config = config.to_table_config();
|
total_hands: u64,
|
||||||
let blind_schedule = config.to_blind_schedule();
|
player_results: Vec<(String, u32, ChipCount)>,
|
||||||
let prize_structure = config.to_prize_structure();
|
}
|
||||||
|
|
||||||
// Create the appropriate tournament type based on game_type.
|
let tournament_results: Vec<TourneyResult> = (0..num_tournaments)
|
||||||
let mut tournament = match config.game_type {
|
.into_par_iter()
|
||||||
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
|
.map(|tournament_index| {
|
||||||
config.num_tables,
|
let table_config = config.to_table_config();
|
||||||
table_config,
|
let blind_schedule = config.to_blind_schedule();
|
||||||
blind_schedule,
|
let prize_structure = config.to_prize_structure();
|
||||||
prize_structure,
|
|
||||||
config.starting_chips,
|
|
||||||
buy_in,
|
|
||||||
),
|
|
||||||
_ => Tournament::new_sng(
|
|
||||||
table_config,
|
|
||||||
blind_schedule,
|
|
||||||
prize_structure,
|
|
||||||
config.starting_chips,
|
|
||||||
buy_in,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the registration order. If randomize_seats is enabled,
|
let mut tournament = match config.game_type {
|
||||||
// shuffle the bot order using a seed derived from the base seed +
|
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
|
||||||
// tournament index for reproducibility.
|
config.num_tables,
|
||||||
let mut order: Vec<usize> = (0..config.bots.len()).collect();
|
table_config,
|
||||||
if config.randomize_seats {
|
blind_schedule,
|
||||||
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
|
prize_structure,
|
||||||
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
|
config.starting_chips,
|
||||||
order.shuffle(&mut rng);
|
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.
|
let mut order: Vec<usize> = (0..config.bots.len()).collect();
|
||||||
// We need to track names by PlayerId for this tournament.
|
if config.randomize_seats {
|
||||||
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
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 mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
||||||
let bot_config = &config.bots[bot_idx];
|
for (seat_seq, &bot_idx) in order.iter().enumerate() {
|
||||||
let player_id = PlayerId(seat_seq as u64);
|
let bot_config = &config.bots[bot_idx];
|
||||||
let bot = bot_factory::create_bot(bot_config, player_id)
|
let player_id = PlayerId(seat_seq as u64);
|
||||||
.map_err(GameError::Custom)?;
|
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();
|
log::info!(
|
||||||
player_names.insert(player_id, name);
|
"Running tournament {}/{}...",
|
||||||
|
|
||||||
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 {})",
|
|
||||||
tournament_index + 1,
|
tournament_index + 1,
|
||||||
standings.len(),
|
num_tournaments
|
||||||
expected_standings
|
|
||||||
);
|
);
|
||||||
}
|
tournament.run_to_completion()?;
|
||||||
|
|
||||||
// Track which player IDs have been recorded to prevent double-counting.
|
let total_hands = tournament.total_hands();
|
||||||
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
let standings = tournament.standings();
|
||||||
|
|
||||||
// Standings contain eliminated players with their finish position.
|
let expected_standings = player_names.len().saturating_sub(1);
|
||||||
// The placement value is the correct finish position (2 = runner-up,
|
if standings.len() != expected_standings {
|
||||||
// N = first eliminated). Players eliminated in the same hand share
|
log::warn!(
|
||||||
// the same position.
|
"Tournament {} ended with {} standings (expected {})",
|
||||||
for &(player_id, finish_position) in standings {
|
tournament_index + 1,
|
||||||
let name = player_names
|
standings.len(),
|
||||||
.get(&player_id)
|
expected_standings
|
||||||
.cloned()
|
);
|
||||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
}
|
||||||
|
|
||||||
let prize = tournament
|
let mut player_results = Vec::new();
|
||||||
.prize_structure()
|
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
||||||
.prize_for_placement(finish_position)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
log::debug!(
|
for &(player_id, finish_position) in standings {
|
||||||
"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) {
|
|
||||||
let name = player_names
|
let name = player_names
|
||||||
.get(&winner_id)
|
.get(&player_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("Player({})", winner_id.0));
|
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||||
|
|
||||||
let prize = tournament
|
let prize = tournament
|
||||||
.prize_structure()
|
.prize_structure()
|
||||||
.prize_for_placement(1)
|
.prize_for_placement(finish_position)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
log::debug!(
|
||||||
result.record_player_finish(&name, 1, prize);
|
"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() {
|
fn record_tournament_result_processes_completed_tournament() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 9,
|
num_seats: 9,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ impl Default for TableConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
num_seats: 9,
|
num_seats: 9,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -330,11 +330,6 @@ impl Table {
|
||||||
return Err(GameError::HandAlreadyInProgress);
|
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.
|
// 2.5. Bankroll management.
|
||||||
if self.reset_bankroll_each_hand {
|
if self.reset_bankroll_each_hand {
|
||||||
|
|
@ -416,7 +411,36 @@ impl Table {
|
||||||
.ok_or(GameError::NoHandInProgress)?;
|
.ok_or(GameError::NoHandInProgress)?;
|
||||||
|
|
||||||
// Run the hand to completion.
|
// 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.
|
// Extract seats from the runner and restore them to the table.
|
||||||
// HandRunner::into_seats() consumes the runner and returns the seats
|
// HandRunner::into_seats() consumes the runner and returns the seats
|
||||||
|
|
@ -563,6 +587,7 @@ impl Table {
|
||||||
num_raises: 0,
|
num_raises: 0,
|
||||||
hand_number: self.hand_number,
|
hand_number: self.hand_number,
|
||||||
active_players: self.active_player_count(),
|
active_players: self.active_player_count(),
|
||||||
|
table_id: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -833,7 +858,7 @@ mod tests {
|
||||||
fn default_config() -> TableConfig {
|
fn default_config() -> TableConfig {
|
||||||
TableConfig {
|
TableConfig {
|
||||||
num_seats: 6,
|
num_seats: 6,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -1288,7 +1313,7 @@ mod tests {
|
||||||
fn test_table_full() {
|
fn test_table_full() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 2,
|
num_seats: 2,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 10 },
|
bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
|
||||||
|
|
@ -1006,7 +1006,7 @@ mod tests {
|
||||||
fn default_table_config() -> TableConfig {
|
fn default_table_config() -> TableConfig {
|
||||||
TableConfig {
|
TableConfig {
|
||||||
num_seats: 9,
|
num_seats: 9,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -1188,7 +1188,7 @@ mod tests {
|
||||||
fn test_sng_registration_fills_table() {
|
fn test_sng_registration_fills_table() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 4,
|
num_seats: 4,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -1217,7 +1217,7 @@ mod tests {
|
||||||
fn test_sng_run_single_hand() {
|
fn test_sng_run_single_hand() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 4,
|
num_seats: 4,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -1283,7 +1283,7 @@ mod tests {
|
||||||
fn test_mtt_table_balancing() {
|
fn test_mtt_table_balancing() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 9,
|
num_seats: 9,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
@ -1328,7 +1328,7 @@ mod tests {
|
||||||
fn test_blind_advancement() {
|
fn test_blind_advancement() {
|
||||||
let config = TableConfig {
|
let config = TableConfig {
|
||||||
num_seats: 4,
|
num_seats: 4,
|
||||||
bet_limit: BetLimit::NoLimit { min_raise: 20 },
|
bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None },
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
ante: 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue