feat: Supersonic evaluator, Cactus Kev hash, repo split cleanup
Evaluator overhaul: - Add Supersonic LUT trie evaluator (32M-entry, O(n) hand evaluation) - Add Cactus Kev perfect-hash 5-card evaluator (prime-product hashing) - 3-tier evaluation priority: Supersonic → Cactus Kev → Algorithmic - DashMap-cached 7-card evaluation for lock-free concurrent access - Rewrite JumpTable around CactusKevEvaluator + DashMap cache Bot framework: - Add StrategyBot adapter (Strategy trait → HoldemPlayer) - Add register_bot_type() for plugin-style bot registration - Add strategy module with modular composable strategies Repository split: - Remove holdem_bots from workspace (moved to private super_marvin repo) - Remove holdem_bots dependency from holdem_sim - Update cash_game.toml to use built-in bots only - Clean up .gitignore (remove stale holdem_bots exclusion) - Delete plans/ directory (internal planning artifacts) Documentation: - Add ARCHITECTURE_SUPERSONIC_EVALUATOR.md - Add REFERENCES.md - Update ROADMAP.md and VISION.md - Remove outdated docs (ACHIEVEMENTS, RAKE, TOURNAMENT_RAKE, meerkat-alignment)
This commit is contained in:
parent
f81f0579d1
commit
09a0777f96
36 changed files with 6565 additions and 4934 deletions
|
|
@ -1,44 +0,0 @@
|
|||
# Poker AI Testbed & Live Server Achievements
|
||||
|
||||
## Completed Phases and Items
|
||||
|
||||
### Phase 1: Foundation ✅
|
||||
|
||||
**Basic poker engine**
|
||||
- Card/Hand/Board representations ✅
|
||||
- Fast LUT hand evaluator (5, 6, and 7 cards) ✅
|
||||
|
||||
### Phase 2: Simulation Testbed ✅
|
||||
|
||||
**Bot framework**
|
||||
- Meerkat-API-compatible `HoldemPlayer` trait ✅
|
||||
- Built-in bots: CallBot, FoldBot, RaiseBot, RandomBot ✅
|
||||
- Bot factory for config-driven bot instantiation ✅
|
||||
|
||||
**Game engine**
|
||||
- Full hand execution engine (HandRunner state machine) ✅
|
||||
- Table management with seating, button rotation, blind posting ✅
|
||||
- Tournament support (SNG, MTT) with blind schedules and prize structures ✅
|
||||
- Pot management with side pot algorithm ✅
|
||||
- NL/PL/FL betting structures ✅
|
||||
- Seed control for reproducible simulations ✅
|
||||
|
||||
**Simulation infrastructure**
|
||||
- TOML-based configuration system ✅
|
||||
- Cash game simulation loop ✅
|
||||
- Performance statistics accumulator ✅
|
||||
- CLI simulation runner (`holdem_sim`) ✅
|
||||
- Example configs for Cash, SNG, and MTT ✅
|
||||
|
||||
**Testing**
|
||||
- 369+ unit tests passing ✅
|
||||
- Integration and property tests ✅
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Bot Performance:** Profitability across different game types
|
||||
- **Simulation Speed:** Ability to run millions of hands efficiently
|
||||
- **Live Reliability:** 99.9% uptime with correct action execution
|
||||
- **Flexibility:** Easy configuration for new sites and game types
|
||||
- **Evolution:** Continuous improvement through simulation results
|
||||
- **Monitoring:** Meaningful debug and performance logging and visualization
|
||||
|
|
@ -1,648 +0,0 @@
|
|||
# Bot Framework Architecture — Supersonic Port
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the module architecture for porting the Java Supersonic bot framework to the Rust `rusty-marvin` project. The design introduces a **strategy framework** with hand analysis, modular strategy composition, and advanced cash-game bots — while keeping proprietary bot logic in a separate private crate.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Public/Private separation** — Advanced bots live in a private crate excludable from public mirrors
|
||||
2. **Framework in `holdem_core`** — Traits, helpers, analysis tools remain public and reusable
|
||||
3. **Idiomatic Rust** — Traits instead of interfaces, enums instead of inheritance hierarchies, `Option` instead of null
|
||||
4. **Minimal disruption** — Existing bots and engine code continue to work unchanged
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
The existing codebase already has:
|
||||
|
||||
- [`HoldemPlayer`](holdem_core/src/game/player.rs:78) trait — the Meerkat-compatible bot interface with `act()`, `action_event()`, etc.
|
||||
- [`GameInfo`](holdem_core/src/game/game_info.rs:21) — read-only game state snapshot passed to bots
|
||||
- [`bot_factory`](holdem_core/src/game/bot_factory.rs:52) — string-based factory mapping `"call_bot"` → `Box<dyn HoldemPlayer>`
|
||||
- Six simple bots in [`holdem_core/src/game/bots/`](holdem_core/src/game/bots/mod.rs) — `CallBot`, `FoldBot`, `RaiseBot`, `RandomBot`, `FlockBot`, `ChumpBot`
|
||||
- [`Evaluator`](holdem_core/src/evaluator/evaluator.rs) + [`EvaluatorSingleton`](holdem_core/src/evaluator/singleton.rs:9) — Two-Plus-Two lookup table evaluator
|
||||
- [`Card`](holdem_core/src/card.rs) with zero-based rank/suit, [`HandValue`](holdem_core/src/evaluator/evaluator.rs:108), [`Street`](holdem_core/src/board.rs) enum
|
||||
|
||||
---
|
||||
|
||||
## Proposed Workspace Structure
|
||||
|
||||
```
|
||||
rusty-marvin/
|
||||
├── Cargo.toml # workspace root — members listed here
|
||||
├── holdem_core/ # PUBLIC crate — framework + simple bots
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── card.rs, deck.rs, board.rs, hand.rs, hole_cards.rs
|
||||
│ ├── errors.rs
|
||||
│ ├── evaluator/ # unchanged
|
||||
│ └── game/
|
||||
│ ├── mod.rs
|
||||
│ ├── action.rs, betting.rs, config.rs
|
||||
│ ├── game_info.rs, player_info.rs
|
||||
│ ├── hand_runner.rs, hand_history.rs
|
||||
│ ├── player.rs # HoldemPlayer trait + Seat
|
||||
│ ├── pot.rs, table.rs, tournament.rs
|
||||
│ ├── simulation.rs, stats.rs, types.rs
|
||||
│ ├── bot_factory.rs # EXTENDED: plugin-style registration
|
||||
│ ├── bots/ # simple test bots — unchanged
|
||||
│ │ ├── mod.rs, call_bot.rs, fold_bot.rs
|
||||
│ │ ├── raise_bot.rs, random_bot.rs
|
||||
│ │ ├── chump_bot.rs, flock_bot.rs
|
||||
│ └── strategy/ # NEW: strategy framework
|
||||
│ ├── mod.rs
|
||||
│ ├── context.rs # StrategyContext — aggregated decision state
|
||||
│ ├── modular.rs # ModularStrategy — composite pattern
|
||||
│ ├── action_recorder.rs # ActionRecorder — per-street action log
|
||||
│ ├── hand_analyzer.rs # HandAnalyzer — postflop hand analysis
|
||||
│ ├── hand_helper.rs # HandHelper — preflop tables + utilities
|
||||
│ ├── hand_range.rs # HandRange — 52x52 weighted opponent range
|
||||
│ └── hand_potential.rs # HandPotential — hand strength estimation
|
||||
├── holdem_bots/ # PRIVATE crate — advanced bots
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── factory.rs # registers bots with holdem_core factory
|
||||
│ └── cash/ # cash game strategies
|
||||
│ ├── mod.rs
|
||||
│ ├── big_stack.rs # BigStackStrategy
|
||||
│ ├── short_stack.rs # ShortStackStrategy
|
||||
│ ├── postflop.rs # PostFlopStrategyV3 — shared postflop logic
|
||||
│ ├── preflop/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── big_stack.rs # BigStackPreFlopStrategy
|
||||
│ │ └── short_stack.rs # ShortStackPreFlopStrategy
|
||||
│ └── street/
|
||||
│ ├── mod.rs
|
||||
│ ├── flop.rs # BigStackFlopStrategy
|
||||
│ ├── turn.rs # BigStackTurnStrategy
|
||||
│ └── river.rs # BigStackRiverStrategy
|
||||
├── holdem_sim/ # unchanged
|
||||
│ └── src/main.rs
|
||||
├── docs/
|
||||
└── plans/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Crate Responsibilities
|
||||
|
||||
### `holdem_core` — Public Framework Crate
|
||||
|
||||
**Contains:** Everything needed to build a poker bot.
|
||||
|
||||
| Module | Purpose | Visibility |
|
||||
|--------|---------|------------|
|
||||
| `card`, `deck`, `board`, `hand`, `hole_cards` | Core card types | Public |
|
||||
| `evaluator` | Hand evaluation via lookup tables | Public |
|
||||
| `game::player` | [`HoldemPlayer`](holdem_core/src/game/player.rs:78) trait — the bot interface | Public |
|
||||
| `game::game_info` | [`GameInfo`](holdem_core/src/game/game_info.rs:21) — read-only game state | Public |
|
||||
| `game::bot_factory` | Bot instantiation with plugin registration | Public |
|
||||
| `game::bots` | Simple test bots: CallBot, FoldBot, etc. | Public |
|
||||
| `game::strategy` | **NEW** — Strategy framework traits and helpers | Public |
|
||||
| `game::strategy::modular` | **NEW** — Composite strategy with voting | Public |
|
||||
| `game::strategy::action_recorder` | **NEW** — Per-street action tracking | Public |
|
||||
| `game::strategy::hand_analyzer` | **NEW** — Postflop hand analysis | Public |
|
||||
| `game::strategy::hand_helper` | **NEW** — Preflop rankings + board texture | Public |
|
||||
| `game::strategy::hand_range` | **NEW** — 52×52 weighted opponent range grid | Public |
|
||||
| `game::strategy::hand_potential` | **NEW** — Hand strength / potential estimation | Public |
|
||||
|
||||
### `holdem_bots` — Private Advanced Bots Crate
|
||||
|
||||
**Contains:** Proprietary bot implementations that implement `HoldemPlayer`.
|
||||
|
||||
| Module | Purpose | Visibility |
|
||||
|--------|---------|------------|
|
||||
| `factory` | Registers bot types with core factory on init | Private |
|
||||
| `cash::big_stack` | BigStackStrategy — stack > 25 BB | Private |
|
||||
| `cash::short_stack` | ShortStackStrategy — stack ≤ 25 BB | Private |
|
||||
| `cash::postflop` | Shared postflop logic using HandPotential | Private |
|
||||
| `cash::preflop::big_stack` | BigStack preflop strategy | Private |
|
||||
| `cash::preflop::short_stack` | ShortStack preflop strategy | Private |
|
||||
| `cash::street::flop` | BigStack flop strategy | Private |
|
||||
| `cash::street::turn` | BigStack turn strategy | Private |
|
||||
| `cash::street::river` | BigStack river strategy | Private |
|
||||
|
||||
---
|
||||
|
||||
## Key Traits and Relationships
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class HoldemPlayer {
|
||||
<<trait>>
|
||||
+id() PlayerId
|
||||
+name() str
|
||||
+init(game_info)
|
||||
+game_start_event(game_info)
|
||||
+hole_cards_dealt(hole_cards, game_info)
|
||||
+stage_event(game_info)
|
||||
+act(game_info) Action
|
||||
+action_event(player_id, action, game_info)
|
||||
+showdown_event(player_id, hole_cards, hand_value, game_info)
|
||||
+game_over_event(game_info)
|
||||
}
|
||||
|
||||
class Strategy {
|
||||
<<trait>>
|
||||
+is_applicable(context) bool
|
||||
+act(context) Action
|
||||
}
|
||||
|
||||
class ModularStrategy {
|
||||
-strategies: Vec~Box dyn Strategy~
|
||||
-mode: VotingMode
|
||||
+act(context) Action
|
||||
}
|
||||
|
||||
class StrategyContext {
|
||||
+game_info: GameInfo
|
||||
+hole_cards: HoleCards
|
||||
+action_recorder: ActionRecorder
|
||||
+aggressor_id: Option~PlayerId~
|
||||
+num_raises: usize
|
||||
+num_callers: usize
|
||||
}
|
||||
|
||||
class ActionRecorder {
|
||||
-actions: Vec~Vec~ActionRecord~~
|
||||
+record(street, player_id, action)
|
||||
+actions_on_street(street) Vec
|
||||
+last_raise_on_street(street) Option
|
||||
+reset()
|
||||
}
|
||||
|
||||
class HandAnalyzer {
|
||||
-evaluator: Arc~Evaluator~
|
||||
+is_over_pair(hole_cards, board) bool
|
||||
+is_paired(board) bool
|
||||
+is_trips_or_higher(hand_value) bool
|
||||
+num_outs_to_monster(hole_cards, board) u8
|
||||
+is_oesd(hole_cards, board) bool
|
||||
+is_flush_draw(hole_cards, board) bool
|
||||
+suit_count(hole_cards, board) u8
|
||||
}
|
||||
|
||||
class HandHelper {
|
||||
+preflop_rank_full_ring(hole_cards) f64
|
||||
+preflop_rank_hu(hole_cards) f64
|
||||
+kill_phil_group(hole_cards) u8
|
||||
+board_texture(board) BoardTexture
|
||||
+count_outs(hole_cards, board) u8
|
||||
}
|
||||
|
||||
class HandRange {
|
||||
-weights: [[f64; 13]; 13]
|
||||
+weight(rank1, rank2) f64
|
||||
+set_weight(rank1, rank2, weight)
|
||||
+normalize()
|
||||
}
|
||||
|
||||
class HandPotential {
|
||||
+hand_strength(hole_cards, board, range) f64
|
||||
+positive_potential(hole_cards, board, range) f64
|
||||
+negative_potential(hole_cards, board, range) f64
|
||||
}
|
||||
|
||||
class VotingMode {
|
||||
<<enum>>
|
||||
FirstApplicable
|
||||
Democratic
|
||||
Random
|
||||
}
|
||||
|
||||
Strategy <|.. ModularStrategy : implements
|
||||
ModularStrategy o-- Strategy : contains N
|
||||
ModularStrategy --> VotingMode : uses
|
||||
StrategyContext --> ActionRecorder : contains
|
||||
Strategy --> StrategyContext : reads
|
||||
HandAnalyzer --> Evaluator : uses
|
||||
HandPotential --> HandRange : uses
|
||||
HoldemPlayer <|.. ModularStrategy : implements via adapter
|
||||
HoldemPlayer <|.. CallBot : implements
|
||||
HoldemPlayer <|.. BigStackBot : implements
|
||||
```
|
||||
|
||||
### Strategy Trait
|
||||
|
||||
The core abstraction for composable bot behavior:
|
||||
|
||||
```rust
|
||||
/// A single strategy component that can decide whether it applies
|
||||
/// to the current game situation and produce an action.
|
||||
pub trait Strategy: Send + Sync {
|
||||
/// Whether this strategy should participate in the current decision.
|
||||
fn is_applicable(&self, context: &StrategyContext) -> bool;
|
||||
|
||||
/// Produce an action. Called only when `is_applicable` returns true.
|
||||
fn act(&self, context: &StrategyContext) -> Action;
|
||||
}
|
||||
```
|
||||
|
||||
### VotingMode Enum
|
||||
|
||||
Replaces the Java inheritance hierarchy for `ModularStrategy` subclasses:
|
||||
|
||||
```rust
|
||||
/// How a ModularStrategy selects among multiple applicable sub-strategies.
|
||||
pub enum VotingMode {
|
||||
/// Use the first strategy where `is_applicable` returns true.
|
||||
FirstApplicable,
|
||||
/// Each applicable strategy votes; majority wins.
|
||||
Democratic,
|
||||
/// Pick a random applicable strategy.
|
||||
Random,
|
||||
}
|
||||
```
|
||||
|
||||
### StrategyContext
|
||||
|
||||
Aggregated decision state passed to strategies — replaces the Java pattern of passing `GameInfo`, hole cards, position, etc. as separate parameters:
|
||||
|
||||
```rust
|
||||
/// Read-only context provided to strategies at decision time.
|
||||
pub struct StrategyContext<'a> {
|
||||
pub game_info: &'a GameInfo,
|
||||
pub hole_cards: &'a HoleCards,
|
||||
pub action_recorder: &'a ActionRecorder,
|
||||
pub aggressor_id: Option<PlayerId>,
|
||||
pub num_raises: usize,
|
||||
pub num_callers: usize,
|
||||
}
|
||||
```
|
||||
|
||||
### ModularStrategy
|
||||
|
||||
The composite pattern. It implements both `Strategy` and `HoldemPlayer`:
|
||||
|
||||
```rust
|
||||
/// Composite strategy that delegates to sub-strategies via a voting mechanism.
|
||||
pub struct ModularStrategy {
|
||||
strategies: Vec<Box<dyn Strategy>>,
|
||||
voting_mode: VotingMode,
|
||||
// Internal state managed across the hand lifecycle
|
||||
hole_cards: Option<HoleCards>,
|
||||
action_recorder: ActionRecorder,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Java → Rust Idiom Mapping
|
||||
|
||||
| Java Construct | Rust Equivalent | Notes |
|
||||
|---|---|---|
|
||||
| `interface Strategy` | `trait Strategy` | `Send + Sync` bounds for thread safety |
|
||||
| `ModularStrategy extends Strategy` | `ModularStrategy` struct implementing `Strategy` trait | Composition over inheritance |
|
||||
| `BigStackStrategy extends ModularStrategy` | `BigStackBot` struct implementing `HoldemPlayer` directly, containing a `ModularStrategy` | No inheritance chain — flat delegation |
|
||||
| `HandAnalyzer` with singleton evaluator | `HandAnalyzer` struct holding `Arc<Evaluator>` from [`EvaluatorSingleton`](holdem_core/src/evaluator/singleton.rs:9) | `OnceLock` singleton already exists |
|
||||
| `HandHelper` static methods | `HandHelper` struct with associated functions / methods | Preflop tables as `const` arrays |
|
||||
| `int card = 0..51` | [`Card`](holdem_core/src/card.rs) struct with rank/suit | Already exists in codebase |
|
||||
| `null` | `Option<T>` | Idiomatic throughout |
|
||||
| `Vector<Action>` | `Vec<Action>` | Standard |
|
||||
| `HandRange extends WeightTable` | `HandRange` struct with `[[f64; 13]; 13]` grid | Flat array, no inheritance |
|
||||
| `ActionRecorder` per-hand state | `ActionRecorder` struct, reset on `game_start_event` | Owned by `ModularStrategy` |
|
||||
| `KillPhilGrouping` enum-like class | Rust `enum` with `#[repr(u8)]` | Type-safe |
|
||||
|
||||
---
|
||||
|
||||
## Bot Factory: Plugin Registration Pattern
|
||||
|
||||
The current [`create_bot()`](holdem_core/src/game/bot_factory.rs:52) uses a hardcoded `match` statement. To support the private crate registering its bots without modifying `holdem_core`, we introduce a **registry pattern**:
|
||||
|
||||
```rust
|
||||
use std::sync::RwLock;
|
||||
use std::collections::HashMap;
|
||||
|
||||
type BotConstructor = fn(&BotConfig, PlayerId) -> Result<Box<dyn HoldemPlayer>, String>;
|
||||
|
||||
static REGISTRY: RwLock<HashMap<String, BotConstructor>> = RwLock::new(HashMap::new());
|
||||
|
||||
/// Register a bot type. Called by holdem_core for built-in bots
|
||||
/// and by holdem_bots for advanced bots.
|
||||
pub fn register_bot_type(bot_type: &str, constructor: BotConstructor) {
|
||||
REGISTRY.write().unwrap().insert(bot_type.to_string(), constructor);
|
||||
}
|
||||
|
||||
/// Create a bot by type name. Falls back to the registry.
|
||||
pub fn create_bot(config: &BotConfig, id: PlayerId) -> Result<Box<dyn HoldemPlayer>, String> {
|
||||
let registry = REGISTRY.read().unwrap();
|
||||
if let Some(ctor) = registry.get(&config.bot_type) {
|
||||
ctor(config, id)
|
||||
} else {
|
||||
Err(format!("unknown bot type: '{}'", config.bot_type))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Initialization flow:**
|
||||
|
||||
1. `holdem_core` exports a `register_builtin_bots()` function that registers the six built-in bots
|
||||
2. `holdem_bots` exports a `register_advanced_bots()` function that registers BigStack, ShortStack, etc.
|
||||
3. `holdem_sim` (or any binary) calls both at startup
|
||||
|
||||
This avoids `holdem_core` depending on `holdem_bots` — the dependency flows the correct direction.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Bot Decision Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Engine calls HoldemPlayer::act] --> B[ModularStrategy::act]
|
||||
B --> C[Build StrategyContext]
|
||||
C --> D{For each sub-strategy}
|
||||
D --> E[is_applicable?]
|
||||
E -->|Yes| F[Collect action vote]
|
||||
E -->|No| D
|
||||
F --> D
|
||||
D -->|All checked| G{VotingMode}
|
||||
G -->|FirstApplicable| H[Return first applicable action]
|
||||
G -->|Democratic| I[Majority vote]
|
||||
G -->|Random| J[Pick random applicable]
|
||||
I --> K[Return winning action]
|
||||
J --> K
|
||||
H --> K
|
||||
K --> L[Return Action to engine]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Hand Analysis Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[HoleCards + Board] --> B[HandAnalyzer]
|
||||
A --> C[HandHelper]
|
||||
B --> D[EvaluatorSingleton]
|
||||
D --> E[HandValue + HandRank]
|
||||
B --> F{Analysis Results}
|
||||
F --> F1[is_over_pair]
|
||||
F --> F2[is_flush_draw]
|
||||
F --> F3[is_oesd]
|
||||
F --> F4[num_outs_to_monster]
|
||||
F --> F5[suit_count]
|
||||
C --> G{Preflop + Texture}
|
||||
G --> G1[preflop_rank_full_ring]
|
||||
G --> G2[kill_phil_group]
|
||||
G --> G3[board_texture]
|
||||
G --> G4[count_outs]
|
||||
F --> H[HandPotential]
|
||||
G --> H
|
||||
H --> I[hand_strength: f64]
|
||||
H --> J[positive_potential: f64]
|
||||
H --> K[negative_potential: f64]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public vs Private Separation Strategy
|
||||
|
||||
### Approach: Separate Crate + `.gitignore` for Public Mirror
|
||||
|
||||
The private `holdem_bots` crate is excluded when publishing to public mirrors:
|
||||
|
||||
**1. Workspace `Cargo.toml` — conditional member:**
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"holdem_core",
|
||||
"holdem_sim",
|
||||
"holdem_bots", # present in private repo
|
||||
]
|
||||
```
|
||||
|
||||
**2. `.gitignore` for public mirror export:**
|
||||
|
||||
When creating a public mirror, use a `.gitattributes` export-ignore or a separate `.gitignore`:
|
||||
|
||||
```gitignore
|
||||
# Public mirror excludes proprietary bots
|
||||
holdem_bots/
|
||||
```
|
||||
|
||||
**3. Conditional compilation in `holdem_sim`:**
|
||||
|
||||
```rust
|
||||
// holdem_sim/src/main.rs
|
||||
use holdem_core::game::bot_factory;
|
||||
|
||||
fn main() {
|
||||
// Register built-in bots (always available)
|
||||
bot_factory::register_builtin_bots();
|
||||
|
||||
// Register advanced bots (only when holdem_bots is in workspace)
|
||||
#[cfg(feature = "advanced-bots")]
|
||||
holdem_bots::register_advanced_bots();
|
||||
|
||||
// ... run simulation
|
||||
}
|
||||
```
|
||||
|
||||
**4. `holdem_sim/Cargo.toml` optional dependency:**
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
holdem_core = { path = "../holdem_core" }
|
||||
holdem_bots = { path = "../holdem_bots", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
advanced-bots = ["holdem_bots"]
|
||||
```
|
||||
|
||||
### What Stays Public
|
||||
|
||||
- All traits: `HoldemPlayer`, `Strategy`
|
||||
- All analysis tools: `HandAnalyzer`, `HandHelper`, `HandPotential`, `HandRange`
|
||||
- `ModularStrategy`, `ActionRecorder`, `StrategyContext`
|
||||
- Simple test bots: `CallBot`, `FoldBot`, `RaiseBot`, `RandomBot`, `FlockBot`, `ChumpBot`
|
||||
- The entire `evaluator` module
|
||||
|
||||
### What Stays Private
|
||||
|
||||
- `BigStackStrategy`, `ShortStackStrategy` implementations
|
||||
- Street-specific strategies: flop, turn, river
|
||||
- Preflop strategy tables for big stack / short stack
|
||||
- `PostFlopStrategyV3` shared logic
|
||||
- Any future proprietary bot implementations
|
||||
|
||||
---
|
||||
|
||||
## Files to Clean Up / Reorganize
|
||||
|
||||
### No Deletions Needed
|
||||
|
||||
All existing files remain. The change is purely additive:
|
||||
|
||||
| File | Action | Reason |
|
||||
|------|--------|--------|
|
||||
| [`holdem_core/src/game/bot_factory.rs`](holdem_core/src/game/bot_factory.rs) | **Refactor** | Replace hardcoded match with plugin registry |
|
||||
| [`holdem_core/src/game/mod.rs`](holdem_core/src/game/mod.rs) | **Add** `pub mod strategy;` | New strategy module |
|
||||
| [`Cargo.toml`](Cargo.toml) | **Add** `holdem_bots` member | New private crate |
|
||||
| [`holdem_sim/Cargo.toml`](holdem_sim/Cargo.toml) | **Add** optional `holdem_bots` dep | Feature-gated advanced bots |
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `holdem_core/src/game/strategy/mod.rs` | Module root, re-exports |
|
||||
| `holdem_core/src/game/strategy/context.rs` | `StrategyContext` struct |
|
||||
| `holdem_core/src/game/strategy/modular.rs` | `ModularStrategy` + `VotingMode` |
|
||||
| `holdem_core/src/game/strategy/action_recorder.rs` | `ActionRecorder` |
|
||||
| `holdem_core/src/game/strategy/hand_analyzer.rs` | `HandAnalyzer` |
|
||||
| `holdem_core/src/game/strategy/hand_helper.rs` | `HandHelper` + preflop tables |
|
||||
| `holdem_core/src/game/strategy/hand_range.rs` | `HandRange` 52×52 grid |
|
||||
| `holdem_core/src/game/strategy/hand_potential.rs` | `HandPotential` estimation |
|
||||
| `holdem_bots/Cargo.toml` | Private crate manifest |
|
||||
| `holdem_bots/src/lib.rs` | Crate root + `register_advanced_bots()` |
|
||||
| `holdem_bots/src/factory.rs` | Bot registration helpers |
|
||||
| `holdem_bots/src/cash/mod.rs` | Cash game module root |
|
||||
| `holdem_bots/src/cash/big_stack.rs` | BigStack bot |
|
||||
| `holdem_bots/src/cash/short_stack.rs` | ShortStack bot |
|
||||
| `holdem_bots/src/cash/postflop.rs` | Shared postflop logic |
|
||||
| `holdem_bots/src/cash/preflop/mod.rs` | Preflop submodule |
|
||||
| `holdem_bots/src/cash/preflop/big_stack.rs` | BigStack preflop |
|
||||
| `holdem_bots/src/cash/preflop/short_stack.rs` | ShortStack preflop |
|
||||
| `holdem_bots/src/cash/street/mod.rs` | Street submodule |
|
||||
| `holdem_bots/src/cash/street/flop.rs` | Flop strategy |
|
||||
| `holdem_bots/src/cash/street/turn.rs` | Turn strategy |
|
||||
| `holdem_bots/src/cash/street/river.rs` | River strategy |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
The implementation should proceed in dependency order — each step produces usable, testable artifacts:
|
||||
|
||||
### Phase 1: Strategy Framework Core
|
||||
|
||||
1. **Create `holdem_core/src/game/strategy/mod.rs`** — module root with re-exports
|
||||
2. **Create `action_recorder.rs`** — `ActionRecorder` struct; pure data, no dependencies beyond `Action` and `Street`
|
||||
3. **Create `context.rs`** — `StrategyContext` struct; references `GameInfo`, `HoleCards`, `ActionRecorder`
|
||||
4. **Create `modular.rs`** — `Strategy` trait, `VotingMode` enum, `ModularStrategy` struct implementing both `Strategy` and `HoldemPlayer`
|
||||
5. **Refactor `bot_factory.rs`** — replace hardcoded match with plugin registry; add `register_builtin_bots()` function
|
||||
6. **Update `game/mod.rs`** — add `pub mod strategy;`
|
||||
7. **Write unit tests** — `ActionRecorder` recording, `ModularStrategy` voting modes, factory registration
|
||||
|
||||
### Phase 2: Hand Analysis Tools
|
||||
|
||||
8. **Create `hand_analyzer.rs`** — `HandAnalyzer` struct using `Arc<Evaluator>`; methods like `is_over_pair()`, `is_flush_draw()`, `num_outs_to_monster()`
|
||||
9. **Create `hand_helper.rs`** — `HandHelper` with `const` preflop ranking tables, `kill_phil_group()`, `board_texture()`, `count_outs()`
|
||||
10. **Create `hand_range.rs`** — `HandRange` with `[[f64; 13]; 13]` weighted grid, `normalize()`, iteration
|
||||
11. **Create `hand_potential.rs`** — `HandPotential` with `hand_strength()`, `positive_potential()`, `negative_potential()` using Monte Carlo or exhaustive enumeration
|
||||
12. **Write property tests** — HandAnalyzer against known hand values, HandHelper against known preflop rankings
|
||||
|
||||
### Phase 3: Private Bots Crate
|
||||
|
||||
13. **Create `holdem_bots/` crate skeleton** — `Cargo.toml`, `lib.rs`, `cash/mod.rs`
|
||||
14. **Create `factory.rs`** — `register_advanced_bots()` function
|
||||
15. **Create `cash/preflop/` strategies** — preflop tables and decision logic for big stack and short stack
|
||||
16. **Create `cash/postflop.rs`** — shared postflop logic using `HandPotential`
|
||||
17. **Create `cash/street/` strategies** — flop, turn, river specific logic
|
||||
18. **Create `cash/big_stack.rs`** — `BigStackBot` implementing `HoldemPlayer`, composing strategies via `ModularStrategy`
|
||||
19. **Create `cash/short_stack.rs`** — `ShortStackBot` implementing `HoldemPlayer`, composing strategies via `ModularStrategy`
|
||||
20. **Update workspace `Cargo.toml`** — add `holdem_bots` member
|
||||
21. **Update `holdem_sim`** — add optional `holdem_bots` dependency and feature flag
|
||||
|
||||
### Phase 4: Integration and Validation
|
||||
|
||||
22. **Integration test** — run a cash game simulation with BigStackBot vs simple bots
|
||||
23. **Integration test** — run a tournament with mixed bot types
|
||||
24. **Validate public mirror** — confirm `holdem_core` compiles and all tests pass without `holdem_bots`
|
||||
25. **Update documentation** — add `holdem_bots/README.md`, update main `README.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph holdem_core
|
||||
card[card, deck, board, hand, hole_cards]
|
||||
evaluator[evaluator]
|
||||
game_engine[game engine: hand_runner, table, pot, betting]
|
||||
game_info[game_info, player_info, types, action]
|
||||
player[player: HoldemPlayer trait]
|
||||
simple_bots[bots: CallBot, FoldBot, etc.]
|
||||
strategy_framework[strategy: Strategy trait, ModularStrategy]
|
||||
analysis[strategy: HandAnalyzer, HandHelper, HandRange, HandPotential]
|
||||
action_recorder[strategy: ActionRecorder, StrategyContext]
|
||||
factory[bot_factory: registry pattern]
|
||||
end
|
||||
|
||||
subgraph holdem_bots
|
||||
bots_factory[factory: register_advanced_bots]
|
||||
big_stack[cash::big_stack]
|
||||
short_stack[cash::short_stack]
|
||||
postflop[cash::postflop]
|
||||
preflop[cash::preflop]
|
||||
street[cash::street]
|
||||
end
|
||||
|
||||
subgraph holdem_sim
|
||||
sim_main[main.rs]
|
||||
end
|
||||
|
||||
card --> evaluator
|
||||
card --> game_engine
|
||||
evaluator --> analysis
|
||||
game_info --> player
|
||||
player --> simple_bots
|
||||
player --> strategy_framework
|
||||
game_info --> strategy_framework
|
||||
action_recorder --> strategy_framework
|
||||
analysis --> strategy_framework
|
||||
strategy_framework --> factory
|
||||
simple_bots --> factory
|
||||
|
||||
player --> big_stack
|
||||
strategy_framework --> big_stack
|
||||
analysis --> big_stack
|
||||
big_stack --> preflop
|
||||
big_stack --> postflop
|
||||
short_stack --> preflop
|
||||
short_stack --> postflop
|
||||
postflop --> street
|
||||
big_stack --> bots_factory
|
||||
short_stack --> bots_factory
|
||||
|
||||
factory --> sim_main
|
||||
bots_factory --> sim_main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why `Strategy` trait separate from `HoldemPlayer`?
|
||||
|
||||
[`HoldemPlayer`](holdem_core/src/game/player.rs:78) is a **lifecycle interface** — it receives events across the entire hand. `Strategy` is a **decision interface** — it only produces actions. Separating them allows:
|
||||
|
||||
- Multiple strategies to be composed inside a single `HoldemPlayer` implementation
|
||||
- Strategies to be tested in isolation with mock `StrategyContext`
|
||||
- The `ModularStrategy` adapter bridges `Strategy` → `HoldemPlayer`
|
||||
|
||||
### Why plugin registry over hardcoded match?
|
||||
|
||||
The current [`create_bot()`](holdem_core/src/game/bot_factory.rs:52) match statement requires modifying `holdem_core` every time a new bot type is added. The registry pattern allows `holdem_bots` to register itself without `holdem_core` knowing about it. This is critical for the public/private separation.
|
||||
|
||||
### Why `[[f64; 13]; 13]` for HandRange instead of `[[f64; 52]; 52]`?
|
||||
|
||||
The Java `WeightTable` uses a 52×52 grid, but most entries are redundant because suit combinations collapse to 13×13 when using suited/offsuit notation. The 13×13 representation is more memory-efficient and aligns with standard poker range notation (e.g., "AA", "AKs", "AKo").
|
||||
|
||||
### Why `OnceLock` over `Lazy` for the evaluator?
|
||||
|
||||
[`OnceLock`](holdem_core/src/evaluator/singleton.rs:17) is already used in the codebase and is in `std::sync`. No need for the `once_cell` dependency for new singletons.
|
||||
|
||||
### Why keep simple bots in `holdem_core`?
|
||||
|
||||
The six existing bots (`CallBot`, `FoldBot`, `RaiseBot`, `RandomBot`, `FlockBot`, `ChumpBot`) serve as:
|
||||
1. **Test fixtures** — used in engine tests and property tests
|
||||
2. **Examples** — demonstrate how to implement `HoldemPlayer`
|
||||
3. **Baseline opponents** — needed even in the public crate for simulation
|
||||
|
||||
They contain no proprietary logic and are intentionally simple.
|
||||
|
|
@ -1,786 +0,0 @@
|
|||
# Architecture: Rake, Bankroll Management & Randomized Seats
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture for three new features in the `holdem_core` poker engine:
|
||||
|
||||
1. **Rake** — Configurable percentage deducted from pots at showdown
|
||||
2. **Bankroll Management** — Per-hand stack reset and automatic short-stack refill
|
||||
3. **Randomized Seats** — Shuffle player seat assignments before each hand or tournament
|
||||
|
||||
All three features are backward-compatible: existing TOML configs without these fields continue to work with sensible defaults (no rake, no reset, no refill, no randomization).
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Rake
|
||||
|
||||
### 1.1 Config Changes
|
||||
|
||||
Add two fields to [`GameConfig`](holdem_core/src/game/config.rs:178):
|
||||
|
||||
```rust
|
||||
// In GameConfig struct:
|
||||
|
||||
/// Rake percentage taken from each pot at showdown (e.g., 0.05 = 5%).
|
||||
/// Rake is deducted proportionally from each pot (main + side pots).
|
||||
/// Set to 0.0 (default) to disable rake.
|
||||
#[serde(default)]
|
||||
pub rake_percentage: f64,
|
||||
|
||||
/// Maximum rake in chips per pot. 0 means no cap.
|
||||
#[serde(default)]
|
||||
pub rake_cap: ChipCount,
|
||||
```
|
||||
|
||||
**Defaults**: `rake_percentage: 0.0`, `rake_cap: 0` (no rake, no cap). Since both use `#[serde(default)]`, existing TOML files without these fields deserialize correctly.
|
||||
|
||||
**Validation**: Add a validation method or `#[serde(deserialize_with = ...)]` that ensures `rake_percentage` is in `[0.0, 1.0]`.
|
||||
|
||||
### 1.2 Data Model Changes
|
||||
|
||||
#### New struct: `RakeConfig`
|
||||
|
||||
```rust
|
||||
/// Runtime rake configuration, derived from GameConfig.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RakeConfig {
|
||||
/// Fraction of each pot taken as rake (e.g., 0.05).
|
||||
pub percentage: f64,
|
||||
/// Maximum rake per pot in chips; 0 = uncapped.
|
||||
pub cap: ChipCount,
|
||||
}
|
||||
|
||||
impl RakeConfig {
|
||||
pub fn no_rake() -> Self {
|
||||
Self { percentage: 0.0, cap: 0 }
|
||||
}
|
||||
|
||||
/// Returns true if rake is effectively disabled.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.percentage == 0.0
|
||||
}
|
||||
|
||||
/// Calculate rake for a pot of the given size.
|
||||
/// Returns the rake amount (floored to whole chips).
|
||||
pub fn calculate_rake(&self, pot_amount: ChipCount) -> ChipCount {
|
||||
if self.percentage == 0.0 || pot_amount == 0 {
|
||||
return 0;
|
||||
}
|
||||
let rake = (pot_amount as f64 * self.percentage).floor() as ChipCount;
|
||||
if self.cap > 0 {
|
||||
rake.min(self.cap)
|
||||
} else {
|
||||
rake
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Changes to `HandResult`
|
||||
|
||||
Add a field to [`HandResult`](holdem_core/src/game/hand_runner.rs:86):
|
||||
|
||||
```rust
|
||||
pub struct HandResult {
|
||||
pub pot_winners: Vec<Vec<PotWinner>>,
|
||||
pub hand_values: HashMap<PlayerId, HandValue>,
|
||||
pub board: Board,
|
||||
pub chip_movements: HashMap<PlayerId, i64>,
|
||||
/// Total rake deducted from all pots this hand.
|
||||
pub total_rake: ChipCount,
|
||||
/// Rake deducted per pot, parallel to pot_winners.
|
||||
pub rake_per_pot: Vec<ChipCount>,
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Behavior Flow
|
||||
|
||||
The rake is applied **after** [`PotManager::distribute()`](holdem_core/src/game/pot.rs:180) returns its results, but **before** chip movements are calculated and stacks are credited. This keeps the pot manager's core logic clean and testable.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[pot_manager.distribute - hand_values] --> B[For each pot: calculate rake]
|
||||
B --> C[For each winner in pot: reduce award by proportional rake]
|
||||
C --> D[Accumulate total_rake and rake_per_pot]
|
||||
D --> E[Calculate chip_movements with raked amounts]
|
||||
E --> F[Credit raked winnings to stacks]
|
||||
F --> G[Build HandResult with rake info]
|
||||
```
|
||||
|
||||
**Hook point**: Both [`HandRunner::showdown()`](holdem_core/src/game/hand_runner.rs:543) and [`HandRunner::resolve_last_player_wins()`](holdem_core/src/game/hand_runner.rs:664) need rake logic. The cleanest approach is:
|
||||
|
||||
1. Add `rake_config: RakeConfig` field to [`HandRunner`](holdem_core/src/game/hand_runner.rs:100)
|
||||
2. Create a private helper method:
|
||||
|
||||
```rust
|
||||
impl HandRunner {
|
||||
/// Apply rake to pot distribution results.
|
||||
/// Returns (raked_pot_winners, rake_per_pot, total_rake).
|
||||
fn apply_rake(
|
||||
&self,
|
||||
pot_winners: Vec<Vec<PotWinner>>,
|
||||
) -> (Vec<Vec<PotWinner>>, Vec<ChipCount>, ChipCount) {
|
||||
if self.rake_config.is_zero() {
|
||||
let total: ChipCount = 0;
|
||||
let per_pot = vec![0; pot_winners.len()];
|
||||
return (pot_winners, per_pot, total);
|
||||
}
|
||||
|
||||
let mut total_rake: ChipCount = 0;
|
||||
let mut rake_per_pot = Vec::with_capacity(pot_winners.len());
|
||||
|
||||
let raked_winners: Vec<Vec<PotWinner>> = pot_winners
|
||||
.into_iter()
|
||||
.map(|mut pot_result| {
|
||||
// Sum the pot from winner amounts (they haven't been raked yet).
|
||||
let pot_total: ChipCount = pot_result.iter().map(|w| w.amount).sum();
|
||||
let rake = self.rake_config.calculate_rake(pot_total);
|
||||
rake_per_pot.push(rake);
|
||||
total_rake += rake;
|
||||
|
||||
// Deduct rake proportionally from each winner.
|
||||
if rake == 0 || pot_result.is_empty() {
|
||||
return pot_result;
|
||||
}
|
||||
|
||||
let num_winners = pot_result.len() as ChipCount;
|
||||
let rake_per_winner = rake / num_winners;
|
||||
let rake_remainder = rake % num_winners;
|
||||
|
||||
for (i, winner) in pot_result.iter_mut().enumerate() {
|
||||
let extra = if (i as ChipCount) < rake_remainder { 1 } else { 0 };
|
||||
winner.amount = winner.amount.saturating_sub(rake_per_winner + extra);
|
||||
}
|
||||
pot_result
|
||||
})
|
||||
.collect();
|
||||
|
||||
(raked_winners, rake_per_pot, total_rake)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. In [`showdown()`](holdem_core/src/game/hand_runner.rs:611), after line 611 (`let pot_winners = self.pot_manager.distribute(&hand_values);`), call `apply_rake()` before computing chip movements.
|
||||
|
||||
4. In [`resolve_last_player_wins()`](holdem_core/src/game/hand_runner.rs:677), same pattern after line 677.
|
||||
|
||||
5. Pass `RakeConfig` through [`HandRunner::new()`](holdem_core/src/game/hand_runner.rs:155) — add it as a parameter.
|
||||
|
||||
6. Thread `RakeConfig` from [`GameConfig`](holdem_core/src/game/config.rs:178) → [`Table`](holdem_core/src/game/table.rs:64) → [`HandRunner`](holdem_core/src/game/hand_runner.rs:100):
|
||||
- Add `rake_config: RakeConfig` to [`TableConfig`](holdem_core/src/game/table.rs:29)
|
||||
- Store it in [`Table`](holdem_core/src/game/table.rs:64)
|
||||
- Pass it when constructing `HandRunner` in [`Table::start_hand()`](holdem_core/src/game/table.rs:322)
|
||||
|
||||
### 1.4 Stats Impact
|
||||
|
||||
Add to [`PlayerStats`](holdem_core/src/game/stats.rs:20):
|
||||
|
||||
```rust
|
||||
/// Total rake paid by this player across all hands.
|
||||
/// Rake is attributed proportionally based on the player's contribution to each pot.
|
||||
pub total_rake_paid: ChipCount,
|
||||
```
|
||||
|
||||
Add to [`SimulationStats`](holdem_core/src/game/stats.rs:92):
|
||||
|
||||
```rust
|
||||
/// Total rake collected across the entire simulation.
|
||||
pub total_rake: ChipCount,
|
||||
```
|
||||
|
||||
Update [`SimulationStats::record_hand_result()`](holdem_core/src/game/stats.rs:120) to accumulate `total_rake` from `result.total_rake`.
|
||||
|
||||
**Rake attribution**: To attribute rake to individual players, we need to know each player's contribution to each pot. The `PotManager` already tracks this via `wagers`. We can add a method to compute per-player rake:
|
||||
|
||||
```rust
|
||||
impl PotManager {
|
||||
/// Returns each player's contribution to each finalized pot.
|
||||
/// Useful for proportional rake attribution.
|
||||
pub fn player_pot_contributions(&self) -> Vec<HashMap<PlayerId, ChipCount>> {
|
||||
// For each pot, compute what each player contributed
|
||||
// based on the side pot algorithm thresholds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, a simpler approach: track rake only at the simulation level (total_rake) and don't attribute per-player. This is simpler and sufficient for most use cases. Per-player rake attribution can be added later.
|
||||
|
||||
### 1.5 Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Pot of 0 chips | Rake = 0 (guard in `calculate_rake`) |
|
||||
| Rake > winner's share | Use `saturating_sub` — winner gets 0, rake is capped at pot total |
|
||||
| Split pot with uneven shares | Remainder rake chips distributed round-robin to winners in order |
|
||||
| No showdown (all fold) | **Rake still applies** — the winner's pot is raked |
|
||||
| Rake cap reached | `rake.min(self.cap)` applied per pot |
|
||||
| Multiple side pots | Rake calculated independently per pot |
|
||||
|
||||
### 1.6 TOML Config Example
|
||||
|
||||
```toml
|
||||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 6
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 10000
|
||||
num_hands = 1000
|
||||
rake_percentage = 0.05 # 5% rake
|
||||
rake_cap = 50 # Max 50 chips rake per pot
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Bankroll Management
|
||||
|
||||
### 2.1 Config Changes
|
||||
|
||||
Add two fields to [`GameConfig`](holdem_core/src/game/config.rs:178):
|
||||
|
||||
```rust
|
||||
/// If true, all players' stacks are reset to `starting_chips` before every hand.
|
||||
/// Their overall profit/loss balance is tracked separately via `bankroll`.
|
||||
#[serde(default)]
|
||||
pub reset_bankroll_each_hand: bool,
|
||||
|
||||
/// When a player's stack drops to or below this threshold (and reset is NOT enabled),
|
||||
/// they automatically buy back up to `starting_chips`. Their overall balance
|
||||
/// decreases by the buy-in amount.
|
||||
#[serde(default)]
|
||||
pub bankroll_refill_threshold: ChipCount,
|
||||
```
|
||||
|
||||
**Defaults**: `reset_bankroll_each_hand: false`, `bankroll_refill_threshold: 0` (disabled). Both use `#[serde(default)]` for backward compatibility.
|
||||
|
||||
### 2.2 Data Model Changes
|
||||
|
||||
#### New field on `Seat`
|
||||
|
||||
Add a `bankroll` field to [`Seat`](holdem_core/src/game/player.rs:268):
|
||||
|
||||
```rust
|
||||
pub struct Seat {
|
||||
// ... existing fields ...
|
||||
|
||||
/// The player's overall balance (profit/loss) across all hands.
|
||||
/// Starts at 0. Increases when the player wins, decreases when they lose.
|
||||
/// When reset_bankroll_each_hand is enabled, this tracks the cumulative
|
||||
/// profit/loss while the stack is reset each hand.
|
||||
/// When bankroll_refill_threshold triggers a reload, the buy-in amount
|
||||
/// is deducted from bankroll.
|
||||
pub bankroll: i64,
|
||||
}
|
||||
```
|
||||
|
||||
Update [`Seat::new()`](holdem_core/src/game/player.rs:331) to initialize `bankroll: 0`.
|
||||
|
||||
#### Changes to `Table`
|
||||
|
||||
Add bankroll config fields to [`Table`](holdem_core/src/game/table.rs:64):
|
||||
|
||||
```rust
|
||||
pub struct Table {
|
||||
// ... existing fields ...
|
||||
|
||||
/// If true, reset all stacks to starting_chips before each hand.
|
||||
reset_bankroll_each_hand: bool,
|
||||
|
||||
/// Auto-refill threshold. 0 = disabled.
|
||||
bankroll_refill_threshold: ChipCount,
|
||||
|
||||
/// Starting chips (for resets and refills).
|
||||
starting_chips: ChipCount,
|
||||
}
|
||||
```
|
||||
|
||||
Add these to [`TableConfig`](holdem_core/src/game/table.rs:29) as well so they flow from config:
|
||||
|
||||
```rust
|
||||
pub struct TableConfig {
|
||||
// ... existing fields ...
|
||||
pub reset_bankroll_each_hand: bool,
|
||||
pub bankroll_refill_threshold: ChipCount,
|
||||
pub starting_chips: ChipCount,
|
||||
}
|
||||
```
|
||||
|
||||
#### Changes to `PlayerInfo`
|
||||
|
||||
Add to [`PlayerInfo`](holdem_core/src/game/player_info.rs:35):
|
||||
|
||||
```rust
|
||||
/// The player's overall bankroll balance across all hands.
|
||||
/// Positive = profit, negative = loss.
|
||||
pub bankroll: i64,
|
||||
```
|
||||
|
||||
#### Changes to `GameInfo`
|
||||
|
||||
Add to [`GameInfo`](holdem_core/src/game/game_info.rs:21):
|
||||
|
||||
```rust
|
||||
/// Whether bankroll reset mode is enabled.
|
||||
pub reset_bankroll_each_hand: bool,
|
||||
```
|
||||
|
||||
### 2.3 Behavior Flow
|
||||
|
||||
#### Reset Mode (`reset_bankroll_each_hand = true`)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Before start_hand] --> B{reset_bankroll_each_hand?}
|
||||
B -- Yes --> C[For each seated player]
|
||||
C --> D[bankroll += stack as i64 - starting_chips as i64]
|
||||
D --> E[stack = starting_chips]
|
||||
E --> F[state = Active]
|
||||
B -- No --> G{refill_threshold > 0?}
|
||||
G -- Yes --> H[For each seated player]
|
||||
H --> I{stack <= threshold AND state != SittingOut?}
|
||||
I -- Yes --> J[refill_amount = starting_chips - stack]
|
||||
J --> K[bankroll -= refill_amount as i64]
|
||||
K --> L[stack = starting_chips]
|
||||
I -- No --> M[Skip]
|
||||
G -- No --> N[Proceed normally]
|
||||
F --> N
|
||||
L --> N
|
||||
M --> N
|
||||
```
|
||||
|
||||
**Hook point**: In [`Table::start_hand()`](holdem_core/src/game/table.rs:288), insert the bankroll logic **before** step 3 (advance button). The new step 2.5:
|
||||
|
||||
```rust
|
||||
// In Table::start_hand(), after the active_count check:
|
||||
|
||||
// 2.5. Bankroll management.
|
||||
if self.reset_bankroll_each_hand {
|
||||
self.reset_stacks_to_starting();
|
||||
} else if self.bankroll_refill_threshold > 0 {
|
||||
self.refill_short_stacks();
|
||||
}
|
||||
```
|
||||
|
||||
New private methods on `Table`:
|
||||
|
||||
```rust
|
||||
impl Table {
|
||||
/// Reset all seated players' stacks to starting_chips.
|
||||
/// Track the change in their bankroll.
|
||||
fn reset_stacks_to_starting(&mut self) {
|
||||
let starting = self.starting_chips;
|
||||
for slot in &mut self.seats {
|
||||
if let Some(seat) = slot.as_mut() {
|
||||
// The "cost" of resetting is the difference between
|
||||
// starting and current stack.
|
||||
// If they have more than starting, they profit.
|
||||
// If they have less, they lost.
|
||||
seat.bankroll += seat.stack as i64 - starting as i64;
|
||||
seat.stack = starting;
|
||||
// Re-activate sitting-out players (they have chips again).
|
||||
if seat.state == PlayerState::SittingOut {
|
||||
seat.state = PlayerState::Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill short-stacked players up to starting_chips.
|
||||
fn refill_short_stacks(&mut self) {
|
||||
let threshold = self.bankroll_refill_threshold;
|
||||
let starting = self.starting_chips;
|
||||
for slot in &mut self.seats {
|
||||
if let Some(seat) = slot.as_mut() {
|
||||
if seat.state == PlayerState::SittingOut {
|
||||
continue;
|
||||
}
|
||||
if seat.stack <= threshold {
|
||||
let refill = starting.saturating_sub(seat.stack);
|
||||
if refill > 0 {
|
||||
seat.bankroll -= refill as i64;
|
||||
seat.stack = starting;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: When `reset_bankroll_each_hand = true`, busted players (stack = 0, state = SittingOut) are re-activated with a full stack. This means all players play every hand — the simulation tracks their cumulative profit/loss via `bankroll`.
|
||||
|
||||
When `bankroll_refill_threshold > 0` (without reset), only players below the threshold get refilled. Busted players (SittingOut) are **not** refilled — they stay out. This matches real poker behavior where a bust-out is different from being short-stacked. If you want busted players to also reload, set `reset_bankroll_each_hand = true` instead.
|
||||
|
||||
#### After Hand Completion
|
||||
|
||||
In [`Table::complete_hand()`](holdem_core/src/game/table.rs:351), after restoring seats, the bankroll needs to be updated with the hand results. The chip movements from `HandResult` already reflect the net change per hand. However, with `reset_bankroll_each_hand`, the bankroll is updated at the **start** of the next hand (via `reset_stacks_to_starting`), not at the end of the current one. This is simpler and avoids double-counting.
|
||||
|
||||
For `bankroll_refill_threshold` mode (without reset), the bankroll is only affected by refill purchases. The stack changes from winning/losing are already reflected in `seat.stack`. The `bankroll` field tracks the additional money spent on refills.
|
||||
|
||||
### 2.4 Stats Impact
|
||||
|
||||
The existing [`SimulationStats::record_final_stacks()`](holdem_core/src/game/stats.rs:153) computes `total_profit = final_stack - starting_stack`. With bankroll management, this is no longer accurate.
|
||||
|
||||
**Solution**: Add `bankroll` tracking to stats:
|
||||
|
||||
```rust
|
||||
// In PlayerStats:
|
||||
/// Total bankroll balance (includes refill costs and reset adjustments).
|
||||
pub bankroll: i64,
|
||||
```
|
||||
|
||||
Update [`SimulationStats::record_final_stacks()`](holdem_core/src/game/stats.rs:153) to also record bankroll:
|
||||
|
||||
```rust
|
||||
pub fn record_final_stacks(
|
||||
&mut self,
|
||||
stacks: &HashMap<PlayerId, ChipCount>,
|
||||
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
||||
bankrolls: &HashMap<PlayerId, i64>, // NEW parameter
|
||||
) {
|
||||
for (&player_id, &final_stack) in stacks {
|
||||
if let Some(stats) = self.players.get_mut(&player_id) {
|
||||
stats.final_stack = final_stack;
|
||||
let starting = starting_stacks.get(&player_id).copied().unwrap_or(0);
|
||||
stats.bankroll = *bankrolls.get(&player_id).unwrap_or(&0);
|
||||
// With bankroll tracking, total_profit = bankroll
|
||||
// (bankroll already captures all net flows)
|
||||
if stats.bankroll != 0 {
|
||||
stats.total_profit = stats.bankroll;
|
||||
} else {
|
||||
// Fallback for non-bankroll mode
|
||||
stats.total_profit = final_stack as i64 - starting as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In [`run_cash_game()`](holdem_core/src/game/simulation.rs:87), collect bankrolls alongside final stacks:
|
||||
|
||||
```rust
|
||||
let bankrolls: HashMap<PlayerId, i64> = table
|
||||
.seats()
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
slot.as_ref().map(|seat| (seat.player_id, seat.bankroll))
|
||||
})
|
||||
.collect();
|
||||
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls);
|
||||
```
|
||||
|
||||
### 2.5 Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Player wins big, stack > starting_chips, reset mode | bankroll increases by the surplus, stack reset to starting_chips |
|
||||
| Player busts, reset mode | Re-activated with starting_chips, bankroll decreases by starting_chips |
|
||||
| Refill threshold = 0 | No refills (disabled) |
|
||||
| Refill threshold >= starting_chips | Refill every hand (unusual but valid) |
|
||||
| Both reset and refill enabled | Reset takes precedence (refill is skipped) |
|
||||
| Tournament mode | Bankroll management is **cash-game only** — ignored for tournaments |
|
||||
| Bankroll goes very negative | `i64` provides ~9 quintillion range — no practical overflow concern |
|
||||
|
||||
### 2.6 TOML Config Example
|
||||
|
||||
```toml
|
||||
# Example: Cash game with bankroll reset (tournament-style tracking)
|
||||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 6
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 10000
|
||||
num_hands = 1000
|
||||
reset_bankroll_each_hand = true
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
```
|
||||
|
||||
```toml
|
||||
# Example: Cash game with auto-refill
|
||||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 6
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 10000
|
||||
num_hands = 1000
|
||||
bankroll_refill_threshold = 1000 # Refill when stack <= 1000
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Randomized Seats
|
||||
|
||||
### 3.1 Config Changes
|
||||
|
||||
Add one field to [`GameConfig`](holdem_core/src/game/config.rs:178):
|
||||
|
||||
```rust
|
||||
/// If true, player seat assignments are randomized before each hand
|
||||
/// (cash game) or before tournament start.
|
||||
#[serde(default)]
|
||||
pub randomize_seats: bool,
|
||||
```
|
||||
|
||||
**Default**: `false`. Uses `#[serde(default)]` for backward compatibility.
|
||||
|
||||
### 3.2 Data Model Changes
|
||||
|
||||
Add to [`Table`](holdem_core/src/game/table.rs:64):
|
||||
|
||||
```rust
|
||||
pub struct Table {
|
||||
// ... existing fields ...
|
||||
|
||||
/// If true, shuffle seat assignments before each hand.
|
||||
randomize_seats: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Add to [`TableConfig`](holdem_core/src/game/table.rs:29):
|
||||
|
||||
```rust
|
||||
pub struct TableConfig {
|
||||
// ... existing fields ...
|
||||
pub randomize_seats: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Behavior Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Table::start_hand] --> B{randomize_seats?}
|
||||
B -- Yes --> C[Collect all seated players into a Vec]
|
||||
C --> D[Shuffle the Vec using seeded RNG]
|
||||
D --> E[Clear all seats]
|
||||
E --> F[Re-seat players in shuffled order to positions 0..N]
|
||||
B -- No --> G[Proceed with existing seat assignments]
|
||||
F --> G
|
||||
G --> H[Advance button and continue hand start]
|
||||
```
|
||||
|
||||
**Hook point**: In [`Table::start_hand()`](holdem_core/src/game/table.rs:288), insert randomization **before** step 3 (advance button) and **after** bankroll management. The new step 2.7:
|
||||
|
||||
```rust
|
||||
// In Table::start_hand():
|
||||
|
||||
// 2.7. Randomize seats if configured.
|
||||
if self.randomize_seats {
|
||||
self.shuffle_seats()?;
|
||||
}
|
||||
```
|
||||
|
||||
New method on `Table`:
|
||||
|
||||
```rust
|
||||
impl Table {
|
||||
/// Randomize seat assignments by collecting all seated players,
|
||||
/// shuffling them, and re-seating at positions 0..N.
|
||||
fn shuffle_seats(&mut self) -> Result<(), GameError> {
|
||||
// 1. Collect all occupied seats.
|
||||
let mut occupied: Vec<Seat> = self
|
||||
.seats
|
||||
.iter_mut()
|
||||
.filter_map(|slot| slot.take())
|
||||
.collect();
|
||||
|
||||
if occupied.len() < 2 {
|
||||
// Put them back.
|
||||
for (i, seat) in occupied.into_iter().enumerate() {
|
||||
self.seats[i] = Some(seat);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Shuffle using seeded RNG for reproducibility.
|
||||
// Use a seed derived from the table seed + hand number.
|
||||
let rng_seed = self.seed
|
||||
.map(|s| s.wrapping_add(self.hand_number))
|
||||
.unwrap_or_else(|| {
|
||||
// Use a simple hash of hand_number as fallback.
|
||||
self.hand_number
|
||||
});
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(rng_seed);
|
||||
use rand::seq::SliceRandom;
|
||||
occupied.shuffle(&mut rng);
|
||||
|
||||
// 3. Clear all seats (already empty from take).
|
||||
// 4. Re-seat at positions 0, 1, 2, ...
|
||||
for (i, mut seat) in occupied.into_iter().enumerate() {
|
||||
seat.index = i as SeatIndex;
|
||||
self.seats[i] = Some(seat);
|
||||
}
|
||||
|
||||
// 5. Reset button to 0 since seat assignments changed.
|
||||
self.button = 0;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For tournaments**: Randomization happens once at tournament start, not per hand. The tournament code in [`tournament.rs`](holdem_core/src/game/tournament.rs) would call a `Table::shuffle_seats()` method during initialization. This is a separate call site but uses the same method.
|
||||
|
||||
### 3.4 Stats Impact
|
||||
|
||||
No new stats fields needed. Seat randomization is transparent to stats tracking, which uses `PlayerId` (not seat index) as the key.
|
||||
|
||||
### 3.5 Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Only 1 player seated | No shuffle (not enough players) |
|
||||
| No seed configured | Use hand_number as a fallback seed |
|
||||
| Seed configured | Use `seed + hand_number` for per-hand variation |
|
||||
| During a hand | No-op (randomization only happens between hands) |
|
||||
| Tournament | Shuffle once at start, not per hand |
|
||||
| Button position | Reset to 0 after shuffle to avoid invalid button references |
|
||||
|
||||
### 3.6 TOML Config Example
|
||||
|
||||
```toml
|
||||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 6
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 10000
|
||||
num_hands = 1000
|
||||
randomize_seats = true
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Feature Interactions
|
||||
|
||||
### Rake + Bankroll Reset
|
||||
|
||||
When both are enabled:
|
||||
1. Rake is deducted from pot winnings during the hand (inside `HandRunner`)
|
||||
2. Bankroll reset happens between hands (inside `Table`)
|
||||
3. The `chip_movements` in `HandResult` already reflect rake deductions
|
||||
4. Bankroll tracking picks up the post-rake stack values
|
||||
|
||||
The interaction is clean because rake operates **within** a hand and bankroll operates **between** hands.
|
||||
|
||||
### Rake + Stats
|
||||
|
||||
With rake enabled, `total_profit` across all players will sum to `-(total_rake as i64)` instead of 0. This is correct — the rake is money leaving the system.
|
||||
|
||||
### Bankroll Reset + Randomized Seats
|
||||
|
||||
Both operate between hands in `Table::start_hand()`. The execution order is:
|
||||
|
||||
1. Bankroll management (reset/refill) — step 2.5
|
||||
2. Seat randomization — step 2.7
|
||||
3. Advance button — step 3
|
||||
|
||||
This order ensures bankroll adjustments happen before seats are shuffled.
|
||||
|
||||
### All Three Together
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Table::start_hand] --> B[Validate: enough players?]
|
||||
B -- No --> ERR[Return NotEnoughPlayers]
|
||||
B -- Yes --> C{reset_bankroll?}
|
||||
C -- Yes --> D[Reset all stacks to starting_chips]
|
||||
C -- No --> E{refill_threshold > 0?}
|
||||
E -- Yes --> F[Refill short stacks]
|
||||
E -- No --> G[Skip]
|
||||
D --> H{randomize_seats?}
|
||||
F --> H
|
||||
G --> H
|
||||
H -- Yes --> I[Shuffle seat assignments]
|
||||
H -- No --> J[Skip]
|
||||
I --> K[Advance button]
|
||||
J --> K
|
||||
K --> L[Collect active seats]
|
||||
L --> M[Create HandRunner with RakeConfig]
|
||||
M --> N[Start hand: post blinds, deal]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Todo List
|
||||
|
||||
The following is the recommended implementation order, broken into independent units:
|
||||
|
||||
### Phase 1: Rake
|
||||
1. Add `RakeConfig` struct to `pot.rs` (or a new `rake.rs` module)
|
||||
2. Add `rake_percentage` and `rake_cap` fields to `GameConfig` with `#[serde(default)]`
|
||||
3. Add `RakeConfig::from_game_config()` conversion
|
||||
4. Add `rake_config: RakeConfig` to `TableConfig` and `Table`
|
||||
5. Add `rake_config: RakeConfig` to `HandRunner`
|
||||
6. Implement `HandRunner::apply_rake()` helper method
|
||||
7. Update `HandRunner::showdown()` to call `apply_rake()`
|
||||
8. Update `HandRunner::resolve_last_player_wins()` to call `apply_rake()`
|
||||
9. Add `total_rake` and `rake_per_pot` to `HandResult`
|
||||
10. Add `total_rake` to `SimulationStats` and update `record_hand_result()`
|
||||
11. Update `GameConfig::to_table_config()` to pass rake config
|
||||
12. Add unit tests for `RakeConfig::calculate_rake()`
|
||||
13. Add integration tests for raked hands
|
||||
|
||||
### Phase 2: Bankroll Management
|
||||
1. Add `bankroll: i64` field to `Seat` (default 0)
|
||||
2. Add `reset_bankroll_each_hand` and `bankroll_refill_threshold` to `GameConfig`
|
||||
3. Add these fields to `TableConfig` and `Table`
|
||||
4. Implement `Table::reset_stacks_to_starting()`
|
||||
5. Implement `Table::refill_short_stacks()`
|
||||
6. Update `Table::start_hand()` to call bankroll management
|
||||
7. Add `bankroll: i64` to `PlayerInfo` and update `Seat::to_player_info()`
|
||||
8. Add `reset_bankroll_each_hand: bool` to `GameInfo`
|
||||
9. Update `SimulationStats::record_final_stacks()` to accept bankrolls
|
||||
10. Update `run_cash_game()` to collect bankrolls
|
||||
11. Add `bankroll: i64` to `PlayerStats`
|
||||
12. Add tests for reset mode, refill mode, and edge cases
|
||||
|
||||
### Phase 3: Randomized Seats
|
||||
1. Add `randomize_seats: bool` to `GameConfig`, `TableConfig`, and `Table`
|
||||
2. Implement `Table::shuffle_seats()` using seeded RNG
|
||||
3. Update `Table::start_hand()` to call shuffle when enabled
|
||||
4. Ensure `rand` crate is available as a dependency
|
||||
5. Add tests for seat randomization with and without seed
|
||||
6. Add tournament integration (shuffle at start)
|
||||
|
||||
### Phase 4: Cross-Cutting
|
||||
1. Update existing TOML config examples with commented-out new fields
|
||||
2. Update `GameConfig::default()` to include new fields
|
||||
3. Update `SimulationStats::summary()` to display rake and bankroll info
|
||||
4. Verify backward compatibility: run existing tests unchanged
|
||||
5. Update `docs/` README with new config options
|
||||
|
||||
---
|
||||
|
||||
## Summary of All Struct Changes
|
||||
|
||||
| Struct | File | New Fields |
|
||||
|--------|------|------------|
|
||||
| `GameConfig` | `config.rs` | `rake_percentage: f64`, `rake_cap: ChipCount`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `randomize_seats: bool` |
|
||||
| `TableConfig` | `table.rs` | `rake_config: RakeConfig`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `starting_chips: ChipCount`, `randomize_seats: bool` |
|
||||
| `Table` | `table.rs` | `rake_config: RakeConfig`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `starting_chips: ChipCount`, `randomize_seats: bool` |
|
||||
| `HandRunner` | `hand_runner.rs` | `rake_config: RakeConfig` |
|
||||
| `HandResult` | `hand_runner.rs` | `total_rake: ChipCount`, `rake_per_pot: Vec<ChipCount>` |
|
||||
| `Seat` | `player.rs` | `bankroll: i64` |
|
||||
| `PlayerInfo` | `player_info.rs` | `bankroll: i64` |
|
||||
| `GameInfo` | `game_info.rs` | `reset_bankroll_each_hand: bool` |
|
||||
| `PlayerStats` | `stats.rs` | `total_rake_paid: ChipCount`, `bankroll: i64` |
|
||||
| `SimulationStats` | `stats.rs` | `total_rake: ChipCount` |
|
||||
| `RakeConfig` | NEW | `percentage: f64`, `cap: ChipCount` |
|
||||
362
docs/ARCHITECTURE_SUPERSONIC_EVALUATOR.md
Normal file
362
docs/ARCHITECTURE_SUPERSONIC_EVALUATOR.md
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
# Supersonic LUT Evaluator — Architecture Plan
|
||||
|
||||
## Overview
|
||||
|
||||
The **Supersonic evaluator** is a port of a Java "Supersonic" poker hand evaluator that uses a pre-computed trie structure to enable 7-card hand evaluation in **exactly 7 array lookups** with no runtime enumeration. This replaces the current Cactus Kev + HashMap approach with a pure array-based lookup that trades memory (~124 MB) for deterministic O(7) performance.
|
||||
|
||||
### Two-Phase Design
|
||||
|
||||
1. **Offline Table Generator** — A one-time process that builds the `hand_ranks` trie by enumerating all possible card combinations and ranking them via the existing Cactus Kev 5-card evaluator. The result is serialized to disk.
|
||||
|
||||
2. **Runtime Lookup Evaluator** — Loads the pre-computed trie from disk and evaluates any 5/6/7-card hand with a simple chain of array lookups.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Offline [Offline: Table Generator]
|
||||
CK[Cactus Kev 5-card Evaluator]
|
||||
GEN[StateTableGenerator]
|
||||
DISK[HandRanks.dat - 124 MB on disk]
|
||||
|
||||
GEN -->|uses| CK
|
||||
GEN -->|writes| DISK
|
||||
end
|
||||
|
||||
subgraph Runtime [Runtime: Supersonic Evaluator]
|
||||
LOAD[Load HandRanks.dat]
|
||||
LUT[hand_ranks: Vec u32 - 32.5M entries]
|
||||
EVAL[evaluate_7_card]
|
||||
|
||||
LOAD --> LUT
|
||||
EVAL -->|7 array lookups| LUT
|
||||
end
|
||||
|
||||
subgraph Fallback [Fallback: Cactus Kev + Cache]
|
||||
CK2[CactusKevEvaluator]
|
||||
CACHE[HashMap u64 to u32 cache]
|
||||
ENUM[C of 7,5 = 21 enumeration]
|
||||
|
||||
ENUM -->|uses| CK2
|
||||
ENUM -->|populates| CACHE
|
||||
end
|
||||
|
||||
DISK -->|loaded by| LOAD
|
||||
|
||||
style Offline fill:#e8f5e9
|
||||
style Runtime fill:#e3f2fd
|
||||
style Fallback fill:#fff3e0
|
||||
```
|
||||
|
||||
### Evaluation Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CARDS[7 cards] --> ENCODE[Encode: rank * 4 + suit]
|
||||
ENCODE --> LOOP[Chain lookup loop]
|
||||
|
||||
LOOP --> P1[p = hand_ranks 53 + card0 + 1]
|
||||
P1 --> P2[p = hand_ranks p + card1 + 1]
|
||||
P2 --> P3[p = hand_ranks p + card2 + 1]
|
||||
P3 --> P4[p = hand_ranks p + card3 + 1]
|
||||
P4 --> P5[p = hand_ranks p + card4 + 1]
|
||||
P5 --> P6[p = hand_ranks p + card5 + 1]
|
||||
P6 --> P7[p = hand_ranks p + card6 + 1]
|
||||
P7 --> RESULT[p = final hand rank]
|
||||
|
||||
style LOOP fill:#fce4ec
|
||||
style RESULT fill:#c8e6c9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Structures
|
||||
|
||||
### `hand_ranks: Vec<u32>` — The Main Trie
|
||||
|
||||
- **Size**: 32,487,834 entries (~124 MB in memory)
|
||||
- **Layout**: A flat array where each entry is either:
|
||||
- A **pointer** (index into the same array) for intermediate nodes
|
||||
- A **hand rank value** for leaf nodes (7-card terminal)
|
||||
- A **pointer to a 5-card sub-table** for 5/6-card evaluation
|
||||
|
||||
### Card Encoding
|
||||
|
||||
Cards are encoded as `rank * 4 + suit`, yielding indices 0–51. This is compatible with the existing [`PackedCard`](holdem_core/src/card.rs) representation where:
|
||||
- `rank()` returns 0–12 (Two through Ace)
|
||||
- `suit()` returns 0–3 (clubs, diamonds, hearts, spades)
|
||||
|
||||
The conversion function already exists in [`tables.rs`](holdem_core/src/evaluator/tables.rs:181):
|
||||
```rust
|
||||
fn packed_card_to_index(pc: PackedCard) -> usize {
|
||||
pc.rank() as usize * 4 + pc.suit() as usize
|
||||
}
|
||||
```
|
||||
|
||||
### Hand Rank Encoding
|
||||
|
||||
Each hand rank value is packed as a 16-bit integer:
|
||||
```
|
||||
hand_rank = (category << 12) | sub_rank
|
||||
```
|
||||
|
||||
Where:
|
||||
- `category` (bits 12–15): 0 = high card, 1 = pair, ..., 9 = royal flush
|
||||
- `sub_rank` (bits 0–11): relative rank within the category
|
||||
|
||||
This must be mapped to the existing [`HandValue`](holdem_core/src/evaluator/evaluator.rs:108) struct which uses `(rank << 20) | value` packing.
|
||||
|
||||
---
|
||||
|
||||
## Table Generation
|
||||
|
||||
### Algorithm Overview
|
||||
|
||||
The generator builds the trie in two passes:
|
||||
|
||||
#### Pass 1: Generate and Sort Keys
|
||||
|
||||
For each hand size (5, 6, 7 cards), generate all unique rank combinations (ignoring suit), normalize them by sorting, and create a sorted list of unique keys. This uses:
|
||||
|
||||
- **Suit stripping**: Remove suit information to get rank-only combinations
|
||||
- **Sorting**: Bose-Nelson sorting network for deterministic ordering of ranks within each hand
|
||||
- **Deduplication**: Only store unique rank combinations
|
||||
|
||||
#### Pass 2: Populate Hand Ranks
|
||||
|
||||
Walk the sorted keys and populate the `hand_ranks` array:
|
||||
|
||||
1. Start at index 53 (indices 1–52 are reserved for card offsets from root)
|
||||
2. For each unique rank combination, allocate trie nodes
|
||||
3. At leaf nodes, evaluate the corresponding 5-card hand using the existing [`CactusKevEvaluator`](holdem_core/src/evaluator/cactus_kev.rs) and store the hand rank
|
||||
4. For 6-card and 7-card nodes, propagate the best 5-card sub-hand rank upward
|
||||
|
||||
### Key Normalization
|
||||
|
||||
The Bose-Nelson sorting network provides a branch-free, deterministic way to sort up to 7 elements. This ensures that the same set of ranks always produces the same trie path regardless of input order.
|
||||
|
||||
### Dependency on Cactus Kev
|
||||
|
||||
The generator uses the existing [`CactusKevEvaluator`](holdem_core/src/evaluator/cactus_kev.rs) to evaluate 5-card hands during table population. This means:
|
||||
- The Cactus Kev module remains a build-time dependency
|
||||
- It does **not** need to be loaded at runtime when using the Supersonic evaluator
|
||||
- The [`plans/cactus-kev-evaluator.md`](plans/cactus-kev-evaluator.md) plan remains relevant
|
||||
|
||||
### Serialization
|
||||
|
||||
The `hand_ranks` array is serialized to disk as a flat binary file:
|
||||
- **Format**: Raw `u32` little-endian array
|
||||
- **Size**: 32,487,834 × 4 bytes ≈ 124 MB
|
||||
- **Checksum**: CRC32 or SHA-256 for integrity verification
|
||||
- **Location**: Configurable, default alongside the existing jump table file
|
||||
|
||||
---
|
||||
|
||||
## Runtime Evaluation
|
||||
|
||||
### Chain Lookup Algorithm
|
||||
|
||||
```rust
|
||||
fn evaluate_7_card(&self, cards: &[PackedCard; 7]) -> HandValue {
|
||||
let mut p: usize = 53; // root node offset
|
||||
for card in cards {
|
||||
let idx = packed_card_to_index(*card);
|
||||
p = self.hand_ranks[p + idx + 1] as usize;
|
||||
}
|
||||
// p is now the final hand rank value
|
||||
decode_hand_rank(p as u32)
|
||||
}
|
||||
```
|
||||
|
||||
### Evaluation by Hand Size
|
||||
|
||||
| Hand Size | Lookups | Final Step |
|
||||
|-----------|---------|------------|
|
||||
| 5 cards | 5 chain lookups | `hand_ranks[p]` — extra dereference |
|
||||
| 6 cards | 6 chain lookups | `hand_ranks[p]` — extra dereference |
|
||||
| 7 cards | 7 chain lookups | `p` is the final value — no extra step |
|
||||
|
||||
The extra dereference for 5/6-card hands exists because the trie stores intermediate pointers at depth 5 and 6, and the actual hand rank is one more lookup away.
|
||||
|
||||
### Hand Rank Decoding
|
||||
|
||||
The raw `u32` from the trie must be decoded into the existing [`HandValue`](holdem_core/src/evaluator/evaluator.rs:108) format:
|
||||
|
||||
```rust
|
||||
fn decode_hand_rank(raw: u32) -> HandValue {
|
||||
let category = (raw >> 12) as u8; // top 4 bits
|
||||
let sub_rank = raw & 0xFFF; // bottom 12 bits
|
||||
let rank = HandRank::from_u8(category).unwrap_or(HandRank::HighCard);
|
||||
HandValue::new(rank, sub_rank)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Plan
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `holdem_core/src/evaluator/supersonic.rs` | Runtime Supersonic evaluator |
|
||||
| `holdem_core/src/evaluator/supersonic_generator.rs` | Offline table generator (behind `feature = "generator"` flag) |
|
||||
|
||||
### Module Changes
|
||||
|
||||
#### [`mod.rs`](holdem_core/src/evaluator/mod.rs)
|
||||
|
||||
Add new sub-modules:
|
||||
```rust
|
||||
#[cfg(feature = "generator")]
|
||||
pub mod supersonic_generator;
|
||||
pub mod supersonic;
|
||||
```
|
||||
|
||||
#### [`tables.rs`](holdem_core/src/evaluator/tables.rs) — `JumpTable`
|
||||
|
||||
Add a `supersonic` field and modify evaluation methods:
|
||||
|
||||
```rust
|
||||
pub struct JumpTable {
|
||||
// ... existing fields ...
|
||||
|
||||
/// Supersonic LUT evaluator (loaded from disk, ~124 MB).
|
||||
/// When present, used as the primary evaluation path.
|
||||
supersonic: Option<SupersonicEvaluator>,
|
||||
}
|
||||
```
|
||||
|
||||
The evaluation priority becomes:
|
||||
1. **Supersonic** (if loaded) — 7 array lookups
|
||||
2. **Cactus Kev + cache** (always available) — 21 enumeration + HashMap
|
||||
3. **Algorithmic** (fallback) — rule-based evaluation
|
||||
|
||||
### Bug Fixes Required
|
||||
|
||||
#### Fix 1: Evaluator Guard Condition
|
||||
|
||||
**Current** (broken — `data` is always empty in the Cactus Kev architecture):
|
||||
```rust
|
||||
// In evaluator.rs lines 697, 715
|
||||
if self.tables.size > 0 && !self.tables.data.is_empty() {
|
||||
```
|
||||
|
||||
**Fixed**:
|
||||
```rust
|
||||
if self.tables.is_built() {
|
||||
```
|
||||
|
||||
The `is_built()` method should check that the Cactus Kev tables are populated (which they always are after `build()`), not whether `data` is non-empty.
|
||||
|
||||
#### Fix 2: 7-Card Caching with Interior Mutability
|
||||
|
||||
**Problem**: [`evaluate_7_card`](holdem_core/src/evaluator/tables.rs:399) takes `&self` but the HashMap cache requires `&mut self` for insertion. Currently, cache misses just recompute every time.
|
||||
|
||||
**Fix**: Use `RefCell<HashMap<u64, u32>>` for interior mutability:
|
||||
```rust
|
||||
seven_card_cache: RefCell<HashMap<u64, u32>>,
|
||||
```
|
||||
|
||||
Or, since the `Evaluator` wraps `JumpTable` in `Arc`, use `RwLock` or accept that the Cactus Kev path is always uncached when the Supersonic LUT is available.
|
||||
|
||||
---
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Approach | 7-card eval | Memory | Startup | Dependencies |
|
||||
|----------|------------|--------|---------|-------------|
|
||||
| **Algorithmic** | O[C of 7,5 x eval] | 0 | Instant | None |
|
||||
| **Cactus Kev + Cache** | O[21] HashMap lookups on miss | ~50 KB tables + cache growth | ~ms | Build CK tables |
|
||||
| **Supersonic LUT** | O[7] array lookups | ~124 MB | Load from disk | Pre-generated .dat file |
|
||||
|
||||
### Detailed Comparison
|
||||
|
||||
| Metric | Algorithmic | Cactus Kev | Supersonic |
|
||||
|--------|------------|------------|------------|
|
||||
| 5-card eval | ~2 µs | ~50 ns | ~35 ns |
|
||||
| 7-card eval first | ~42 µs | ~1 µs | ~50 ns |
|
||||
| 7-card eval cached | N/A | ~50 ns | ~50 ns |
|
||||
| Memory usage | 0 | ~50 KB + growing cache | ~124 MB fixed |
|
||||
| Startup time | 0 | ~1 ms | ~100 ms disk load |
|
||||
| Deterministic | Yes | No — HashMap ordering | Yes — array indexing |
|
||||
| Cache-friendly | N/A | No — HashMap | Yes — sequential array |
|
||||
|
||||
### When to Use Which
|
||||
|
||||
- **Supersonic**: Production simulations, mass hand evaluation, any scenario where 124 MB is acceptable
|
||||
- **Cactus Kev**: Development, testing, memory-constrained environments
|
||||
- **Algorithmic**: Correctness oracle, zero-dependency fallback
|
||||
|
||||
---
|
||||
|
||||
## Phased Implementation Plan
|
||||
|
||||
### Phase 1: Fix Immediate Bugs
|
||||
|
||||
- [ ] Add `is_built()` method to [`JumpTable`](holdem_core/src/evaluator/tables.rs:118) that checks CK table population
|
||||
- [ ] Fix guard condition in [`Evaluator::evaluate_5_card`](holdem_core/src/evaluator/evaluator.rs:672), [`evaluate_6_card`](holdem_core/src/evaluator/evaluator.rs:697), [`evaluate_7_card`](holdem_core/src/evaluator/evaluator.rs:715) to use `is_built()`
|
||||
- [ ] Fix 7-card caching: wrap `seven_card_cache` in `RefCell<HashMap<u64, u32>>`
|
||||
- [ ] Verify all existing tests still pass after bug fixes
|
||||
|
||||
### Phase 2: Implement Supersonic Generator
|
||||
|
||||
- [ ] Create `supersonic_generator.rs` behind `feature = "generator"` flag
|
||||
- [ ] Implement key normalization (suit stripping, Bose-Nelson sort)
|
||||
- [ ] Implement two-pass trie population algorithm
|
||||
- [ ] Use [`CactusKevEvaluator`](holdem_core/src/evaluator/cactus_kev.rs) for 5-card hand ranking during generation
|
||||
- [ ] Implement serialization to binary file with checksum
|
||||
- [ ] Add CLI command or example to run the generator
|
||||
- [ ] Validate generated table against all C(52,7) = 133,784,560 combinations
|
||||
|
||||
### Phase 3: Implement Supersonic Runtime Evaluator
|
||||
|
||||
- [ ] Create `supersonic.rs` with `SupersonicEvaluator` struct
|
||||
- [ ] Implement file loading with checksum verification
|
||||
- [ ] Implement chain lookup for 5/6/7-card evaluation
|
||||
- [ ] Implement hand rank decoding (Supersonic format → existing `HandValue`)
|
||||
- [ ] Add fallback to Cactus Kev if LUT file is missing or corrupt
|
||||
|
||||
### Phase 4: Wire Up as Default Evaluator
|
||||
|
||||
- [ ] Add `Option<SupersonicEvaluator>` to [`JumpTable`](holdem_core/src/evaluator/tables.rs:118)
|
||||
- [ ] Modify evaluation methods to try Supersonic first, fall back to Cactus Kev
|
||||
- [ ] Add configuration option to enable/disable Supersonic loading
|
||||
- [ ] Update [`Evaluator`](holdem_core/src/evaluator/evaluator.rs:591) to attempt loading Supersonic LUT at startup
|
||||
|
||||
### Phase 5: Update Tests and Benchmarks
|
||||
|
||||
- [ ] Add correctness test: compare Supersonic results against AlgorithmicEvaluator for all hand types
|
||||
- [ ] Add property test: Supersonic agrees with Cactus Kev for random hands
|
||||
- [ ] Add benchmark: Supersonic vs Cactus Kev vs Algorithmic throughput
|
||||
- [ ] Update [`benchmarks.rs`](holdem_core/src/evaluator/benchmarks.rs) with Supersonic benchmark suite
|
||||
- [ ] Update [`ACHIEVEMENTS.md`](docs/ACHIEVEMENTS.md) when complete
|
||||
|
||||
---
|
||||
|
||||
## File Impact Summary
|
||||
|
||||
| File | Action | Phase |
|
||||
|------|--------|-------|
|
||||
| `evaluator/tables.rs` | Add `is_built()`, fix guard, add `Option<SupersonicEvaluator>` | 1, 4 |
|
||||
| `evaluator/evaluator.rs` | Fix guard conditions | 1 |
|
||||
| `evaluator/supersonic.rs` | **Create** — runtime evaluator | 3 |
|
||||
| `evaluator/supersonic_generator.rs` | **Create** — offline generator | 2 |
|
||||
| `evaluator/mod.rs` | Add new sub-modules | 3 |
|
||||
| `Cargo.toml` | Add `generator` feature flag | 2 |
|
||||
| `evaluator/benchmarks.rs` | Add Supersonic benchmarks | 5 |
|
||||
| `evaluator/property_tests.rs` | Add Supersonic property tests | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| 124 MB memory footprint | High for embedded/constrained environments | Make Supersonic optional; Cactus Kev remains default |
|
||||
| Generator produces incorrect table | Silent correctness bugs | Validate against all 133M 7-card combinations |
|
||||
| Binary file format portability | Cross-platform issues | Use explicit little-endian encoding + version header |
|
||||
| Startup latency for disk load | ~100ms delay on cold start | Load asynchronously; use Cactus Kev until ready |
|
||||
| `RefCell` panic in multithreaded context | Runtime crash | Use `RwLock` instead, or make cache per-thread |
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
# Tournament Rake + Prize Percentage Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture for adding a **tournament rake** (house share) to the prize distribution system. The tournament rake is a percentage of total entry fees withheld by the house before the remaining pool is distributed to players according to the `prizes` percentage schedule.
|
||||
|
||||
### Key Concept
|
||||
|
||||
```
|
||||
total_entry_fees = buy_in × num_players (× num_tables for MTT)
|
||||
prize_pool = total_entry_fees × (100 - tournament_rake_percentage) / 100
|
||||
house_rake = total_entry_fees - prize_pool
|
||||
payout_N = prize_pool × prize_percentage_N / 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
The uncommitted changes on `master` already converted [`prizes`](holdem_core/src/game/config.rs:385) from absolute chip amounts to **percentages of the prize pool**. The current [`to_prize_structure()`](holdem_core/src/game/config.rs:535) computes:
|
||||
|
||||
```rust
|
||||
prize_pool = buy_in * num_players // or buy_in * num_players * num_tables for MTT
|
||||
prizes = percentages.map(|pct| (prize_pool * pct / 100.0).round())
|
||||
```
|
||||
|
||||
There is **no** tournament rake concept. The entire entry fee pool is redistributed to players.
|
||||
|
||||
The existing [`rake_percentage`](holdem_core/src/game/config.rs:338) and [`RakeConfig`](holdem_core/src/game/config.rs:51) are for **cash game hand rake** — a percentage taken from each pot at showdown. This is a completely separate mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[TOML Config] --> B[GameConfig]
|
||||
B --> C[to_prize_structure]
|
||||
C --> D[Compute total_entry_fees]
|
||||
D --> E[Apply tournament_rake_percentage]
|
||||
E --> F[prize_pool = total_entry_fees * 100 - rake / 100]
|
||||
E --> G[house_rake = total_entry_fees - prize_pool]
|
||||
F --> H[Distribute prize_pool by prizes percentages]
|
||||
H --> I[PrizeStructure with absolute chip amounts]
|
||||
G --> J[PrizeStructure.rake_amount for reporting]
|
||||
I --> K[Tournament uses prizes for payouts]
|
||||
J --> L[Stats tracking: house_rake collected]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Details
|
||||
|
||||
### 1. New `GameConfig` Field
|
||||
|
||||
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:283)
|
||||
|
||||
Add a new field to [`GameConfig`](holdem_core/src/game/config.rs:284):
|
||||
|
||||
```rust
|
||||
/// Tournament rake percentage (0–100). The house share withheld from the
|
||||
/// prize pool before distribution. For example, `10` means the house keeps
|
||||
/// 10% of total entry fees and the remaining 90% forms the prize pool.
|
||||
///
|
||||
/// This is separate from `rake_percentage` which is per-hand cash game rake.
|
||||
/// Default: 0 (no tournament rake).
|
||||
#[serde(default)]
|
||||
pub tournament_rake_percentage: u8,
|
||||
```
|
||||
|
||||
**Serde behavior:** `#[serde(default)]` means the field defaults to `0` when absent from TOML. Existing configs without this field work identically to today — full backward compatibility.
|
||||
|
||||
**Default impl:** Add `tournament_rake_percentage: 0` to the [`Default for GameConfig`](holdem_core/src/game/config.rs:397) block.
|
||||
|
||||
**Type choice `u8`:** A `u8` supports 0–100%, which is the valid range. Values above 50 will produce a validation warning. Values of exactly 100 would yield a zero prize pool, which validation should catch.
|
||||
|
||||
### 2. Updated `to_prize_structure()` Logic
|
||||
|
||||
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:535)
|
||||
|
||||
The current method at line 535 needs two changes:
|
||||
|
||||
#### a) Custom prizes path (line 536–550)
|
||||
|
||||
```rust
|
||||
if !self.prizes.is_empty() {
|
||||
let num_players = self.bots.len().max(2);
|
||||
let total_entry_fees = match self.game_type {
|
||||
GameType::MultiTableTournament => {
|
||||
self.buy_in * num_players as ChipCount * self.num_tables as ChipCount
|
||||
}
|
||||
_ => self.buy_in * num_players as ChipCount,
|
||||
};
|
||||
|
||||
// Apply tournament rake
|
||||
let rake_pct = self.tournament_rake_percentage as f64 / 100.0;
|
||||
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;
|
||||
let house_rake = total_entry_fees - prize_pool;
|
||||
|
||||
let prizes: Vec<ChipCount> = self
|
||||
.prizes
|
||||
.iter()
|
||||
.map(|&pct| (prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount)
|
||||
.collect();
|
||||
|
||||
PrizeStructure::with_rake(prizes, house_rake)
|
||||
}
|
||||
```
|
||||
|
||||
#### b) Standard prize paths (line 551–561)
|
||||
|
||||
The [`standard_sng()`](holdem_core/src/game/tournament.rs:168) and [`standard_mtt()`](holdem_core/src/game/tournament.rs:190) methods also compute `prize_pool = buy_in * num_players` internally. They need to accept the rake percentage as a parameter:
|
||||
|
||||
```rust
|
||||
PrizeStructure::standard_sng(self.buy_in, num_players, self.tournament_rake_percentage)
|
||||
PrizeStructure::standard_mtt(self.buy_in, total_entries, self.tournament_rake_percentage)
|
||||
```
|
||||
|
||||
Inside these methods, the prize pool computation becomes:
|
||||
|
||||
```rust
|
||||
let total_entry_fees = buy_in * num_players as ChipCount;
|
||||
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_percentage as f64 / 100.0)).round() as ChipCount;
|
||||
let house_rake = total_entry_fees - prize_pool;
|
||||
```
|
||||
|
||||
### 3. `PrizeStructure` Changes
|
||||
|
||||
**File:** [`holdem_core/src/game/tournament.rs`](holdem_core/src/game/tournament.rs:150)
|
||||
|
||||
Add an optional `rake_amount` field for reporting:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrizeStructure {
|
||||
/// Prize amounts ordered from 1st place to last paid position.
|
||||
pub prizes: Vec<ChipCount>,
|
||||
/// House rake withheld from the prize pool (0 when no tournament rake).
|
||||
#[serde(default)]
|
||||
pub rake_amount: ChipCount,
|
||||
}
|
||||
```
|
||||
|
||||
**New constructor:**
|
||||
|
||||
```rust
|
||||
/// Creates a prize structure with rake information.
|
||||
pub fn with_rake(prizes: Vec<ChipCount>, rake_amount: ChipCount) -> Self {
|
||||
Self { prizes, rake_amount }
|
||||
}
|
||||
```
|
||||
|
||||
**Updated existing constructor:**
|
||||
|
||||
```rust
|
||||
pub fn custom(prizes: Vec<ChipCount>) -> Self {
|
||||
Self { prizes, rake_amount: 0 }
|
||||
}
|
||||
```
|
||||
|
||||
**New accessor:**
|
||||
|
||||
```rust
|
||||
/// Returns the total entry fees (prize pool + rake).
|
||||
pub fn total_entry_fees(&self) -> ChipCount {
|
||||
self.total_prize_pool() + self.rake_amount
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Validation Rules
|
||||
|
||||
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:568) — [`validate()`](holdem_core/src/game/config.rs:568) method
|
||||
|
||||
Add the following warnings:
|
||||
|
||||
| Condition | Warning Message |
|
||||
|-----------|----------------|
|
||||
| `tournament_rake_percentage > 0 && game_type == Cash` | `tournament_rake_percentage is set but game type is Cash. Tournament rake only applies to SNG and MTT formats.` |
|
||||
| `tournament_rake_percentage > 50` | `tournament_rake_percentage is {value}%, which exceeds 50%. This is unusually high and may be a misconfiguration.` |
|
||||
| `!prizes.is_empty() && prizes.iter().sum::<u64>() != 100` | `Custom prize percentages sum to {sum}%, not 100%. Prize distribution may not match expectations.` |
|
||||
| `tournament_rake_percentage == 100` | `tournament_rake_percentage is 100%, meaning the entire prize pool goes to the house. No prizes will be paid.` |
|
||||
|
||||
**Prize sum tolerance:** Due to integer percentages, allow a ±1% tolerance (99–101%) for the sum check. A strict `!= 100` check would be too rigid.
|
||||
|
||||
```rust
|
||||
let prize_sum: u64 = self.prizes.iter().map(|&p| p as u64).sum();
|
||||
if !(99..=101).contains(&prize_sum) {
|
||||
warnings.push(format!(
|
||||
"Custom prize percentages sum to {}%, not 100%. ...",
|
||||
prize_sum
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Stats Reporting Impact
|
||||
|
||||
**File:** [`holdem_core/src/game/stats.rs`](holdem_core/src/game/stats.rs:100)
|
||||
|
||||
#### a) `SimulationStats`
|
||||
|
||||
The existing [`total_rake`](holdem_core/src/game/stats.rs:109) field tracks **cash game hand rake**. Add a separate field for tournament rake:
|
||||
|
||||
```rust
|
||||
pub struct SimulationStats {
|
||||
// ... existing fields ...
|
||||
pub total_rake: ChipCount,
|
||||
/// Total tournament rake collected across all tournaments in the series.
|
||||
#[serde(default)]
|
||||
pub total_tournament_rake: ChipCount,
|
||||
}
|
||||
```
|
||||
|
||||
#### b) `SngPlayerSeriesResult`
|
||||
|
||||
The existing [`record_finish()`](holdem_core/src/game/stats.rs:478) method tracks `total_entry_fees` as `buy_in × tournaments_played`. With tournament rake, the player still pays the full `buy_in` as entry fee — the rake is just not returned via prizes. So the existing ROI computation remains correct:
|
||||
|
||||
```
|
||||
ROI = (total_prize_money - total_entry_fees) / total_entry_fees × 100
|
||||
```
|
||||
|
||||
A player who never cashes in a 10% rake tournament will have a -100% ROI regardless of the rake, because their `total_prize_money = 0` and `total_entry_fees = buy_in × N`.
|
||||
|
||||
However, for **reporting purposes**, the rake information should be available. Consider adding to `SngSeriesResult`:
|
||||
|
||||
```rust
|
||||
/// Total rake collected by the house across all tournaments in this series.
|
||||
pub total_house_rake: ChipCount,
|
||||
```
|
||||
|
||||
#### c) Where to accumulate tournament rake
|
||||
|
||||
The rake is accumulated in the simulation loop when tournaments complete. The call site is in [`simulation.rs`](holdem_core/src/game/simulation.rs:225) where `to_prize_structure()` is called. After each tournament, the `prize_structure.rake_amount` should be added to the running `total_tournament_rake`.
|
||||
|
||||
---
|
||||
|
||||
## Example TOML Configs
|
||||
|
||||
### SNG with 10% Tournament Rake
|
||||
|
||||
```toml
|
||||
[game]
|
||||
game_type = "SitAndGo"
|
||||
num_seats = 10
|
||||
starting_chips = 1500
|
||||
buy_in = 11
|
||||
randomize_seats = true
|
||||
num_tournaments = 100
|
||||
|
||||
# House keeps 10% of entry fees
|
||||
tournament_rake_percentage = 10
|
||||
|
||||
# Prize distribution of the remaining 90% pool: 1st=50%, 2nd=30%, 3rd=20%
|
||||
prizes = [50, 30, 20]
|
||||
|
||||
# With 10 players: total_entry_fees = 110, house_rake = 11, prize_pool = 99
|
||||
# 1st place: 49.5 → 50 chips, 2nd: 29.7 → 30 chips, 3rd: 19.8 → 20 chips
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
ante = 0
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 15
|
||||
big_blind = 30
|
||||
ante = 0
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 25
|
||||
big_blind = 50
|
||||
ante = 5
|
||||
hands = 10
|
||||
|
||||
# ... more blind levels ...
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "flock_bot"
|
||||
name = "FlockBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "chump_bot"
|
||||
name = "ChumpBot-1"
|
||||
|
||||
# ... more bots ...
|
||||
```
|
||||
|
||||
### MTT with 5% Tournament Rake
|
||||
|
||||
```toml
|
||||
[game]
|
||||
game_type = "MultiTableTournament"
|
||||
num_seats = 10
|
||||
num_tables = 10
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 1500
|
||||
buy_in = 5
|
||||
hands_per_level = 10
|
||||
num_blind_levels = 20
|
||||
randomize_seats = true
|
||||
num_tournaments = 100
|
||||
|
||||
# House keeps 5% of entry fees
|
||||
tournament_rake_percentage = 5
|
||||
|
||||
# Prize distribution of the remaining 95% pool
|
||||
# With 100 players: total_entry_fees = 500, house_rake = 25, prize_pool = 475
|
||||
prizes = [40, 20, 10, 6, 4, 2, 2, 2, 2, 2]
|
||||
# Sum = 94% — validation will warn, but it still works
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
# ... blind levels and bots ...
|
||||
```
|
||||
|
||||
### Cash Game — Tournament Rake Not Applicable
|
||||
|
||||
```toml
|
||||
[game]
|
||||
game_type = "Cash"
|
||||
num_seats = 10
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 1500
|
||||
num_hands = 10000
|
||||
|
||||
# Cash game hand rake (existing feature)
|
||||
rake_percentage = 0.05
|
||||
rake_cap = 50
|
||||
|
||||
# tournament_rake_percentage would trigger a validation warning if set
|
||||
# It has no effect on cash games
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
# ... bots ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
These are the steps to implement this design, in order:
|
||||
|
||||
1. **Add `tournament_rake_percentage: u8` to [`GameConfig`](holdem_core/src/game/config.rs:284)**
|
||||
- Add `#[serde(default)]` field
|
||||
- Add `tournament_rake_percentage: 0` to [`Default`](holdem_core/src/game/config.rs:397) impl
|
||||
- Update doc comment on [`prizes`](holdem_core/src/game/config.rs:381) to mention rake interaction
|
||||
|
||||
2. **Add `rake_amount: ChipCount` to [`PrizeStructure`](holdem_core/src/game/tournament.rs:151)**
|
||||
- Add `#[serde(default)]` field
|
||||
- Update [`custom()`](holdem_core/src/game/tournament.rs:160) to set `rake_amount: 0`
|
||||
- Add `with_rake()` constructor
|
||||
- Add `total_entry_fees()` accessor
|
||||
- Update [`standard_sng()`](holdem_core/src/game/tournament.rs:168) to accept `rake_percentage: u8`
|
||||
- Update [`standard_mtt()`](holdem_core/src/game/tournament.rs:190) to accept `rake_percentage: u8`
|
||||
|
||||
3. **Update [`to_prize_structure()`](holdem_core/src/game/config.rs:535)**
|
||||
- Apply rake deduction to `prize_pool` in the custom prizes path
|
||||
- Pass `tournament_rake_percentage` to `standard_sng()` / `standard_mtt()`
|
||||
- Return `PrizeStructure::with_rake(prizes, house_rake)` when rake > 0
|
||||
|
||||
4. **Update [`validate()`](holdem_core/src/game/config.rs:568)**
|
||||
- Warn when `tournament_rake_percentage > 0` for Cash games
|
||||
- Warn when `tournament_rake_percentage > 50`
|
||||
- Warn when `tournament_rake_percentage == 100`
|
||||
- Warn when prize percentages sum outside 99–101%
|
||||
|
||||
5. **Update stats tracking in [`stats.rs`](holdem_core/src/game/stats.rs)**
|
||||
- Add `total_tournament_rake: ChipCount` to [`SimulationStats`](holdem_core/src/game/stats.rs:101)
|
||||
- Add `total_house_rake: ChipCount` to `SngSeriesResult` / `MttSeriesResult`
|
||||
- Accumulate rake in the simulation loop
|
||||
|
||||
6. **Update existing tests in [`config.rs`](holdem_core/src/game/config.rs:1118)**
|
||||
- Add `tournament_rake_percentage: 0` to all existing `GameConfig` test instances
|
||||
- Add new test: `to_prize_structure_sng_with_rake()`
|
||||
- Add new test: `to_prize_structure_mtt_with_rake()`
|
||||
- Add new test: `validate_tournament_rake_warnings()`
|
||||
|
||||
7. **Update TOML example configs**
|
||||
- Update [`sng_tournament.toml`](holdem_core/examples/configs/sng_tournament.toml) to include `tournament_rake_percentage = 10`
|
||||
- Update [`mtt_10_tables.toml`](holdem_core/examples/configs/mtt_10_tables.toml) to include `tournament_rake_percentage = 5`
|
||||
- Add comments explaining the rake computation
|
||||
|
||||
---
|
||||
|
||||
## Rounding Behavior
|
||||
|
||||
The prize pool computation uses `.round()` for consistency with the existing approach:
|
||||
|
||||
```rust
|
||||
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;
|
||||
```
|
||||
|
||||
This means:
|
||||
- `110 * 0.90 = 99.0` → prize_pool = 99 ✓
|
||||
- `100 * 0.95 = 95.0` → prize_pool = 95 ✓
|
||||
- `107 * 0.90 = 96.3` → prize_pool = 96, rake = 11
|
||||
|
||||
The `house_rake` is computed as `total_entry_fees - prize_pool` (residual method) rather than independently, ensuring `prize_pool + house_rake == total_entry_fees` always holds exactly.
|
||||
|
||||
Individual prize amounts also use `.round()`:
|
||||
```rust
|
||||
(prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount
|
||||
```
|
||||
|
||||
Due to rounding, the sum of individual prizes may differ from `prize_pool` by ±1 chip per prize position. This is acceptable for simulation purposes.
|
||||
|
||||
---
|
||||
|
||||
## Relationship Between Rake Types
|
||||
|
||||
| Property | Cash Game Rake | Tournament Rake |
|
||||
|----------|---------------|-----------------|
|
||||
| Config field | [`rake_percentage`](holdem_core/src/game/config.rs:338) | `tournament_rake_percentage` (new) |
|
||||
| Type | `f64` (0.0–1.0) | `u8` (0–100) |
|
||||
| When applied | Each hand at showdown | Once at tournament start |
|
||||
| Cap | [`rake_cap`](holdem_core/src/game/config.rs:342) per pot | No cap (percentage of total) |
|
||||
| Tracked in | [`RakeConfig`](holdem_core/src/game/config.rs:51) | `PrizeStructure.rake_amount` |
|
||||
| Stats field | [`SimulationStats.total_rake`](holdem_core/src/game/stats.rs:109) | `SimulationStats.total_tournament_rake` (new) |
|
||||
| Applicable to | Cash games only | SNG and MTT only |
|
||||
|
||||
The two rake types are **completely independent**. A cash game with `rake_percentage = 0.05` takes 5% of each pot. A tournament with `tournament_rake_percentage = 10` withholds 10% of entry fees from the prize pool. They never interact.
|
||||
68
docs/REFERENCES.md
Normal file
68
docs/REFERENCES.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Reference Implementations
|
||||
|
||||
## Java Supersonic Evaluator
|
||||
|
||||
The Rust Supersonic evaluator is a port of a Java implementation that uses a pre-computed trie for O(7) hand evaluation.
|
||||
|
||||
### Source Location
|
||||
|
||||
- **Path**: `/home/jan/Development/poker/old_bot/SNG-Testbed/src/pokerai/pokerbots/common/supersonic/`
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `LookupHandEvaluator.java` | Runtime evaluator — singleton that loads `HandRanks.dat` and provides 5/6/7-card evaluation via chain lookups |
|
||||
| `generator/StateTableGenerator.java` | Offline table generator — builds the 32.5M-entry `hand_ranks` trie using two-pass algorithm |
|
||||
| `generator/Flushes.java` | Flush and straight-flush ranking table — pre-computed hand rank values for suited hands |
|
||||
| `generator/Unique.java` | Straight and high-card ranking table — pre-computed hand rank values for non-flush unsuited hands |
|
||||
| `generator/Products.java` | Sorted unique 5-prime products — used during key normalization for the trie |
|
||||
| `generator/Values.java` | Hand rank values by product position — maps sorted prime products to hand rank encodings |
|
||||
| `test/CorrectnessTest.java` | Correctness validation — tests 5M random hands against a reference evaluator |
|
||||
|
||||
### Algorithm Notes
|
||||
|
||||
- The Java implementation uses `int[]` for the trie (32,487,834 entries)
|
||||
- Card encoding: `rank * 4 + suit` (same as Rust's `PackedCard` index)
|
||||
- Hand rank encoding: `(category << 12) | sub_rank` (16-bit packed)
|
||||
- Root node starts at index 53 (indices 1–52 reserved for initial card offsets)
|
||||
- The generator uses the Cactus Kev 5-card evaluator during table population
|
||||
|
||||
### Porting Considerations
|
||||
|
||||
- Java `int` is 32-bit signed → Rust `u32` (hand ranks are never negative)
|
||||
- Java array initialization to 0 → Rust `vec![0u32; 32_487_834]`
|
||||
- Java singleton pattern → Rust `OnceLock` or `Arc<SupersonicEvaluator>`
|
||||
- Java `ObjectOutputStream` → Rust custom binary serialization with checksum
|
||||
|
||||
---
|
||||
|
||||
## Cactus Kev Algorithm
|
||||
|
||||
The existing Rust Cactus Kev implementation in [`cactus_kev.rs`](holdem_core/src/evaluator/cactus_kev.rs) was based on the original Cactus Kev algorithm.
|
||||
|
||||
### Source References
|
||||
|
||||
- **Original**: http://suffe.coolpage.cash/poker/evaluator.html (archived)
|
||||
- **Algorithm**: Uses prime number products to create perfect hashes for 5-card hand equivalence classes
|
||||
- **Tables**: 1,287 flush entries + 6,175 unsuited entries = 7,462 total lookup entries
|
||||
|
||||
### Relationship to Supersonic
|
||||
|
||||
The Cactus Kev evaluator serves two roles in the Supersonic architecture:
|
||||
1. **Build-time dependency**: Used by the table generator to evaluate 5-card hands
|
||||
2. **Runtime fallback**: Used when the Supersonic LUT file is not available
|
||||
|
||||
---
|
||||
|
||||
## Other References
|
||||
|
||||
### Meerkat API (Java)
|
||||
|
||||
- **Purpose**: Reference API for bot interface design
|
||||
|
||||
### Algorithmic Evaluator
|
||||
|
||||
- **Location**: [`algorithmic_evaluator.rs`](holdem_core/src/evaluator/algorithmic_evaluator.rs)
|
||||
- **Purpose**: Rule-based evaluator used as correctness oracle
|
||||
- **Note**: Not ported from Java; implemented directly in Rust from poker hand ranking rules
|
||||
|
|
@ -1,63 +1,33 @@
|
|||
# Poker AI Testbed & Live Server Roadmap
|
||||
|
||||
## Step-by-Step Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
**Basic poker engine**
|
||||
- Game state management
|
||||
- Game Info and Player Info traits
|
||||
- Event reporting and action scheme
|
||||
- Basic game rules (NL/FL/PL)
|
||||
|
||||
**Configuration system**
|
||||
- TOML configuration structures
|
||||
- Bot parameter configuration
|
||||
- Game type configurations (Cash/SNG/MTT, buyins, blinds, hands per level, etc.)
|
||||
- Simulation parameters (hands/tournaments count, buyin thresholds for bots, etc.)
|
||||
|
||||
### Phase 2: Simulation Testbed ✅
|
||||
**Goal:** Mass simulation capability
|
||||
|
||||
**Testbed core** ✅
|
||||
- Tournament/cash game runners ✅
|
||||
- Result collection and analysis ✅
|
||||
- Bot interface with early folding capability ✅
|
||||
- Bot performance metrics & summary ✅
|
||||
- Simulation and game factories ✅
|
||||
- Configuration-driven experiment runner ✅
|
||||
- Seed control for reproducibility ✅
|
||||
## Phase 2: Simulation Testbed ✅
|
||||
|
||||
**Remaining for mass simulation**
|
||||
- Parallel simulation engine (multi-threading)
|
||||
- Result visualization (charts/graphs)
|
||||
- Rebuy/addon support for tournaments
|
||||
- GUI for configuration of simulations and bots
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Bot Framework
|
||||
## Phase 4: Bot Framework
|
||||
**Goal:** All bot types with playability enhancement
|
||||
|
||||
**Bot core implementations** (partially complete — basic bots exist)
|
||||
- ~~Generic helper functions for strategies~~ (basic bots done: CallBot, FoldBot, RaiseBot, RandomBot)
|
||||
- Modular Strategy per stage and game situation
|
||||
- Rule-based bot with configurable thresholds – port from Java
|
||||
- Nash equilibrium calculator for SNG endgame – port from Java
|
||||
- Statistical tracking bot – port from Java
|
||||
**Bot core implementations** (partially complete)
|
||||
- Logging of decision-making process
|
||||
|
||||
**Advanced bots**
|
||||
- Nash equilibrium calculator for SNG endgame – port from Java
|
||||
- Statistical tracking bot – port from Java
|
||||
- GPU acceleration for mass rollouts
|
||||
- Hand-reading opponent modeling (possibly with GPU integration)
|
||||
- Flop playability precomputation & score integration
|
||||
- Basic reinforcement learning skeleton
|
||||
- Hybrid bot combining multiple approaches
|
||||
- Factory for bot and sub-strategy composition
|
||||
- Genetic algorithm for bot evolution (via configuration files)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: "Unit Test" Framework
|
||||
### Phase 5: "Unit Test" Framework
|
||||
**Goal:** Bot behavior can be tested against predefined hands
|
||||
|
||||
**Hand Replayer**
|
||||
|
|
@ -68,7 +38,7 @@
|
|||
|
||||
---
|
||||
|
||||
### Phase 5: Live Server Infrastructure
|
||||
### Phase 6: Live Server Infrastructure
|
||||
**Goal:** Real-time bot deployment
|
||||
|
||||
**Server core**
|
||||
|
|
@ -87,7 +57,7 @@
|
|||
|
||||
---
|
||||
|
||||
### Phase 6: Integration & Polish
|
||||
### Phase 7: Integration & Polish
|
||||
**Goal:** Production-ready system
|
||||
|
||||
**Advanced features**
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Create a **comprehensive poker AI ecosystem** with two main components:
|
|||
|
||||
- **Configurable bots** (Rule-based, Nash EQ, Statistical, Hand-reading, Reinforcement Learning)
|
||||
- **Mass parallel simulations** with GPU acceleration (e.g., for turn and river rollouts)
|
||||
- **Memory-efficient evaluation**: 79% memory reduction enables larger-scale simulations
|
||||
- **Multi-tier hand evaluation**: Supersonic LUT trie → Cactus Kev perfect-hash → Algorithmic fallback, with DashMap-cached 7-card evaluation
|
||||
- **TOML-driven configuration** for all parameters of bots and simulations
|
||||
- **Comprehensive result analysis** and bot evolution
|
||||
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
# Meerkat API Alignment — Bug Fixes and Improvements Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After a thorough comparison of the Rust `holdem_core` implementation against the Java Meerkat API reference (`Meerkat-API`), I identified several bugs, behavioral discrepancies, and missing features. The core architecture is solid and well-structured, but there are concrete issues that need fixing for correct poker gameplay.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### BUG-1: FixedLimit raise validation uses `small_bet` for all streets
|
||||
|
||||
**File:** [`action.rs`](holdem_core/src/game/action.rs:173)
|
||||
|
||||
**Problem:** In `validate_action()`, the `FixedLimit` branch always compares `raise_increment` against `small_bet`, even on turn/river streets where `big_bet` should be used. The validator doesn't know the current street.
|
||||
|
||||
```rust
|
||||
// Current (WRONG for turn/river):
|
||||
BetLimit::FixedLimit { small_bet, big_bet: _ } => {
|
||||
if raise_increment != *small_bet { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** Pass the current `Street` to `validate_action()` so it can use the correct bet size. On preflop/flop use `small_bet`, on turn/river use `big_bet`.
|
||||
|
||||
**Impact:** Fixed-limit games on turn/river incorrectly reject valid raises or accept invalid ones.
|
||||
|
||||
---
|
||||
|
||||
### BUG-2: Pot-limit `max_raise_amount` calculation is wrong
|
||||
|
||||
**File:** [`betting.rs`](holdem_core/src/game/betting.rs:106)
|
||||
|
||||
**Problem:** The pot-limit max raise formula returns `pot + call_amount`, but the standard pot-limit formula should be:
|
||||
|
||||
```
|
||||
max_raise_total = pot + call_amount + current_bet
|
||||
```
|
||||
|
||||
Wait, let me re-examine. The trait says it returns the max raise *amount*, but the semantics are unclear. In standard pot-limit:
|
||||
- A player can raise up to: `pot_size + amount_to_call`
|
||||
- Where `pot_size` is the pot BEFORE the player's call
|
||||
- So the max *total* they can put in is: `current_bet + call_amount + pot_size`
|
||||
|
||||
The current code returns `pot + call_amount` which is ambiguous. The caller needs to know whether this is the raise increment or the total. This needs clarification and the actual pot-limit cap needs to be enforced in `HandRunner`.
|
||||
|
||||
**Fix:** Clarify the semantics of `max_raise_amount` — it should return the maximum *total* wager for the player (not just the increment). Then enforce it in `validate_action` or `apply_action`.
|
||||
|
||||
---
|
||||
|
||||
### BUG-3: FixedLimit `max_raise_amount` returns `None`
|
||||
|
||||
**File:** [`betting.rs`](holdem_core/src/game/betting.rs:112)
|
||||
|
||||
**Problem:** For `FixedLimit`, `max_raise_amount()` returns `None`, meaning "no cap." But Fixed-Limit definitely has a cap — the fixed bet size. The comment even acknowledges this confusion.
|
||||
|
||||
**Fix:** Return `Some(fixed_bet_size)` for the current street. This requires passing `street` to the method, or restructuring the trait.
|
||||
|
||||
---
|
||||
|
||||
### BUG-4: All-in that doesn't exceed current bet doesn't reopen action
|
||||
|
||||
**File:** [`hand_runner.rs`](holdem_core/src/game/hand_runner.rs:597)
|
||||
|
||||
**Problem:** When a player goes all-in for less than the current bet, the code calls `br.apply_call()` and does NOT reset `has_acted_this_round` for other players. However, consider this scenario:
|
||||
|
||||
- Player A bets 100
|
||||
- Player B raises to 200
|
||||
- Player C goes all-in for 150 (doesn't exceed 200)
|
||||
- Player D has already acted and called the 200
|
||||
|
||||
In this case, Player D doesn't need to act again, which is correct. BUT if Player C's all-in somehow changes the action flow, there could be issues. Actually, this behavior is correct for standard poker rules — an all-in that doesn't exceed the current bet does NOT reopen the betting. So this is actually fine.
|
||||
|
||||
**Revised:** This is NOT a bug. The current behavior is correct.
|
||||
|
||||
---
|
||||
|
||||
### BUG-5: `run_to_completion` may double-credit winnings
|
||||
|
||||
**File:** [`hand_runner.rs`](holdem_core/src/game/hand_runner.rs:968)
|
||||
|
||||
**Problem:** When `step()` resolves the hand via `resolve_last_player_wins()`, it calls `finalize_result()` which credits winnings to stacks and sets state to `Complete`. Then `run_to_completion()` enters the `Complete` branch and calls `distribute()` again, computing chip movements from a second distribution. However, `distribute()` only reads pot state (doesn't mutate), and `credit_winnings()` is NOT called again in the `Complete` branch. So the stacks are only credited once.
|
||||
|
||||
Wait, let me re-read more carefully...
|
||||
|
||||
In `resolve_last_player_wins()`:
|
||||
1. Calls `finalize_result()` which credits winnings, sets state to Complete
|
||||
2. Returns `Ok(HandResult)` but the result is discarded by `step()` which only returns `HandState`
|
||||
|
||||
Then in `run_to_completion()`:
|
||||
- The `Complete` branch reconstructs a `HandResult` but does NOT call `credit_winnings()` again
|
||||
- It only computes `chip_movements` for the result struct
|
||||
|
||||
So actually the stacks are only credited once (in `finalize_result`), but the `HandResult` constructed in the `Complete` branch of `run_to_completion` calls `distribute()` again, which returns a fresh result from the pot manager. This is fine because `distribute` is read-only.
|
||||
|
||||
**Revised:** This is NOT a double-credit bug. The code is correct but slightly confusing. The `Complete` branch in `run_to_completion` should ideally reuse the result from `finalize_result` rather than reconstructing it, but functionally it's correct.
|
||||
|
||||
---
|
||||
|
||||
### BUG-6: Missing `game_state_changed` callback
|
||||
|
||||
**File:** [`player.rs`](holdem_core/src/game/player.rs)
|
||||
|
||||
**Problem:** The Meerkat API has a `gameStateChanged()` callback that is called after an action event has been fully processed. The Rust implementation does not have this callback. In Meerkat, the sequence is:
|
||||
|
||||
1. `actionEvent(pos, act)` — the action is observed
|
||||
2. `gameStateChanged()` — the game info state has been updated
|
||||
|
||||
This is important because in Meerkat, `actionEvent` is called BEFORE the game state is updated, and `gameStateChanged` is called AFTER. In the Rust implementation, `action_event` is called with an already-updated `GameInfo`, which is actually better. But the `gameStateChanged` callback is missing.
|
||||
|
||||
**Fix:** This is a minor API gap. The current Rust design is arguably better (providing updated state in `action_event`), so this is low priority. Could add a `game_state_changed` method with a default no-op implementation.
|
||||
|
||||
---
|
||||
|
||||
### BUG-7: `validate_action` doesn't validate FixedLimit raise count cap
|
||||
|
||||
**File:** [`action.rs`](holdem_core/src/game/action.rs)
|
||||
|
||||
**Problem:** In Meerkat's Fixed-Limit, there's a cap of 3 raises per round (or 4 total bets). The `can_raise` method in `BettingRules` checks this, but `validate_action` doesn't check it. The validation only checks the raise amount, not the number of raises.
|
||||
|
||||
**Fix:** Pass `raises_this_round` to `validate_action` and check `bet_limit.can_raise(street, raises_this_round)` for FixedLimit.
|
||||
|
||||
---
|
||||
|
||||
## Meerkat API Alignment
|
||||
|
||||
### ALIGN-1: Add `get_amount_to_call` convenience method to `GameInfo`
|
||||
|
||||
**File:** [`game_info.rs`](holdem_core/src/game/game_info.rs)
|
||||
|
||||
**Problem:** Meerkat's `GameInfo.getAmountToCall(seat)` is used extensively by bots. The Rust `GameInfo` has `current_bet` and players have `wagered_this_round`, but there's no convenience method to compute `amount_to_call = current_bet - wagered_this_round`.
|
||||
|
||||
**Fix:** Add:
|
||||
```rust
|
||||
pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount
|
||||
pub fn is_preflop(&self) -> bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ALIGN-2: Add `check_or_fold` Action helper
|
||||
|
||||
**Problem:** Meerkat has `Action.checkOrFoldAction(toCall)` which returns CHECK if toCall is 0, FOLD otherwise. The Rust implementation doesn't have this convenience.
|
||||
|
||||
**Fix:** Add a method to `Action`:
|
||||
```rust
|
||||
pub fn check_or_fold(amount_to_call: ChipCount) -> Action
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ALIGN-3: Add `bet_action` helper
|
||||
|
||||
**Problem:** Meerkat has `Action.betAction(gi)` which creates a raise/bet of the minimum amount. The Rust implementation requires manually computing the raise total.
|
||||
|
||||
**Fix:** Add a constructor or helper that computes the correct raise total from game state.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
### TEST-1: Fixed-limit raise validation on turn/river
|
||||
|
||||
No tests verify that Fixed-Limit raises on turn/river use `big_bet` instead of `small_bet`.
|
||||
|
||||
### TEST-2: Heads-up blind positions
|
||||
|
||||
No tests verify that in heads-up, the dealer is the small blind and acts first preflop.
|
||||
|
||||
### TEST-3: Side pot edge cases
|
||||
|
||||
No tests for:
|
||||
- Three players all-in with different amounts
|
||||
- Folded player with money in the pot creating side pots
|
||||
- Split pots with odd chips
|
||||
|
||||
### TEST-4: All-in that reopens betting
|
||||
|
||||
No tests verify that a full all-in raise correctly resets `has_acted_this_round` for other players.
|
||||
|
||||
---
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. **BUG-1** — FixedLimit validation uses wrong bet size (gameplay-breaking for fixed-limit)
|
||||
2. **BUG-7** — FixedLimit raise count not validated (gameplay-breaking for fixed-limit)
|
||||
3. **BUG-2** — Pot-limit max raise calculation needs enforcement (gameplay-breaking for pot-limit)
|
||||
4. **BUG-3** — FixedLimit max_raise_amount returns None (related to BUG-2)
|
||||
5. **ALIGN-1** — Missing GameInfo convenience methods (bot compatibility)
|
||||
6. **ALIGN-2** — Missing check_or_fold helper (bot compatibility)
|
||||
7. **ALIGN-3** — Missing bet_action helper (bot compatibility)
|
||||
8. **TEST-1 through TEST-4** — Comprehensive test coverage
|
||||
9. **BUG-6** — Missing game_state_changed callback (low priority)
|
||||
Loading…
Add table
Add a link
Reference in a new issue