feat: port Java Supersonic bot framework and cash game bots to Rust
Framework (holdem_core::game::strategy): - HandAnalyzer: 15 hand analysis methods (overpair, flush draw, OESD, outs, etc.) - HandHelper: 14 board texture and hand strength methods (Kill Phil grouping, etc.) - ActionRecorder: per-street action tracking with player filtering - StrategyContext: read-only game state snapshot with position/pot odds helpers - Strategy trait + ModularStrategy: composite pattern with 3 voting modes (FirstApplicable, Democratic, Random) Cash Game Bots (holdem_bots crate — private, exclude from public mirrors): - PostFlopStrategy: shared postflop logic using hand strength approximation - BigStackBot: NL cash strategy for stack > 25 BB (preflop + postflop) - ShortStackBot: NL cash strategy for stack ≤ 25 BB (preflop + postflop) - Full preflop decision trees ported from Java Supersonic framework Repository restructuring: - Created holdem_bots crate with feature-gated dependency from holdem_sim - Added game::strategy module to holdem_core - Cleaned up 4 unused evaluator stubs - Moved plans/ to docs/, added proptest-regressions/ to .gitignore - Added ARCHITECTURE_BOT_FRAMEWORK.md documentation Tests: 58 new strategy tests + 17 bot tests (all passing)
This commit is contained in:
parent
7c79ae4966
commit
f81f0579d1
27 changed files with 4731 additions and 27 deletions
648
docs/ARCHITECTURE_BOT_FRAMEWORK.md
Normal file
648
docs/ARCHITECTURE_BOT_FRAMEWORK.md
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
# 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.
|
||||
193
docs/meerkat-alignment-fixes.md
Normal file
193
docs/meerkat-alignment-fixes.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# 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