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
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -8,3 +8,10 @@
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Proptest regressions (auto-generated)
|
||||||
|
proptest-regressions/
|
||||||
|
|
||||||
|
# Private bot strategies — exclude from public mirrors
|
||||||
|
# Uncomment the following lines when pushing to public repositories:
|
||||||
|
# holdem_bots/
|
||||||
|
|
|
||||||
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -368,6 +368,14 @@ version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "holdem_bots"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"holdem_core",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "holdem_core"
|
name = "holdem_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -392,6 +400,7 @@ name = "holdem_sim"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"holdem_bots",
|
||||||
"holdem_core",
|
"holdem_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"holdem_core",
|
"holdem_core",
|
||||||
"holdem_sim",
|
"holdem_sim",
|
||||||
|
"holdem_bots",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
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.
|
||||||
9
holdem_bots/Cargo.toml
Normal file
9
holdem_bots/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "holdem_bots"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Advanced poker bot strategies for rusty-marvin (private — not for public mirrors)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
holdem_core = { path = "../holdem_core" }
|
||||||
|
log = "0.4"
|
||||||
67
holdem_bots/src/cash/big_stack.rs
Normal file
67
holdem_bots/src/cash/big_stack.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! Big stack cash game strategy (stack > 25 BB).
|
||||||
|
//!
|
||||||
|
//! Port of Java `BigStackStrategy` which combines a preflop strategy
|
||||||
|
//! with the shared postflop strategy using a `ModularStrategy` composite.
|
||||||
|
|
||||||
|
use holdem_core::game::action::Action;
|
||||||
|
use holdem_core::game::strategy::{ModularStrategy, Strategy, StrategyContext, VotingMode};
|
||||||
|
|
||||||
|
use super::postflop::PostFlopStrategy;
|
||||||
|
use super::BigStackPreFlop;
|
||||||
|
|
||||||
|
/// Big stack cash game strategy (stack > 25 BB).
|
||||||
|
///
|
||||||
|
/// Uses a [`ModularStrategy`] with [`VotingMode::FirstApplicable`] to combine:
|
||||||
|
/// - [`BigStackPreFlop`] — handles all preflop decisions
|
||||||
|
/// - [`PostFlopStrategy`] — handles all postflop decisions
|
||||||
|
///
|
||||||
|
/// # Applicability
|
||||||
|
///
|
||||||
|
/// Only applicable when:
|
||||||
|
/// - The game is No-Limit
|
||||||
|
/// - Our stack is greater than 25 big blinds
|
||||||
|
pub struct BigStackBot {
|
||||||
|
inner: ModularStrategy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BigStackBot {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let inner = ModularStrategy::new("BigStack", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(BigStackPreFlop))
|
||||||
|
.with_sub_strategy(Box::new(PostFlopStrategy));
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BigStackBot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for BigStackBot {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Applicable for NL games with stack > 25 BB
|
||||||
|
ctx.stack_in_bb() > 25.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
self.inner.get_action(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_big_stack_bot_default() {
|
||||||
|
let bot = BigStackBot::default();
|
||||||
|
assert_eq!(bot.inner.name(), "BigStack");
|
||||||
|
assert_eq!(bot.inner.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
364
holdem_bots/src/cash/big_stack_preflop.rs
Normal file
364
holdem_bots/src/cash/big_stack_preflop.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
//! Big stack preflop strategy for NL cash games.
|
||||||
|
//!
|
||||||
|
//! Port of Java `BigStackPreFlopStrategy`. Implements a tight-aggressive
|
||||||
|
//! preflop strategy suitable for deep-stacked play (> 25 BB).
|
||||||
|
|
||||||
|
use holdem_core::game::action::Action;
|
||||||
|
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||||
|
use holdem_core::hole_cards::HoleCards;
|
||||||
|
|
||||||
|
// Rank constants (0=Two .. 12=Ace) matching our card representation
|
||||||
|
const ACE: u8 = 12;
|
||||||
|
const KING: u8 = 11;
|
||||||
|
const QUEEN: u8 = 10;
|
||||||
|
const TEN: u8 = 8;
|
||||||
|
const FIVE: u8 = 3;
|
||||||
|
|
||||||
|
/// Big stack preflop strategy.
|
||||||
|
///
|
||||||
|
/// Decision tree ported from Java `BigStackPreFlopStrategy`:
|
||||||
|
///
|
||||||
|
/// 1. **Got re-raised (i_am_aggressor):** AA/KK → all-in, pairs with deep stacks → call, else fold
|
||||||
|
/// 2. **No raise in front (num_raises ≤ 1):**
|
||||||
|
/// - AA-TT → Raise
|
||||||
|
/// - AK → Raise
|
||||||
|
/// - Pairs in late position → Call/Raise based on position
|
||||||
|
/// - AT+ first-in from late → Raise
|
||||||
|
/// - Suited aces/connectors from late → Raise/Call
|
||||||
|
/// 3. **One raise in front (num_raises == 2):** AA-QQ → Raise, pairs deep → Call
|
||||||
|
/// 4. **Multiple raises (num_raises > 2):** AA/KK → All-in
|
||||||
|
///
|
||||||
|
/// Raise sizing: `(4 + num_callers) × BB - amount_to_call`
|
||||||
|
pub struct BigStackPreFlop;
|
||||||
|
|
||||||
|
impl Strategy for BigStackPreFlop {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Only applicable preflop when we have hole cards
|
||||||
|
ctx.hole_cards.is_some() && ctx.game_info.is_preflop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
let hole = match ctx.hole_cards {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return Action::Fold,
|
||||||
|
};
|
||||||
|
|
||||||
|
let c0 = hole.cards[0];
|
||||||
|
let c1 = hole.cards[1];
|
||||||
|
let r0 = c0.rank(); // higher rank (HoleCards stores desc)
|
||||||
|
let r1 = c1.rank(); // lower rank
|
||||||
|
let suited = c0.suit() == c1.suit();
|
||||||
|
let pair = r0 == r1;
|
||||||
|
|
||||||
|
let to_call = ctx.amount_to_call();
|
||||||
|
let bb = ctx.big_blind();
|
||||||
|
let our_stack = ctx.our_stack();
|
||||||
|
let num_raises = ctx.num_raises;
|
||||||
|
let num_callers = ctx.num_callers;
|
||||||
|
let num_players = ctx.game_info.get_num_seats();
|
||||||
|
let our_position = ctx.our_position;
|
||||||
|
|
||||||
|
// In Java: BIG_BLIND = 1, position 0 = SB, 1 = BB
|
||||||
|
// our_position: 0 = SB, 1 = BB, ..., N-1 = Button
|
||||||
|
let is_blind = our_position <= 1;
|
||||||
|
let is_mid_or_later = is_blind || our_position > (num_players as u32 - 5);
|
||||||
|
let is_late_or_blind = is_blind || our_position >= (num_players as u32 - 2);
|
||||||
|
|
||||||
|
// --- Got re-raised (we were the aggressor, now facing a raise) ---
|
||||||
|
if ctx.i_am_aggressor {
|
||||||
|
if pair {
|
||||||
|
// All-in with AA-KK
|
||||||
|
if r0 >= KING {
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
// Call min-raises, and call with pairs if both us and the raiser
|
||||||
|
// have 20 BB left
|
||||||
|
let raiser_stack = get_raiser_stack(ctx);
|
||||||
|
if (our_stack as f64 > 20.0 * bb as f64
|
||||||
|
&& raiser_stack as f64 > 20.0 * bb as f64)
|
||||||
|
|| to_call <= bb
|
||||||
|
{
|
||||||
|
return Action::Call;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-pair: fold
|
||||||
|
return Action::check_or_fold(to_call);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Our first action in this hand ---
|
||||||
|
let mut raise = false;
|
||||||
|
let mut all_in = false;
|
||||||
|
let mut call = false;
|
||||||
|
|
||||||
|
// Raise with AA-KK, go all-in with two or more raises in front
|
||||||
|
if pair && r0 >= KING {
|
||||||
|
raise = true;
|
||||||
|
if num_raises > 2 {
|
||||||
|
all_in = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One raise so far (num_raises == 2; BB counts as raise in Java)
|
||||||
|
if num_raises == 2 {
|
||||||
|
if pair {
|
||||||
|
// Raise with AA-QQ
|
||||||
|
if r0 > QUEEN {
|
||||||
|
raise = true;
|
||||||
|
} else if get_raiser_stack(ctx) as f64 >= 20.0 * bb as f64
|
||||||
|
&& our_stack as f64 >= 20.0 * bb as f64
|
||||||
|
{
|
||||||
|
// Call with pairs if both deep enough
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AK (both king+)
|
||||||
|
if r0 >= KING && r1 >= KING {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No raise in front (num_raises <= 1; BB counts as raise in Java)
|
||||||
|
if num_raises <= 1 {
|
||||||
|
// Raise with AA-TT
|
||||||
|
if pair && r0 >= TEN {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raise with AK
|
||||||
|
if r0 >= KING && r1 >= KING {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call with pairs in mid position and later, raise on no callers in late
|
||||||
|
if pair && is_mid_or_later {
|
||||||
|
call = true;
|
||||||
|
if num_callers == 0 && is_late_or_blind {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AT+ (both cards broadway with ace)
|
||||||
|
if r0 >= ACE && r1 >= TEN {
|
||||||
|
// Raise first in from mid or later
|
||||||
|
if num_callers == 0 && is_mid_or_later {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
// Raise with callers in late position
|
||||||
|
if num_callers >= 1 && is_late_or_blind {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
// Call in the blinds with callers
|
||||||
|
if num_callers >= 1 && is_blind {
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suited aces (Ax suited)
|
||||||
|
if r0 >= ACE && suited {
|
||||||
|
// Raise first in from late or blinds
|
||||||
|
if num_callers == 0 && is_late_or_blind {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
// Call with callers from late or blinds
|
||||||
|
if num_callers >= 1 && is_late_or_blind {
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadway cards (both >= Ten)
|
||||||
|
if r0 >= TEN && r1 >= TEN {
|
||||||
|
// Raise first in from late or blinds
|
||||||
|
if num_callers == 0 && is_late_or_blind {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if suited {
|
||||||
|
// Call with callers from mid or later with suited broadway
|
||||||
|
if num_callers >= 1 && is_mid_or_later {
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Call with callers in blinds with offsuit broadway
|
||||||
|
if num_callers >= 1 && is_blind {
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suited connectors (high card >= Five, gap of 1, suited)
|
||||||
|
if r0 >= FIVE && r0 == r1 + 1 && suited {
|
||||||
|
// Raise with suited connectors in late position on no callers
|
||||||
|
if num_callers == 0 && is_late_or_blind {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
// Call with suited connectors from mid or later on callers
|
||||||
|
if num_callers >= 1 && is_mid_or_later {
|
||||||
|
call = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute decision
|
||||||
|
if raise || all_in {
|
||||||
|
if all_in {
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
let raise_amount =
|
||||||
|
((4 + num_callers) as u64 * bb as u64).saturating_sub(to_call);
|
||||||
|
if raise_amount == 0 {
|
||||||
|
return Action::Call;
|
||||||
|
}
|
||||||
|
return Action::Raise {
|
||||||
|
total: to_call + raise_amount,
|
||||||
|
};
|
||||||
|
} else if call {
|
||||||
|
return Action::Call;
|
||||||
|
} else {
|
||||||
|
return Action::check_or_fold(to_call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the stack of the last raiser (aggressor).
|
||||||
|
fn get_raiser_stack(ctx: &StrategyContext) -> u64 {
|
||||||
|
ctx.aggressor_seat
|
||||||
|
.and_then(|seat| ctx.game_info.player_at_seat(seat))
|
||||||
|
.map(|p| p.stack)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hole card classification helpers (for testing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Check if hole cards are a pocket pair of the given rank or higher.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_pair_at_least(hole: &HoleCards, min_rank: u8) -> bool {
|
||||||
|
hole.cards[0].rank() == hole.cards[1].rank() && hole.cards[0].rank() >= min_rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if hole cards are AK (suited or offsuit).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_ako(hole: &HoleCards) -> bool {
|
||||||
|
let r0 = hole.cards[0].rank();
|
||||||
|
let r1 = hole.cards[1].rank();
|
||||||
|
r0 == ACE && r1 == KING
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if hole cards are AKs (suited).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_aks(hole: &HoleCards) -> bool {
|
||||||
|
is_ako(hole) && hole.cards[0].suit() == hole.cards[1].suit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if both cards are >= given rank.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_broadway(hole: &HoleCards, min_rank: u8) -> bool {
|
||||||
|
hole.cards[0].rank() >= min_rank && hole.cards[1].rank() >= min_rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if hole cards are suited.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_suited(hole: &HoleCards) -> bool {
|
||||||
|
hole.cards[0].suit() == hole.cards[1].suit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the higher and lower rank from hole cards.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn rank_high_low(hole: &HoleCards) -> (u8, u8) {
|
||||||
|
(hole.cards[0].rank(), hole.cards[1].rank())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use holdem_core::card::Card;
|
||||||
|
use holdem_core::game::strategy::hand_helper::HandHelper;
|
||||||
|
use holdem_core::hole_cards::HoleCards;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||||
|
HoleCards::new(
|
||||||
|
Card::from_str(c1).unwrap(),
|
||||||
|
Card::from_str(c2).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Classification helper tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_pair_at_least() {
|
||||||
|
let aa = make_hole("As", "Ah");
|
||||||
|
assert!(is_pair_at_least(&aa, ACE));
|
||||||
|
assert!(is_pair_at_least(&aa, KING));
|
||||||
|
|
||||||
|
let kk = make_hole("Ks", "Kh");
|
||||||
|
assert!(!is_pair_at_least(&kk, ACE));
|
||||||
|
assert!(is_pair_at_least(&kk, KING));
|
||||||
|
|
||||||
|
let ak = make_hole("As", "Kh");
|
||||||
|
assert!(!is_pair_at_least(&ak, ACE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_ako() {
|
||||||
|
let ak = make_hole("As", "Kh");
|
||||||
|
let ka = make_hole("Ks", "Ah");
|
||||||
|
assert!(is_ako(&ak));
|
||||||
|
assert!(is_ako(&ka));
|
||||||
|
|
||||||
|
let aq = make_hole("As", "Qh");
|
||||||
|
assert!(!is_ako(&aq));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_aks() {
|
||||||
|
let aks = make_hole("As", "Ks");
|
||||||
|
let ako = make_hole("As", "Kh");
|
||||||
|
assert!(is_aks(&aks));
|
||||||
|
assert!(!is_aks(&ako));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_broadway() {
|
||||||
|
let ak = make_hole("As", "Kh");
|
||||||
|
assert!(is_broadway(&ak, KING));
|
||||||
|
assert!(!is_broadway(&ak, ACE));
|
||||||
|
|
||||||
|
let jt = make_hole("Js", "Th");
|
||||||
|
assert!(is_broadway(&jt, TEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_suited() {
|
||||||
|
let suited = make_hole("As", "Ks");
|
||||||
|
let offsuit = make_hole("As", "Kh");
|
||||||
|
assert!(is_suited(&suited));
|
||||||
|
assert!(!is_suited(&offsuit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rank_high_low() {
|
||||||
|
let ak = make_hole("Ks", "Ah");
|
||||||
|
let (hi, lo) = rank_high_low(&ak);
|
||||||
|
assert_eq!(hi, ACE);
|
||||||
|
assert_eq!(lo, KING);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_aa() {
|
||||||
|
let aa = make_hole("As", "Ah");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&aa), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_72o() {
|
||||||
|
let trash = make_hole("7s", "2h");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&trash), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
holdem_bots/src/cash/mod.rs
Normal file
21
holdem_bots/src/cash/mod.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//! Cash game (ring NL) bot strategies.
|
||||||
|
//!
|
||||||
|
//! Provides two bot strategies based on stack depth:
|
||||||
|
//! - [`BigStackBot`] — for stacks > 25 BB (tight-aggressive, deep stack play)
|
||||||
|
//! - [`ShortStackBot`] — for stacks ≤ 25 BB (push/fold oriented)
|
||||||
|
//!
|
||||||
|
//! Both bots use a shared [`PostFlopStrategy`] and separate preflop strategies
|
||||||
|
//! tailored to their stack depth.
|
||||||
|
|
||||||
|
pub mod postflop;
|
||||||
|
pub mod big_stack_preflop;
|
||||||
|
pub mod short_stack_preflop;
|
||||||
|
pub mod big_stack;
|
||||||
|
pub mod short_stack;
|
||||||
|
|
||||||
|
// Re-export the main bot types for convenience
|
||||||
|
pub use big_stack::BigStackBot;
|
||||||
|
pub use short_stack::ShortStackBot;
|
||||||
|
pub use big_stack_preflop::BigStackPreFlop;
|
||||||
|
pub use short_stack_preflop::ShortStackPreFlop;
|
||||||
|
pub use postflop::PostFlopStrategy;
|
||||||
287
holdem_bots/src/cash/postflop.rs
Normal file
287
holdem_bots/src/cash/postflop.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
//! Shared postflop strategy for cash game bots.
|
||||||
|
//!
|
||||||
|
//! Simplified port of Java `PostFlopStrategyV3` that uses `HandAnalyzer`/`HandHelper`
|
||||||
|
//! instead of `HandPotential` (which we don't have in Rust yet).
|
||||||
|
//!
|
||||||
|
//! # Decision Logic
|
||||||
|
//!
|
||||||
|
//! 1. Classify hand strength (trips+, two pair, top pair, overpair, draw)
|
||||||
|
//! 2. Based on strength, position, and pot odds, decide fold/call/raise
|
||||||
|
//! 3. Use outs-based calling when facing bets
|
||||||
|
|
||||||
|
use holdem_core::board::Board;
|
||||||
|
use holdem_core::game::action::Action;
|
||||||
|
use holdem_core::game::strategy::hand_analyzer::HandAnalyzer;
|
||||||
|
use holdem_core::game::strategy::hand_helper::HandHelper;
|
||||||
|
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||||
|
use holdem_core::hole_cards::HoleCards;
|
||||||
|
|
||||||
|
/// Shared postflop strategy using hand strength analysis.
|
||||||
|
///
|
||||||
|
/// This is a simplified port of Java `PostFlopStrategyV3` that uses
|
||||||
|
/// [`HandAnalyzer`]/[`HandHelper`] instead of `HandPotential`.
|
||||||
|
///
|
||||||
|
/// The original Java code used `HandPotential` to compute raw hand strength (hs),
|
||||||
|
/// positive potential (ppot), and negative potential (npot), then derived
|
||||||
|
/// `winProb = hs * (1 - npot) + (1 - hs) * ppot`. Since we don't have
|
||||||
|
/// `HandPotential` yet, we approximate win probability using hand classification
|
||||||
|
/// and outs counting.
|
||||||
|
pub struct PostFlopStrategy;
|
||||||
|
|
||||||
|
/// Approximate win probability based on hand classification.
|
||||||
|
///
|
||||||
|
/// Returns a value in [0.0, 1.0] representing our estimated equity against
|
||||||
|
/// a single opponent. This is a rough approximation that maps hand categories
|
||||||
|
/// to equity estimates.
|
||||||
|
fn approximate_win_prob(hole: &HoleCards, board: &Board) -> f64 {
|
||||||
|
// Monster hands: very high equity
|
||||||
|
if HandAnalyzer::is_trips_or_higher(hole, board) {
|
||||||
|
return 0.90;
|
||||||
|
}
|
||||||
|
if HandAnalyzer::is_real_two_pair(hole, board) {
|
||||||
|
return 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strong hands
|
||||||
|
if HandHelper::is_overpair(hole, board) {
|
||||||
|
return 0.70;
|
||||||
|
}
|
||||||
|
if HandHelper::is_top_pair(hole, board) {
|
||||||
|
return 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing hands — estimate based on outs
|
||||||
|
let outs = HandAnalyzer::num_outs_to_monster(hole, board);
|
||||||
|
if outs >= 12 {
|
||||||
|
return 0.50;
|
||||||
|
}
|
||||||
|
if outs >= 9 {
|
||||||
|
return 0.40;
|
||||||
|
}
|
||||||
|
if outs >= 6 {
|
||||||
|
return 0.30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weak made hands
|
||||||
|
if HandAnalyzer::is_paired(hole, board) {
|
||||||
|
return 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overcards
|
||||||
|
if HandAnalyzer::is_two_over_cards(hole, board) {
|
||||||
|
return 0.20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing
|
||||||
|
0.10
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the "effective" win probability adjusted for number of opponents.
|
||||||
|
///
|
||||||
|
/// In the Java code: `max(winProb, hs) ^ (numActivePlayers - 1)`.
|
||||||
|
/// This adjusts for the fact that equity drops with more opponents.
|
||||||
|
fn effective_equity(win_prob: f64, num_opponents: usize) -> f64 {
|
||||||
|
if num_opponents == 0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
win_prob.powi(num_opponents as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for PostFlopStrategy {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Applicable when we have hole cards and we're postflop
|
||||||
|
ctx.hole_cards.is_some() && ctx.game_info.is_postflop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
let hole = match ctx.hole_cards {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return Action::Fold,
|
||||||
|
};
|
||||||
|
|
||||||
|
let board = build_board_from_game_info(ctx);
|
||||||
|
let num_opponents = ctx.num_active.saturating_sub(1);
|
||||||
|
let win_prob = approximate_win_prob(&hole, &board);
|
||||||
|
let equity = effective_equity(win_prob, num_opponents);
|
||||||
|
|
||||||
|
let to_call = ctx.amount_to_call();
|
||||||
|
let pot = ctx.pot_size();
|
||||||
|
let our_stack = ctx.our_stack();
|
||||||
|
let min_raise = ctx.big_blind(); // Use BB as min raise in NL
|
||||||
|
let is_flop = ctx.game_info.is_flop();
|
||||||
|
|
||||||
|
let mut raise_amount: f64 = 0.0;
|
||||||
|
|
||||||
|
// First in (nothing to call)
|
||||||
|
if to_call == 0 {
|
||||||
|
if ctx.i_am_preflop_aggressor && is_flop {
|
||||||
|
// Continuation bet: bet if equity is decent
|
||||||
|
if equity > 0.30 {
|
||||||
|
raise_amount = (pot as f64 / 2.0).round();
|
||||||
|
// Round to min_raise
|
||||||
|
if min_raise > 0 {
|
||||||
|
raise_amount = (raise_amount / min_raise as f64).floor()
|
||||||
|
* min_raise as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bet out with strong hands
|
||||||
|
let threshold = if is_flop { 0.50 } else { 0.75 };
|
||||||
|
if equity > threshold {
|
||||||
|
raise_amount = (pot as f64 * 2.0 / 3.0).round();
|
||||||
|
if min_raise > 0 {
|
||||||
|
raise_amount = (raise_amount / min_raise as f64).floor()
|
||||||
|
* min_raise as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Facing a bet
|
||||||
|
if ctx.num_raises > 1 {
|
||||||
|
// Re-raise with very strong hands
|
||||||
|
if equity > 0.94 {
|
||||||
|
raise_amount = pot as f64;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Raise with strong hands
|
||||||
|
if equity > 0.89 {
|
||||||
|
raise_amount = pot as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bet more than half my stack → all-in
|
||||||
|
if raise_amount * 2.0 >= our_stack as f64 && raise_amount > 0.0 {
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Committed to pot: amount in pot + raise >= 2 * remaining stack
|
||||||
|
let wagered = ctx
|
||||||
|
.game_info
|
||||||
|
.player_by_id(ctx.our_id)
|
||||||
|
.map(|p| p.wagered_this_hand)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if raise_amount > 0.0
|
||||||
|
&& raise_amount + wagered as f64
|
||||||
|
>= 2.0 * (our_stack as f64 - raise_amount)
|
||||||
|
{
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raise_amount > 0.0 {
|
||||||
|
// Been raised already (we were aggressor but someone re-raised) → all-in
|
||||||
|
if ctx.i_am_aggressor && ctx.num_raises > 0 {
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
let total = to_call + raise_amount as u64;
|
||||||
|
return Action::Raise { total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call decision: equity / 4 > to_call / pot
|
||||||
|
if to_call > 0 && equity / 4.0 > (to_call as f64).min(our_stack as f64) / pot as f64 {
|
||||||
|
// Committed to pot when calling
|
||||||
|
let wagered = ctx
|
||||||
|
.game_info
|
||||||
|
.player_by_id(ctx.our_id)
|
||||||
|
.map(|p| p.wagered_this_hand)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if to_call as f64 + wagered as f64
|
||||||
|
>= 1.5 * (our_stack as f64 - to_call as f64)
|
||||||
|
{
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
return Action::Call;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check or fold
|
||||||
|
Action::check_or_fold(to_call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `Board` from the `GameInfo` board cards.
|
||||||
|
fn build_board_from_game_info(ctx: &StrategyContext) -> Board {
|
||||||
|
let mut board = Board::new();
|
||||||
|
let cards = &ctx.game_info.board;
|
||||||
|
if cards.len() >= 3 {
|
||||||
|
let _ = board.deal_flop(cards[0..3].to_vec());
|
||||||
|
}
|
||||||
|
if cards.len() >= 4 {
|
||||||
|
let _ = board.deal_turn(cards[3]);
|
||||||
|
}
|
||||||
|
if cards.len() >= 5 {
|
||||||
|
let _ = board.deal_river(cards[4]);
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use holdem_core::card::Card;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||||
|
HoleCards::new(
|
||||||
|
Card::from_str(c1).unwrap(),
|
||||||
|
Card::from_str(c2).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_board(cards: &[&str]) -> Board {
|
||||||
|
let mut board = Board::new();
|
||||||
|
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
|
||||||
|
if parsed.len() >= 3 {
|
||||||
|
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 4 {
|
||||||
|
board.deal_turn(parsed[3]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 5 {
|
||||||
|
board.deal_river(parsed[4]).unwrap();
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_approximate_win_prob_trips() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Ad", "5c", "9h"]);
|
||||||
|
let wp = approximate_win_prob(&hole, &board);
|
||||||
|
assert!(wp >= 0.85, "Trips should have high win prob, got {}", wp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_approximate_win_prob_two_pair() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Ad", "Kc", "9h"]);
|
||||||
|
let wp = approximate_win_prob(&hole, &board);
|
||||||
|
assert!(wp >= 0.70, "Two pair should have good win prob, got {}", wp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_approximate_win_prob_weak() {
|
||||||
|
let hole = make_hole("2s", "7d");
|
||||||
|
let board = make_board(&["Ah", "Kc", "Qh"]);
|
||||||
|
let wp = approximate_win_prob(&hole, &board);
|
||||||
|
assert!(wp < 0.20, "Weak hand should have low win prob, got {}", wp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_equity_multiple_opponents() {
|
||||||
|
// Equity should drop with more opponents
|
||||||
|
let e1 = effective_equity(0.70, 1);
|
||||||
|
let e3 = effective_equity(0.70, 3);
|
||||||
|
assert!(e3 < e1, "Equity should drop with more opponents");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_equity_zero_opponents() {
|
||||||
|
let e = effective_equity(0.5, 0);
|
||||||
|
assert_eq!(e, 1.0, "With no opponents, equity should be 1.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
67
holdem_bots/src/cash/short_stack.rs
Normal file
67
holdem_bots/src/cash/short_stack.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! Short stack cash game strategy (stack ≤ 25 BB).
|
||||||
|
//!
|
||||||
|
//! Port of Java `ShortStackStrategy` which combines a preflop strategy
|
||||||
|
//! with the shared postflop strategy using a `ModularStrategy` composite.
|
||||||
|
|
||||||
|
use holdem_core::game::action::Action;
|
||||||
|
use holdem_core::game::strategy::{ModularStrategy, Strategy, StrategyContext, VotingMode};
|
||||||
|
|
||||||
|
use super::postflop::PostFlopStrategy;
|
||||||
|
use super::ShortStackPreFlop;
|
||||||
|
|
||||||
|
/// Short stack cash game strategy (stack ≤ 25 BB).
|
||||||
|
///
|
||||||
|
/// Uses a [`ModularStrategy`] with [`VotingMode::FirstApplicable`] to combine:
|
||||||
|
/// - [`ShortStackPreFlop`] — handles all preflop decisions
|
||||||
|
/// - [`PostFlopStrategy`] — handles all postflop decisions
|
||||||
|
///
|
||||||
|
/// # Applicability
|
||||||
|
///
|
||||||
|
/// Only applicable when:
|
||||||
|
/// - The game is No-Limit
|
||||||
|
/// - Our stack is 25 big blinds or less
|
||||||
|
pub struct ShortStackBot {
|
||||||
|
inner: ModularStrategy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShortStackBot {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let inner = ModularStrategy::new("ShortStack", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(ShortStackPreFlop))
|
||||||
|
.with_sub_strategy(Box::new(PostFlopStrategy));
|
||||||
|
Self { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShortStackBot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for ShortStackBot {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Applicable for NL games with stack <= 25 BB
|
||||||
|
ctx.stack_in_bb() <= 25.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
self.inner.get_action(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_stack_bot_default() {
|
||||||
|
let bot = ShortStackBot::default();
|
||||||
|
assert_eq!(bot.inner.name(), "ShortStack");
|
||||||
|
assert_eq!(bot.inner.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
holdem_bots/src/cash/short_stack_preflop.rs
Normal file
204
holdem_bots/src/cash/short_stack_preflop.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
//! Short stack preflop strategy for NL cash games.
|
||||||
|
//!
|
||||||
|
//! Port of Java `ShortStackPreFlopStrategy`. Implements a push/fold oriented
|
||||||
|
//! preflop strategy suitable for short-stacked play (≤ 25 BB).
|
||||||
|
|
||||||
|
use holdem_core::game::action::Action;
|
||||||
|
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||||
|
|
||||||
|
// Rank constants (0=Two .. 12=Ace)
|
||||||
|
const ACE: u8 = 12;
|
||||||
|
const KING: u8 = 11;
|
||||||
|
const QUEEN: u8 = 10;
|
||||||
|
const JACK: u8 = 9;
|
||||||
|
const TEN: u8 = 8;
|
||||||
|
const NINE: u8 = 7;
|
||||||
|
const EIGHT: u8 = 6;
|
||||||
|
const SEVEN: u8 = 5;
|
||||||
|
|
||||||
|
/// Short stack preflop strategy.
|
||||||
|
///
|
||||||
|
/// Decision tree ported from Java `ShortStackPreFlopStrategy`:
|
||||||
|
///
|
||||||
|
/// 1. **Already raised and got re-raised (didRaise):**
|
||||||
|
/// - If stack/raise_amount >= 4: all-in with AA/KK/QQ/JJ/AK, fold otherwise
|
||||||
|
/// - If stack/raise_amount > 2.5: all-in with AA/KK/QQ/JJ/TT/AK/AQ, fold otherwise
|
||||||
|
/// - Otherwise: all-in with everything (pot-committed)
|
||||||
|
///
|
||||||
|
/// 2. **First action:**
|
||||||
|
/// - AA/KK → Always raise
|
||||||
|
/// - QQ/JJ/AK → Raise if max one raise
|
||||||
|
/// - TT/99/AQ → Raise if no raise from mid/late
|
||||||
|
/// - 88/77/AJ/KQ → Raise from late/blinds
|
||||||
|
///
|
||||||
|
/// Raise sizing: `(4 + num_callers) × BB - amount_to_call`
|
||||||
|
/// If raise × 2 > stack → all-in
|
||||||
|
pub struct ShortStackPreFlop;
|
||||||
|
|
||||||
|
impl Strategy for ShortStackPreFlop {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Only applicable preflop when we have hole cards
|
||||||
|
ctx.hole_cards.is_some() && ctx.game_info.is_preflop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
let hole = match ctx.hole_cards {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return Action::Fold,
|
||||||
|
};
|
||||||
|
|
||||||
|
let r0 = hole.cards[0].rank(); // higher rank
|
||||||
|
let r1 = hole.cards[1].rank(); // lower rank
|
||||||
|
let pair = r0 == r1;
|
||||||
|
|
||||||
|
let to_call = ctx.amount_to_call();
|
||||||
|
let bb = ctx.big_blind();
|
||||||
|
let our_stack = ctx.our_stack();
|
||||||
|
let num_raises = ctx.num_raises;
|
||||||
|
let num_callers = ctx.num_callers;
|
||||||
|
let num_players = ctx.game_info.get_num_seats();
|
||||||
|
let our_position = ctx.our_position;
|
||||||
|
|
||||||
|
let is_blind = our_position <= 1;
|
||||||
|
let is_mid_or_later = is_blind || our_position > (num_players as u32 - 5);
|
||||||
|
let is_late_or_blind = is_blind || our_position >= (num_players as u32 - 2);
|
||||||
|
|
||||||
|
// --- Already raised and got re-raised ---
|
||||||
|
// In Java: didRaise is set when we raised previously. In our framework,
|
||||||
|
// i_am_aggressor means we were the last raiser. If num_raises > 0 and
|
||||||
|
// i_am_aggressor, it means we raised and someone else re-raised.
|
||||||
|
// But actually in the Java code, didRaise is set when the PLAYER raised,
|
||||||
|
// and it persists. In our framework, we need to check if we raised and
|
||||||
|
// now face a re-raise (someone raised after us).
|
||||||
|
//
|
||||||
|
// Simplified: if we're the aggressor and there's a bet to call, we got re-raised.
|
||||||
|
if ctx.i_am_aggressor && to_call > 0 {
|
||||||
|
// Estimate the raise amount we put in (use BB as approximation)
|
||||||
|
// In Java: bankrollDivRaiseAmount = bankroll / didRaiseAmount
|
||||||
|
// We approximate didRaiseAmount as the standard raise size
|
||||||
|
let did_raise_amount = (4 + num_callers) as f64 * bb as f64;
|
||||||
|
let ratio = our_stack as f64 / did_raise_amount;
|
||||||
|
|
||||||
|
if ratio >= 4.0 {
|
||||||
|
// All-in with AA, KK, QQ, JJ, AK; fold otherwise
|
||||||
|
if (pair && r0 >= JACK) || (r0 == ACE && r1 == KING) {
|
||||||
|
return Action::AllIn;
|
||||||
|
} else {
|
||||||
|
return Action::check_or_fold(to_call);
|
||||||
|
}
|
||||||
|
} else if ratio > 2.5 {
|
||||||
|
// All-in with AA, KK, QQ, JJ, TT, AK, AQ; fold otherwise
|
||||||
|
if (pair && r0 >= TEN)
|
||||||
|
|| (r0 == ACE && r1 == KING)
|
||||||
|
|| (r0 == ACE && r1 == QUEEN)
|
||||||
|
{
|
||||||
|
return Action::AllIn;
|
||||||
|
} else {
|
||||||
|
return Action::check_or_fold(to_call);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pot committed: all-in with everything we raised with
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- First action in this hand ---
|
||||||
|
let mut raise = false;
|
||||||
|
|
||||||
|
// AA, KK: always raise
|
||||||
|
if pair && r0 >= KING {
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max one raise so far (num_raises <= 2 in Java convention where BB = 1 raise)
|
||||||
|
if num_raises <= 2 {
|
||||||
|
// QQ, AK, JJ
|
||||||
|
if (pair && r0 == QUEEN)
|
||||||
|
|| (r0 == ACE && r1 == KING)
|
||||||
|
|| (pair && r0 == JACK)
|
||||||
|
{
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No raise so far; mid position, late position, or blinds
|
||||||
|
if num_raises <= 1 && is_mid_or_later {
|
||||||
|
// TT, 99, AQ
|
||||||
|
if (pair && r0 == TEN)
|
||||||
|
|| (pair && r0 == NINE)
|
||||||
|
|| (r0 == ACE && r1 == QUEEN)
|
||||||
|
{
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No raise so far; late position or blinds
|
||||||
|
if is_late_or_blind {
|
||||||
|
// 88, 77, AJ, KQ
|
||||||
|
if (pair && r0 == EIGHT)
|
||||||
|
|| (pair && r0 == SEVEN)
|
||||||
|
|| (r0 == ACE && r1 == JACK)
|
||||||
|
|| (r0 == KING && r1 == QUEEN)
|
||||||
|
{
|
||||||
|
raise = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if raise {
|
||||||
|
let raise_amount =
|
||||||
|
((4 + num_callers) as u64 * bb as u64).saturating_sub(to_call);
|
||||||
|
|
||||||
|
// If raise would commit more than half our stack, just go all-in
|
||||||
|
if raise_amount * 2 > our_stack {
|
||||||
|
return Action::AllIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raise_amount == 0 {
|
||||||
|
return Action::Call;
|
||||||
|
}
|
||||||
|
return Action::Raise {
|
||||||
|
total: to_call + raise_amount,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return Action::check_or_fold(to_call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use holdem_core::card::Card;
|
||||||
|
use holdem_core::hole_cards::HoleCards;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||||
|
HoleCards::new(
|
||||||
|
Card::from_str(c1).unwrap(),
|
||||||
|
Card::from_str(c2).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_stack_preflop_classifies_aa() {
|
||||||
|
let aa = make_hole("As", "Ah");
|
||||||
|
let r0 = aa.cards[0].rank();
|
||||||
|
let r1 = aa.cards[1].rank();
|
||||||
|
assert!(r0 == r1 && r0 >= KING);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_stack_preflop_classifies_72o() {
|
||||||
|
let trash = make_hole("7s", "2h");
|
||||||
|
let r0 = trash.cards[0].rank();
|
||||||
|
let r1 = trash.cards[1].rank();
|
||||||
|
let pair = r0 == r1;
|
||||||
|
assert!(!pair);
|
||||||
|
assert!(r0 < ACE || r1 < KING); // Not AK
|
||||||
|
}
|
||||||
|
}
|
||||||
7
holdem_bots/src/lib.rs
Normal file
7
holdem_bots/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Advanced poker bot strategies.
|
||||||
|
//!
|
||||||
|
//! This crate contains sophisticated bot implementations ported from the Java
|
||||||
|
//! Supersonic framework. It is **not** intended for public distribution —
|
||||||
|
//! exclude from public git mirrors via `.gitignore` or separate repository.
|
||||||
|
|
||||||
|
pub mod cash;
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Seeds for failure cases proptest has generated in the past. It is
|
|
||||||
# automatically read and these particular cases re-run before any
|
|
||||||
# novel cases are generated.
|
|
||||||
#
|
|
||||||
# It is recommended to check this file in to source control so that
|
|
||||||
# everyone who runs the test benefits from these saved cases.
|
|
||||||
cc 379f4d3cc221bdadc0189eec0a50b7bacd7b0de376ac22599cd362016bbe153d # shrinks to cards = [Card { rank: 10, suit: 1 }, Card { rank: 0, suit: 2 }, Card { rank: 4, suit: 2 }, Card { rank: 1, suit: 3 }, Card { rank: 11, suit: 3 }]
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
//! Benchmark utilities for the hand evaluator.
|
|
||||||
//!
|
|
||||||
//! This module provides helper functions and data structures for benchmarking
|
|
||||||
//! hand evaluation performance.
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
//! Integration utilities for the hand evaluator.
|
|
||||||
//!
|
|
||||||
//! This module provides helper functions and data structures for integration
|
|
||||||
//! testing and cross-validation of hand evaluation results.
|
|
||||||
|
|
@ -14,18 +14,14 @@
|
||||||
//! - **`examples`**: Usage examples and performance benchmarks
|
//! - **`examples`**: Usage examples and performance benchmarks
|
||||||
|
|
||||||
pub mod algorithmic_evaluator;
|
pub mod algorithmic_evaluator;
|
||||||
pub mod benchmark_utils;
|
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod evaluator;
|
pub mod evaluator;
|
||||||
pub mod examples;
|
pub mod examples;
|
||||||
pub mod file_io;
|
pub mod file_io;
|
||||||
pub mod integration;
|
pub mod integration;
|
||||||
pub mod integration_utils;
|
|
||||||
pub mod property_tests;
|
pub mod property_tests;
|
||||||
pub mod singleton;
|
pub mod singleton;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
pub mod tables_new;
|
|
||||||
pub mod test_utils;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod algorithmic_evaluator_test;
|
mod algorithmic_evaluator_test;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
//! New table implementations for the hand evaluator.
|
|
||||||
//!
|
|
||||||
//! This module is a placeholder for future jump table implementations
|
|
||||||
//! and optimized lookup structures.
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
//! Test utilities for the hand evaluator.
|
|
||||||
//!
|
|
||||||
//! This module provides helper functions and data structures for testing
|
|
||||||
//! hand evaluation correctness.
|
|
||||||
|
|
@ -31,6 +31,7 @@ pub mod player_info;
|
||||||
pub mod pot;
|
pub mod pot;
|
||||||
pub mod simulation;
|
pub mod simulation;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod strategy;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod tournament;
|
pub mod tournament;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
|
||||||
242
holdem_core/src/game/strategy/action_recorder.rs
Normal file
242
holdem_core/src/game/strategy/action_recorder.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
//! Action recorder — tracks actions for post-hand analysis and opponent modeling.
|
||||||
|
//!
|
||||||
|
//! Ported from the Java `ActionRecorder` class in the Supersonic framework.
|
||||||
|
//! Records all player actions in a hand, segmented by betting round (street).
|
||||||
|
|
||||||
|
use crate::board::Street;
|
||||||
|
use crate::game::action::Action;
|
||||||
|
use crate::game::types::PlayerId;
|
||||||
|
|
||||||
|
/// A recorded action with the acting player.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecordedAction {
|
||||||
|
/// The player who performed the action.
|
||||||
|
pub player_id: PlayerId,
|
||||||
|
/// The action performed.
|
||||||
|
pub action: Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records all actions in a hand, segmented by betting round.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use holdem_core::game::strategy::action_recorder::{ActionRecorder, RecordedAction};
|
||||||
|
/// use holdem_core::game::action::Action;
|
||||||
|
/// use holdem_core::game::types::PlayerId;
|
||||||
|
/// use holdem_core::board::Street;
|
||||||
|
///
|
||||||
|
/// let mut recorder = ActionRecorder::new();
|
||||||
|
///
|
||||||
|
/// recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 100 });
|
||||||
|
/// recorder.record(Street::Preflop, PlayerId(2), Action::Call);
|
||||||
|
/// recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
/// recorder.record(Street::Flop, PlayerId(2), Action::Check);
|
||||||
|
///
|
||||||
|
/// assert_eq!(recorder.preflop_actions().len(), 2);
|
||||||
|
/// assert_eq!(recorder.flop_actions().len(), 2);
|
||||||
|
/// assert_eq!(recorder.all_actions().len(), 4);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActionRecorder {
|
||||||
|
preflop: Vec<RecordedAction>,
|
||||||
|
flop: Vec<RecordedAction>,
|
||||||
|
turn: Vec<RecordedAction>,
|
||||||
|
river: Vec<RecordedAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionRecorder {
|
||||||
|
/// Create a new empty action recorder.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
preflop: Vec::new(),
|
||||||
|
flop: Vec::new(),
|
||||||
|
turn: Vec::new(),
|
||||||
|
river: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an action for the current street.
|
||||||
|
pub fn record(&mut self, street: Street, player_id: PlayerId, action: Action) {
|
||||||
|
let recorded = RecordedAction { player_id, action };
|
||||||
|
match street {
|
||||||
|
Street::Preflop => &mut self.preflop,
|
||||||
|
Street::Flop => &mut self.flop,
|
||||||
|
Street::Turn => &mut self.turn,
|
||||||
|
Street::River => &mut self.river,
|
||||||
|
}
|
||||||
|
.push(recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all recorded actions (new hand).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.preflop.clear();
|
||||||
|
self.flop.clear();
|
||||||
|
self.turn.clear();
|
||||||
|
self.river.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all actions across all streets, in order (preflop → flop → turn → river).
|
||||||
|
pub fn all_actions(&self) -> Vec<&RecordedAction> {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
all.extend(self.preflop.iter());
|
||||||
|
all.extend(self.flop.iter());
|
||||||
|
all.extend(self.turn.iter());
|
||||||
|
all.extend(self.river.iter());
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get preflop actions.
|
||||||
|
pub fn preflop_actions(&self) -> &[RecordedAction] {
|
||||||
|
&self.preflop
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get flop actions.
|
||||||
|
pub fn flop_actions(&self) -> &[RecordedAction] {
|
||||||
|
&self.flop
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get turn actions.
|
||||||
|
pub fn turn_actions(&self) -> &[RecordedAction] {
|
||||||
|
&self.turn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get river actions.
|
||||||
|
pub fn river_actions(&self) -> &[RecordedAction] {
|
||||||
|
&self.river
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get actions for a specific player across all streets.
|
||||||
|
pub fn actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
|
||||||
|
self.all_actions()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|ra| ra.player_id == player_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get preflop actions for a specific player.
|
||||||
|
pub fn preflop_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
|
||||||
|
self.preflop
|
||||||
|
.iter()
|
||||||
|
.filter(|ra| ra.player_id == player_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get flop actions for a specific player.
|
||||||
|
pub fn flop_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
|
||||||
|
self.flop
|
||||||
|
.iter()
|
||||||
|
.filter(|ra| ra.player_id == player_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get turn actions for a specific player.
|
||||||
|
pub fn turn_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
|
||||||
|
self.turn
|
||||||
|
.iter()
|
||||||
|
.filter(|ra| ra.player_id == player_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get river actions for a specific player.
|
||||||
|
pub fn river_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
|
||||||
|
self.river
|
||||||
|
.iter()
|
||||||
|
.filter(|ra| ra.player_id == player_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ActionRecorder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game::action::Action;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_record_and_retrieve() {
|
||||||
|
let mut recorder = ActionRecorder::new();
|
||||||
|
|
||||||
|
recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 100 });
|
||||||
|
recorder.record(Street::Preflop, PlayerId(2), Action::Call);
|
||||||
|
recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
recorder.record(Street::Flop, PlayerId(2), Action::Check);
|
||||||
|
|
||||||
|
assert_eq!(recorder.preflop_actions().len(), 2);
|
||||||
|
assert_eq!(recorder.flop_actions().len(), 2);
|
||||||
|
assert_eq!(recorder.turn_actions().len(), 0);
|
||||||
|
assert_eq!(recorder.river_actions().len(), 0);
|
||||||
|
assert_eq!(recorder.all_actions().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_actions_for_player() {
|
||||||
|
let mut recorder = ActionRecorder::new();
|
||||||
|
|
||||||
|
recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 50 });
|
||||||
|
recorder.record(Street::Preflop, PlayerId(2), Action::Call);
|
||||||
|
recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
recorder.record(Street::Flop, PlayerId(2), Action::Raise { total: 100 });
|
||||||
|
|
||||||
|
let p1_actions = recorder.actions_for_player(PlayerId(1));
|
||||||
|
assert_eq!(p1_actions.len(), 2);
|
||||||
|
assert_eq!(p1_actions[0].player_id, PlayerId(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preflop_actions_for_player() {
|
||||||
|
let mut recorder = ActionRecorder::new();
|
||||||
|
|
||||||
|
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
|
||||||
|
recorder.record(Street::Preflop, PlayerId(2), Action::Fold);
|
||||||
|
recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
|
||||||
|
let p1_preflop = recorder.preflop_actions_for_player(PlayerId(1));
|
||||||
|
assert_eq!(p1_preflop.len(), 1);
|
||||||
|
assert!(matches!(p1_preflop[0].action, Action::Call));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset() {
|
||||||
|
let mut recorder = ActionRecorder::new();
|
||||||
|
|
||||||
|
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
|
||||||
|
recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
assert_eq!(recorder.all_actions().len(), 2);
|
||||||
|
|
||||||
|
recorder.reset();
|
||||||
|
assert_eq!(recorder.all_actions().len(), 0);
|
||||||
|
assert_eq!(recorder.preflop_actions().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_streets() {
|
||||||
|
let mut recorder = ActionRecorder::new();
|
||||||
|
|
||||||
|
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
|
||||||
|
recorder.record(Street::Flop, PlayerId(1), Action::Check);
|
||||||
|
recorder.record(Street::Turn, PlayerId(1), Action::Check);
|
||||||
|
recorder.record(Street::River, PlayerId(1), Action::AllIn);
|
||||||
|
|
||||||
|
assert_eq!(recorder.preflop_actions().len(), 1);
|
||||||
|
assert_eq!(recorder.flop_actions().len(), 1);
|
||||||
|
assert_eq!(recorder.turn_actions().len(), 1);
|
||||||
|
assert_eq!(recorder.river_actions().len(), 1);
|
||||||
|
assert_eq!(recorder.all_actions().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default() {
|
||||||
|
let recorder = ActionRecorder::default();
|
||||||
|
assert_eq!(recorder.all_actions().len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
714
holdem_core/src/game/strategy/context.rs
Normal file
714
holdem_core/src/game/strategy/context.rs
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
//! Strategy context — game state and metadata available to strategy modules.
|
||||||
|
//!
|
||||||
|
//! Provides a read-only snapshot of the current game state that strategies can
|
||||||
|
//! query to make decisions. This replaces the Java pattern where `ModularStrategy`
|
||||||
|
//! stored all state as protected fields — instead, everything is bundled into a
|
||||||
|
//! single `StrategyContext` that is passed to each strategy.
|
||||||
|
//!
|
||||||
|
//! Ported from the Java Supersonic framework's `ModularStrategy` protected fields.
|
||||||
|
|
||||||
|
use crate::game::game_info::GameInfo;
|
||||||
|
use crate::game::strategy::action_recorder::ActionRecorder;
|
||||||
|
use crate::game::types::{ChipCount, PlayerId, SeatIndex};
|
||||||
|
use crate::HoleCards;
|
||||||
|
|
||||||
|
/// Read-only context provided to strategies for decision-making.
|
||||||
|
///
|
||||||
|
/// Contains the current game state, hole cards, position information,
|
||||||
|
/// aggressor tracking, and action history. Strategies query this context
|
||||||
|
/// to make decisions.
|
||||||
|
///
|
||||||
|
/// # Lifetimes
|
||||||
|
///
|
||||||
|
/// The `'a` lifetime ties this context to the borrowed [`GameInfo`] and
|
||||||
|
/// [`ActionRecorder`] — the context is a transient snapshot created for
|
||||||
|
/// a single decision point and does not outlive the game state it references.
|
||||||
|
pub struct StrategyContext<'a> {
|
||||||
|
/// Current game state from the engine.
|
||||||
|
pub game_info: &'a GameInfo,
|
||||||
|
/// Our hole cards for this hand. `None` if we folded or haven't been dealt yet.
|
||||||
|
pub hole_cards: Option<HoleCards>,
|
||||||
|
/// Our seat index.
|
||||||
|
pub our_seat: SeatIndex,
|
||||||
|
/// Our player ID.
|
||||||
|
pub our_id: PlayerId,
|
||||||
|
/// Our position (0 = SB, 1 = BB, ..., N-1 = Button).
|
||||||
|
pub our_position: u32,
|
||||||
|
/// Number of active players still in the hand.
|
||||||
|
pub num_active: usize,
|
||||||
|
/// Whether we were the last raiser in the current betting round.
|
||||||
|
pub i_am_aggressor: bool,
|
||||||
|
/// Whether we raised preflop.
|
||||||
|
pub i_am_preflop_aggressor: bool,
|
||||||
|
/// Number of raises in the current betting round.
|
||||||
|
pub num_raises: u32,
|
||||||
|
/// Number of callers since the last raise.
|
||||||
|
pub num_callers: u32,
|
||||||
|
/// Seat of the last raiser, if any.
|
||||||
|
pub aggressor_seat: Option<SeatIndex>,
|
||||||
|
/// Action recorder for this hand.
|
||||||
|
pub recorder: &'a ActionRecorder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StrategyContext<'a> {
|
||||||
|
// --- Stack helpers ---
|
||||||
|
|
||||||
|
/// Our remaining chip stack.
|
||||||
|
pub fn our_stack(&self) -> ChipCount {
|
||||||
|
self.game_info
|
||||||
|
.player_by_id(self.our_id)
|
||||||
|
.map(|p| p.stack)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Big blind amount.
|
||||||
|
pub fn big_blind(&self) -> ChipCount {
|
||||||
|
self.game_info.big_blind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small blind amount.
|
||||||
|
pub fn small_blind(&self) -> ChipCount {
|
||||||
|
self.game_info.small_blind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ante amount (0 if no ante).
|
||||||
|
pub fn ante(&self) -> ChipCount {
|
||||||
|
self.game_info.ante
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our stack size expressed in big blinds.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// stack_in_bb = our_stack / big_blind
|
||||||
|
/// ```
|
||||||
|
pub fn stack_in_bb(&self) -> f64 {
|
||||||
|
let bb = self.big_blind() as f64;
|
||||||
|
if bb == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.our_stack() as f64 / bb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Harrington M ratio — stack divided by the cost of one round.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// M = our_stack / (sb + bb + ante × num_active)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// If ante is 0, simplifies to `our_stack / (sb + bb)`.
|
||||||
|
pub fn m_ratio(&self) -> f64 {
|
||||||
|
let cost_per_round = if self.ante() > 0 {
|
||||||
|
(self.small_blind() + self.big_blind()
|
||||||
|
+ self.ante() * self.num_active as u64) as f64
|
||||||
|
} else {
|
||||||
|
(self.small_blind() + self.big_blind()) as f64
|
||||||
|
};
|
||||||
|
if cost_per_round == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.our_stack() as f64 / cost_per_round
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective M adjusted for table size.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// effective_m = M × sqrt(num_active / 10)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// In short-handed games the blinds come around more often, so the
|
||||||
|
/// effective M is lower than the raw M.
|
||||||
|
pub fn effective_m(&self) -> f64 {
|
||||||
|
let m = self.m_ratio();
|
||||||
|
m * (self.num_active as f64 / 10.0).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Q ratio — our stack relative to the average stack.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Q = our_stack / average_stack
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Returns 1.0 when all stacks are equal. Values > 1.0 mean we have a
|
||||||
|
/// bigger-than-average stack.
|
||||||
|
pub fn q_ratio(&self) -> f64 {
|
||||||
|
let mut total_chips: u64 = 0;
|
||||||
|
let mut player_count: u64 = 0;
|
||||||
|
for player_opt in &self.game_info.players {
|
||||||
|
if let Some(p) = player_opt {
|
||||||
|
if p.is_in_hand() {
|
||||||
|
total_chips += p.stack + p.wagered_this_hand;
|
||||||
|
player_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if player_count == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let avg = total_chips as f64 / player_count as f64;
|
||||||
|
if avg == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let our_total = self.our_stack() as f64
|
||||||
|
+ self
|
||||||
|
.game_info
|
||||||
|
.player_by_id(self.our_id)
|
||||||
|
.map(|p| p.wagered_this_hand as f64)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
our_total / avg
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pot odds ---
|
||||||
|
|
||||||
|
/// Amount we need to call to stay in the hand.
|
||||||
|
pub fn amount_to_call(&self) -> ChipCount {
|
||||||
|
self.game_info.get_amount_to_call(self.our_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current pot size.
|
||||||
|
pub fn pot_size(&self) -> ChipCount {
|
||||||
|
self.game_info.pot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pot odds as a fraction: `amount_to_call / (pot_size + amount_to_call)`.
|
||||||
|
///
|
||||||
|
/// Returns 0.0 when there is nothing to call (we can check).
|
||||||
|
pub fn pot_odds(&self) -> f64 {
|
||||||
|
let to_call = self.amount_to_call();
|
||||||
|
if to_call == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let pot = self.pot_size();
|
||||||
|
to_call as f64 / (pot + to_call) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Position helpers ---
|
||||||
|
|
||||||
|
/// Whether we're the last to act in the current betting round.
|
||||||
|
///
|
||||||
|
/// Simplified heuristic: true if no active player with a higher position
|
||||||
|
/// is still in the hand and hasn't acted yet. For preflop with no raises,
|
||||||
|
/// the big blind is last to act.
|
||||||
|
pub fn i_am_last_to_act(&self) -> bool {
|
||||||
|
let our_seat = self.our_seat;
|
||||||
|
|
||||||
|
// If there's a next_to_act and it's not us, we're not last.
|
||||||
|
if let Some(next) = self.game_info.next_to_act {
|
||||||
|
if next != our_seat {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are last if next_to_act is our seat (or None and we're the
|
||||||
|
// only active player).
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any opponent has a stack greater than `factor × our_stack`.
|
||||||
|
///
|
||||||
|
/// Useful for determining if we can be covered (e.g., for all-in
|
||||||
|
/// decisions). A factor of 1.0 checks if anyone simply has us covered.
|
||||||
|
pub fn we_are_covered(&self, factor: f64) -> bool {
|
||||||
|
let our_stack = self.our_stack() as f64;
|
||||||
|
let threshold = factor * our_stack;
|
||||||
|
|
||||||
|
for player_opt in &self.game_info.players {
|
||||||
|
if let Some(p) = player_opt {
|
||||||
|
// Only consider opponents who are still in the hand
|
||||||
|
if p.id != self.our_id && p.is_in_hand() {
|
||||||
|
let opp_total = (p.stack + p.wagered_this_hand) as f64;
|
||||||
|
if opp_total > threshold {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective stack size in big blinds — the smallest active stack in the
|
||||||
|
/// pot, capped by our own stack.
|
||||||
|
///
|
||||||
|
/// This is the most we can win (or lose) in the hand against a single
|
||||||
|
/// opponent.
|
||||||
|
pub fn effective_stack_bb(&self) -> f64 {
|
||||||
|
let our_stack = self.our_stack();
|
||||||
|
let mut min_stack = our_stack;
|
||||||
|
|
||||||
|
for player_opt in &self.game_info.players {
|
||||||
|
if let Some(p) = player_opt {
|
||||||
|
if p.id != self.our_id && p.is_in_hand() {
|
||||||
|
min_stack = min_stack.min(p.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bb = self.big_blind() as f64;
|
||||||
|
if bb == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
min_stack as f64 / bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game::betting::BetLimit;
|
||||||
|
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||||
|
use crate::board::Street;
|
||||||
|
|
||||||
|
/// Helper to build a minimal `GameInfo` for testing.
|
||||||
|
fn make_game_info(
|
||||||
|
pot: ChipCount,
|
||||||
|
current_bet: ChipCount,
|
||||||
|
big_blind: ChipCount,
|
||||||
|
small_blind: ChipCount,
|
||||||
|
ante: ChipCount,
|
||||||
|
players: Vec<Option<PlayerInfo>>,
|
||||||
|
) -> GameInfo {
|
||||||
|
GameInfo {
|
||||||
|
num_seats: players.len() as SeatIndex,
|
||||||
|
bet_limit: BetLimit::NoLimit {
|
||||||
|
min_raise: big_blind,
|
||||||
|
},
|
||||||
|
small_blind,
|
||||||
|
big_blind,
|
||||||
|
ante,
|
||||||
|
street: Street::Flop,
|
||||||
|
board: vec![],
|
||||||
|
pot,
|
||||||
|
current_bet,
|
||||||
|
min_raise: big_blind,
|
||||||
|
button_seat: 2,
|
||||||
|
small_blind_seat: 0,
|
||||||
|
big_blind_seat: 1,
|
||||||
|
next_to_act: Some(0),
|
||||||
|
players,
|
||||||
|
actions_this_hand: vec![vec![], vec![]],
|
||||||
|
num_raises: 1,
|
||||||
|
hand_number: 1,
|
||||||
|
active_players: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_player(
|
||||||
|
id: u64,
|
||||||
|
seat: SeatIndex,
|
||||||
|
stack: ChipCount,
|
||||||
|
wagered_this_round: ChipCount,
|
||||||
|
wagered_this_hand: ChipCount,
|
||||||
|
state: PlayerState,
|
||||||
|
) -> PlayerInfo {
|
||||||
|
PlayerInfo {
|
||||||
|
id: PlayerId(id),
|
||||||
|
name: format!("Player {}", id),
|
||||||
|
seat,
|
||||||
|
stack,
|
||||||
|
wagered_this_round,
|
||||||
|
wagered_this_hand,
|
||||||
|
state,
|
||||||
|
has_acted_this_round: false,
|
||||||
|
is_dealer: false,
|
||||||
|
is_small_blind: seat == 0,
|
||||||
|
is_big_blind: seat == 1,
|
||||||
|
bankroll: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_recorder() -> ActionRecorder {
|
||||||
|
ActionRecorder::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stack_in_bb() {
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 300, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 500 / 100 = 5.0 BB
|
||||||
|
assert!((ctx.stack_in_bb() - 5.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stack_in_bb_zero_blind() {
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(0, 0, 0, 0, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 1,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0 BB should not panic, returns 0.0
|
||||||
|
assert!((ctx.stack_in_bb() - 0.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pot_odds() {
|
||||||
|
// Pot is 200, need to call 100 → odds = 100 / (200 + 100) = 0.333...
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 100, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 500, 100, 100, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(200, 100, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 1,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: Some(1),
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
let odds = ctx.pot_odds();
|
||||||
|
assert!((odds - 1.0 / 3.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pot_odds_zero_to_call() {
|
||||||
|
// Nothing to call → pot odds = 0.0
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 500, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!((ctx.pot_odds() - 0.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_we_are_covered_true() {
|
||||||
|
// We have 500, opponent has 1500. factor=1.0 → threshold=500 → covered
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 1500, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(ctx.we_are_covered(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_we_are_covered_false() {
|
||||||
|
// We have 500, opponent has 400. factor=1.0 → threshold=500 → not covered
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 400, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!ctx.we_are_covered(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_we_are_covered_with_factor() {
|
||||||
|
// We have 500, opponent has 800. factor=2.0 → threshold=1000 → not covered
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 800, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!ctx.we_are_covered(2.0));
|
||||||
|
assert!(ctx.we_are_covered(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m_ratio_no_ante() {
|
||||||
|
// Stack=1000, sb=50, bb=100, no ante → M = 1000 / 150 ≈ 6.67
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(150, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
let m = ctx.m_ratio();
|
||||||
|
assert!((m - 1000.0 / 150.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m_ratio_with_ante() {
|
||||||
|
// Stack=1000, sb=50, bb=100, ante=25, 2 players
|
||||||
|
// cost_per_round = 50 + 100 + 25*2 = 200
|
||||||
|
// M = 1000 / 200 = 5.0
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(200, 0, 100, 50, 25, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
let m = ctx.m_ratio();
|
||||||
|
assert!((m - 5.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_m() {
|
||||||
|
// 2 active players, M = 5.0
|
||||||
|
// effective_m = 5.0 * sqrt(2/10) = 5.0 * sqrt(0.2)
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(200, 0, 100, 50, 25, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
let eff_m = ctx.effective_m();
|
||||||
|
let expected = 5.0 * (2.0_f64 / 10.0).sqrt();
|
||||||
|
assert!((eff_m - expected).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_q_ratio() {
|
||||||
|
// Both have 1000 → Q = 1.0
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!((ctx.q_ratio() - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_stack_bb() {
|
||||||
|
// We have 500, opponent has 300 → effective = min(500, 300) = 300
|
||||||
|
// 300 / 100 = 3.0 BB
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 300, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!((ctx.effective_stack_bb() - 3.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_amount_to_call() {
|
||||||
|
// current_bet=200, we wagered 0 this round → need to call 200
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 500, 200, 200, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(200, 200, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 1,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: Some(1),
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(ctx.amount_to_call(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_we_are_covered_ignores_folded() {
|
||||||
|
// We have 500, one folded opponent with 2000, one active with 300
|
||||||
|
// → not covered (folded opponent is ignored)
|
||||||
|
let players = vec![
|
||||||
|
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
|
||||||
|
Some(make_player(2, 1, 2000, 0, 0, PlayerState::Folded)),
|
||||||
|
Some(make_player(3, 2, 300, 0, 0, PlayerState::Active)),
|
||||||
|
];
|
||||||
|
let gi = make_game_info(100, 0, 100, 50, 0, players);
|
||||||
|
let recorder = make_recorder();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
game_info: &gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder: &recorder,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!ctx.we_are_covered(1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
621
holdem_core/src/game/strategy/hand_analyzer.rs
Normal file
621
holdem_core/src/game/strategy/hand_analyzer.rs
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
//! Hand analyzer — evaluates hand strength and equity for strategy decisions.
|
||||||
|
//!
|
||||||
|
//! Ported from the Java `HandAnalyzer` class in the Supersonic framework.
|
||||||
|
//! Provides static analysis methods for poker hands combining hole cards and
|
||||||
|
//! community board cards.
|
||||||
|
|
||||||
|
use crate::board::Board;
|
||||||
|
use crate::card::Card;
|
||||||
|
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||||
|
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||||
|
use crate::hand::Hand;
|
||||||
|
use crate::hole_cards::HoleCards;
|
||||||
|
|
||||||
|
/// Stateless hand analysis utility.
|
||||||
|
///
|
||||||
|
/// All methods are associated functions (no `&self`) that operate on
|
||||||
|
/// `&HoleCards` and `&Board` references, delegating to the
|
||||||
|
/// [`AlgorithmicEvaluator`] for hand ranking.
|
||||||
|
pub struct HandAnalyzer;
|
||||||
|
|
||||||
|
impl HandAnalyzer {
|
||||||
|
/// Evaluate the combined hand (hole cards + board) and return its [`HandValue`].
|
||||||
|
fn evaluate(hole: &HoleCards, board: &Board) -> HandValue {
|
||||||
|
let hand = Hand::from_hole_cards_and_board(hole, board)
|
||||||
|
.expect("hole + board should produce a valid hand");
|
||||||
|
let cards = hand.cards();
|
||||||
|
match cards.len() {
|
||||||
|
5 => {
|
||||||
|
let a: [Card; 5] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let a: [Card; 6] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_6_card(&a)
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
|
let a: [Card; 7] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_7_card(&a)
|
||||||
|
}
|
||||||
|
_ => HandValue::new(HandRank::HighCard, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate the board-only hand (no hole cards) and return its [`HandValue`].
|
||||||
|
fn evaluate_board(board: &Board) -> HandValue {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
match cards.len() {
|
||||||
|
5 => {
|
||||||
|
let a: [Card; 5] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let a: [Card; 6] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_6_card(&a)
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
|
let a: [Card; 7] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_7_card(&a)
|
||||||
|
}
|
||||||
|
_ => HandValue::new(HandRank::HighCard, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the hole cards form a pocket pair whose rank is strictly above
|
||||||
|
/// every board card rank.
|
||||||
|
///
|
||||||
|
/// Port of Java `isOverPair`.
|
||||||
|
pub fn is_over_pair(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let c0 = hole.cards[0];
|
||||||
|
let c1 = hole.cards[1];
|
||||||
|
if c0.rank() != c1.rank() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pair_rank = c0.rank();
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
if bc.rank() >= pair_rank {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if any two cards in the combined hole+board share the same rank.
|
||||||
|
///
|
||||||
|
/// Port of Java `isPaired`.
|
||||||
|
pub fn is_paired(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let mut ranks = Vec::new();
|
||||||
|
ranks.push(hole.cards[0].rank());
|
||||||
|
ranks.push(hole.cards[1].rank());
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
ranks.push(bc.rank());
|
||||||
|
}
|
||||||
|
// Check for any duplicate rank
|
||||||
|
for i in 1..ranks.len() {
|
||||||
|
for j in 0..i {
|
||||||
|
if ranks[i] == ranks[j] {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the hand is two pair using **both** hole cards (not just a
|
||||||
|
/// board pair).
|
||||||
|
///
|
||||||
|
/// Port of Java `isRealTwoPair`.
|
||||||
|
pub fn is_real_two_pair(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
// If hole cards are a pocket pair, it's not a "real two pair" using both
|
||||||
|
if hole.cards[0].rank() == hole.cards[1].rank() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// If the board itself is paired, it's not a "real" two pair
|
||||||
|
if board_is_paired(board) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Self::evaluate(hole, board).rank == HandRank::TwoPair
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the hand rank is ≥ Three of a Kind.
|
||||||
|
///
|
||||||
|
/// Port of Java `isTripsOrHigher`.
|
||||||
|
pub fn is_trips_or_higher(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
Self::evaluate(hole, board).rank >= HandRank::ThreeOfAKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count remaining deck cards that improve the hand to two-pair or better.
|
||||||
|
///
|
||||||
|
/// Iterates all 52 cards, skips those already in hole+board, simulates
|
||||||
|
/// adding each remaining card to the board, and counts cards that improve
|
||||||
|
/// the hand rank.
|
||||||
|
///
|
||||||
|
/// Port of Java `numOutsToMonster`.
|
||||||
|
pub fn num_outs_to_monster(hole: &HoleCards, board: &Board) -> u32 {
|
||||||
|
let board_cards = board.visible_cards();
|
||||||
|
if board_cards.len() >= 5 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_rank = Self::evaluate(hole, board).rank;
|
||||||
|
|
||||||
|
// Collect cards already in play
|
||||||
|
let mut in_play = [false; 52];
|
||||||
|
for c in &hole.cards {
|
||||||
|
in_play[card_index(*c)] = true;
|
||||||
|
}
|
||||||
|
for c in board_cards {
|
||||||
|
in_play[card_index(*c)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut outs = 0u32;
|
||||||
|
for idx in 0..52u8 {
|
||||||
|
if in_play[idx as usize] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let test_card = Card::new(idx % 13, idx / 13).unwrap();
|
||||||
|
|
||||||
|
// Build a new board with the extra card
|
||||||
|
let mut new_board_cards: Vec<Card> = board_cards.to_vec();
|
||||||
|
new_board_cards.push(test_card);
|
||||||
|
let new_board = build_board(&new_board_cards);
|
||||||
|
|
||||||
|
let new_rank = Self::evaluate(hole, &new_board).rank;
|
||||||
|
if new_rank > current_rank
|
||||||
|
&& (new_rank >= HandRank::TwoPair
|
||||||
|
|| Self::is_real_two_pair(hole, &new_board)
|
||||||
|
|| Self::is_trips_or_higher(hole, &new_board))
|
||||||
|
{
|
||||||
|
outs += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the combined hole+board contains an open-ended straight draw
|
||||||
|
/// (4 consecutive ranks, not a gutshot).
|
||||||
|
///
|
||||||
|
/// Port of Java `isOESD`. Only valid for 3-4 board cards.
|
||||||
|
pub fn is_oesd(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let board_cards = board.visible_cards();
|
||||||
|
if board_cards.len() > 4 || board_cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sc = Self::straight_count(hole, board);
|
||||||
|
if sc != 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the high card of the straight run
|
||||||
|
let hi = straight_hi_rank(hole, board);
|
||||||
|
// Not OESD if the high card is Ace (only gutshot from bottom) or
|
||||||
|
// the low card would be below 2 (rank 0) — i.e. high card is 3 (rank 1).
|
||||||
|
// In Java: not OESD if hiCard rank == ACE or hiCard rank == FOUR.
|
||||||
|
// Ace rank in our system = 12, Four rank = 2.
|
||||||
|
// The "four" check means the run is A-2-3-4 which is wheel gutshot.
|
||||||
|
// Actually in Java: straightHiCard rank != ACE && != FOUR.
|
||||||
|
// Rank 3 (Five) as hi means 5-4-3-2, low would be Ace → also gutshot.
|
||||||
|
// Let's follow the Java logic: exclude when hi card is Ace (12) or
|
||||||
|
// when the sequence ends at the bottom (hi rank == 3 = Five, meaning 5-4-3-2).
|
||||||
|
// Java FOUR = rank index 2 in Meerkat (0-indexed from 2).
|
||||||
|
// In our system: rank 2 = Four. The Java check is:
|
||||||
|
// straightHiCard != ACE && straightHiCard != FOUR
|
||||||
|
// This means: if the 4-sequence high card is Four (rank 2), the sequence
|
||||||
|
// is 4-3-2-A which is a wheel gutshot. If high is Ace, it's also not OESD.
|
||||||
|
hi != 12 && hi != 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the combined hole+board has 4 cards of the same suit (flush draw).
|
||||||
|
///
|
||||||
|
/// Port of Java `isFlushDraw`. Only valid for 3-4 board cards.
|
||||||
|
pub fn is_flush_draw(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let board_cards = board.visible_cards();
|
||||||
|
if board_cards.len() > 4 || board_cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Self::suit_count(hole, board) == 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the combined hole+board has 5+ cards of the same suit (complete flush).
|
||||||
|
///
|
||||||
|
/// Port of Java `isFlush`. Only valid for 3-5 board cards.
|
||||||
|
pub fn is_flush(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let board_cards = board.visible_cards();
|
||||||
|
if board_cards.len() > 5 || board_cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Self::suit_count(hole, board) >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if both hole card ranks are strictly above every board card rank.
|
||||||
|
///
|
||||||
|
/// Port of Java `isTwoOverCards`.
|
||||||
|
pub fn is_two_over_cards(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let board_cards = board.visible_cards();
|
||||||
|
if board_cards.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let r0 = hole.cards[0].rank();
|
||||||
|
let r1 = hole.cards[1].rank();
|
||||||
|
for bc in board_cards {
|
||||||
|
if bc.rank() >= r0 || bc.rank() >= r1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max count of any single suit across hole+board.
|
||||||
|
///
|
||||||
|
/// Port of Java `suitCount(card0, card1, board)`.
|
||||||
|
pub fn suit_count(hole: &HoleCards, board: &Board) -> u8 {
|
||||||
|
let mut counts = [0u8; 4];
|
||||||
|
counts[hole.cards[0].suit() as usize] += 1;
|
||||||
|
counts[hole.cards[1].suit() as usize] += 1;
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
counts[bc.suit() as usize] += 1;
|
||||||
|
}
|
||||||
|
counts.iter().max().copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max suit count on board only.
|
||||||
|
///
|
||||||
|
/// Port of Java `suitCount(board)`.
|
||||||
|
pub fn board_suit_count(board: &Board) -> u8 {
|
||||||
|
let mut counts = [0u8; 4];
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
counts[bc.suit() as usize] += 1;
|
||||||
|
}
|
||||||
|
counts.iter().max().copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of the longest consecutive rank sequence in hole+board.
|
||||||
|
///
|
||||||
|
/// Uses unique sorted ranks and finds the longest run.
|
||||||
|
/// Port of Java `straightCount(card0, card1, board)`.
|
||||||
|
pub fn straight_count(hole: &HoleCards, board: &Board) -> u8 {
|
||||||
|
let mut ranks: Vec<u8> = Vec::new();
|
||||||
|
ranks.push(hole.cards[0].rank());
|
||||||
|
ranks.push(hole.cards[1].rank());
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
ranks.push(bc.rank());
|
||||||
|
}
|
||||||
|
longest_consecutive_run(&ranks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of the longest consecutive rank sequence on board only.
|
||||||
|
///
|
||||||
|
/// Port of Java `straightCount(board)`.
|
||||||
|
pub fn board_straight_count(board: &Board) -> u8 {
|
||||||
|
let ranks: Vec<u8> = board.visible_cards().iter().map(|c| c.rank()).collect();
|
||||||
|
longest_consecutive_run(&ranks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the hole cards improve the board's hand rank.
|
||||||
|
///
|
||||||
|
/// Compares the evaluation of hole+board vs board-only.
|
||||||
|
/// Port of Java `weContribute`.
|
||||||
|
pub fn we_contribute(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let with_us = Self::evaluate(hole, board);
|
||||||
|
let board_only = Self::evaluate_board(board);
|
||||||
|
with_us != board_only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Convert a card to a unique index 0..51 (suit * 13 + rank).
|
||||||
|
fn card_index(c: Card) -> usize {
|
||||||
|
(c.suit() as usize) * 13 + (c.rank() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the board itself contains a pair.
|
||||||
|
fn board_is_paired(board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
for i in 1..cards.len() {
|
||||||
|
for j in 0..i {
|
||||||
|
if cards[i].rank() == cards[j].rank() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `Board` from a slice of cards (deals flop/turn/river as needed).
|
||||||
|
fn build_board(cards: &[Card]) -> Board {
|
||||||
|
let mut board = Board::new();
|
||||||
|
if cards.len() >= 3 {
|
||||||
|
board.deal_flop(vec![cards[0], cards[1], cards[2]]).unwrap();
|
||||||
|
}
|
||||||
|
if cards.len() >= 4 {
|
||||||
|
board.deal_turn(cards[3]).unwrap();
|
||||||
|
}
|
||||||
|
if cards.len() >= 5 {
|
||||||
|
board.deal_river(cards[4]).unwrap();
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the length of the longest consecutive run of ranks.
|
||||||
|
fn longest_consecutive_run(ranks: &[u8]) -> u8 {
|
||||||
|
if ranks.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Deduplicate and sort descending
|
||||||
|
let mut unique: Vec<u8> = ranks.to_vec();
|
||||||
|
unique.sort_unstable();
|
||||||
|
unique.dedup();
|
||||||
|
|
||||||
|
if unique.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best = 1u8;
|
||||||
|
let mut current = 1u8;
|
||||||
|
for i in 1..unique.len() {
|
||||||
|
if unique[i] == unique[i - 1] + 1 {
|
||||||
|
current += 1;
|
||||||
|
if current > best {
|
||||||
|
best = current;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the rank of the highest card in the longest consecutive run.
|
||||||
|
/// Used by `is_oesd` to determine if the draw is open-ended.
|
||||||
|
fn straight_hi_rank(hole: &HoleCards, board: &Board) -> u8 {
|
||||||
|
let mut ranks: Vec<u8> = Vec::new();
|
||||||
|
ranks.push(hole.cards[0].rank());
|
||||||
|
ranks.push(hole.cards[1].rank());
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
ranks.push(bc.rank());
|
||||||
|
}
|
||||||
|
let mut unique: Vec<u8> = ranks.to_vec();
|
||||||
|
unique.sort_unstable();
|
||||||
|
unique.dedup();
|
||||||
|
|
||||||
|
if unique.len() < 2 {
|
||||||
|
return unique.first().copied().unwrap_or(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best_len = 1u8;
|
||||||
|
let mut best_hi = unique[0];
|
||||||
|
let mut current_len = 1u8;
|
||||||
|
|
||||||
|
for i in 1..unique.len() {
|
||||||
|
if unique[i] == unique[i - 1] + 1 {
|
||||||
|
current_len += 1;
|
||||||
|
if current_len > best_len {
|
||||||
|
best_len = current_len;
|
||||||
|
best_hi = unique[i];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current_len = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return the LOWEST rank of the best run (to match Java's straightHiCard
|
||||||
|
// which returns the start of the run, i.e. the lowest card in the sequence).
|
||||||
|
// Actually, Java returns the card at the start of the longest run, which is
|
||||||
|
// the LOWEST rank. Let's compute that.
|
||||||
|
// best_hi is the highest. The lowest = best_hi - (best_len - 1).
|
||||||
|
// But for the OESD check we need the high card. Let's return the high card.
|
||||||
|
best_hi
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn make_board(cards: &[&str]) -> Board {
|
||||||
|
let mut board = Board::new();
|
||||||
|
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
|
||||||
|
if parsed.len() >= 3 {
|
||||||
|
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 4 {
|
||||||
|
board.deal_turn(parsed[3]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 5 {
|
||||||
|
board.deal_river(parsed[4]).unwrap();
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||||
|
HoleCards::new(
|
||||||
|
Card::from_str(c1).unwrap(),
|
||||||
|
Card::from_str(c2).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_over_pair_true() {
|
||||||
|
// Pocket Kings above a T-high board
|
||||||
|
let hole = make_hole("Ks", "Kh");
|
||||||
|
let board = make_board(&["Ts", "7d", "2c"]);
|
||||||
|
assert!(HandAnalyzer::is_over_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_over_pair_false_not_pair() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Ts", "7d", "2c"]);
|
||||||
|
assert!(!HandAnalyzer::is_over_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_over_pair_false_rank_on_board() {
|
||||||
|
// Pocket TT with a T on board — not an overpair
|
||||||
|
let hole = make_hole("Ts", "Th");
|
||||||
|
let board = make_board(&["Td", "7d", "2c"]);
|
||||||
|
assert!(!HandAnalyzer::is_over_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_flush_draw() {
|
||||||
|
// 4 hearts: Ah Kh from hole, Qh Jh on board
|
||||||
|
let hole = make_hole("Ah", "Kh");
|
||||||
|
let board = make_board(&["Qh", "Jh", "2d"]);
|
||||||
|
assert!(HandAnalyzer::is_flush_draw(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_flush_draw() {
|
||||||
|
let hole = make_hole("Ah", "Kd");
|
||||||
|
let board = make_board(&["Qh", "Jh", "2d"]);
|
||||||
|
assert!(!HandAnalyzer::is_flush_draw(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_paired_with_hole_pair() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Kd", "Qh", "Jc"]);
|
||||||
|
assert!(HandAnalyzer::is_paired(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_paired_with_board_pair() {
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Qh", "Qc", "Jc"]);
|
||||||
|
assert!(HandAnalyzer::is_paired(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_paired() {
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Qh", "Jc", "9s"]);
|
||||||
|
assert!(!HandAnalyzer::is_paired(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_straight_count_five_card_run() {
|
||||||
|
// A-K-Q-J-T
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Qh", "Jc", "Ts"]);
|
||||||
|
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_straight_count_four_card_run() {
|
||||||
|
// K-Q-J-T (no A)
|
||||||
|
let hole = make_hole("Ks", "Qd");
|
||||||
|
let board = make_board(&["Jh", "Tc", "4s"]);
|
||||||
|
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_straight_count_no_run() {
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["7h", "2c", "9s"]);
|
||||||
|
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suit_count() {
|
||||||
|
let hole = make_hole("Ah", "Kh");
|
||||||
|
let board = make_board(&["Qh", "Jd", "2c"]);
|
||||||
|
assert_eq!(HandAnalyzer::suit_count(&hole, &board), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_board_suit_count() {
|
||||||
|
let board = make_board(&["Ah", "Kh", "Qh"]);
|
||||||
|
assert_eq!(HandAnalyzer::board_suit_count(&board), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_two_over_cards() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Qd", "Jc", "Ts"]);
|
||||||
|
assert!(HandAnalyzer::is_two_over_cards(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_two_over_cards() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Ad", "Jc", "Ts"]);
|
||||||
|
assert!(!HandAnalyzer::is_two_over_cards(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_trips_or_higher() {
|
||||||
|
// Three aces
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Ad", "Kc", "Qs"]);
|
||||||
|
assert!(HandAnalyzer::is_trips_or_higher(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_trips_or_higher() {
|
||||||
|
// Just a pair
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Ah", "Qc", "Js"]);
|
||||||
|
assert!(!HandAnalyzer::is_trips_or_higher(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_we_contribute() {
|
||||||
|
// Hole cards improve the board
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Kd", "Qc", "Js"]);
|
||||||
|
assert!(HandAnalyzer::we_contribute(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_flush_complete() {
|
||||||
|
let hole = make_hole("Ah", "Kh");
|
||||||
|
let board = make_board(&["Qh", "Jh", "Th"]);
|
||||||
|
assert!(HandAnalyzer::is_flush(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_board_straight_count() {
|
||||||
|
let board = make_board(&["Ts", "9d", "8c"]);
|
||||||
|
assert_eq!(HandAnalyzer::board_straight_count(&board), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_real_two_pair() {
|
||||||
|
// AK with A-K-9-7-2 board → two pair AA-KK (no straight, no board pair)
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Ah", "Kc", "9s", "7c", "2s"]);
|
||||||
|
assert!(HandAnalyzer::is_real_two_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_real_two_pair_false_pocket_pair() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Kd", "Qc", "Js"]);
|
||||||
|
assert!(!HandAnalyzer::is_real_two_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_oesd() {
|
||||||
|
// K-Q-J-T with 4-card straight, open-ended
|
||||||
|
let hole = make_hole("Ks", "Qd");
|
||||||
|
let board = make_board(&["Jh", "Tc", "4s"]);
|
||||||
|
assert!(HandAnalyzer::is_oesd(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_oesd_false_broadway_top() {
|
||||||
|
// A-K-Q-J is NOT OESD because high card is Ace
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Qh", "Jc", "4s"]);
|
||||||
|
assert!(!HandAnalyzer::is_oesd(&hole, &board));
|
||||||
|
}
|
||||||
|
}
|
||||||
678
holdem_core/src/game/strategy/hand_helper.rs
Normal file
678
holdem_core/src/game/strategy/hand_helper.rs
Normal file
|
|
@ -0,0 +1,678 @@
|
||||||
|
//! Hand helper — utility functions for hand evaluation in strategy contexts.
|
||||||
|
//!
|
||||||
|
//! Ported from the Java `HandHelper` class. Provides board texture analysis,
|
||||||
|
//! hand strength categorization, and the Kill Phil hand grouping system.
|
||||||
|
|
||||||
|
use crate::board::Board;
|
||||||
|
use crate::card::Card;
|
||||||
|
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
|
||||||
|
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||||
|
use crate::hand::Hand;
|
||||||
|
use crate::hole_cards::HoleCards;
|
||||||
|
|
||||||
|
/// Static utility class for hand and board analysis.
|
||||||
|
pub struct HandHelper;
|
||||||
|
|
||||||
|
impl HandHelper {
|
||||||
|
/// Evaluate the combined hand (hole cards + board).
|
||||||
|
fn evaluate(hole: &HoleCards, board: &Board) -> HandValue {
|
||||||
|
let hand = Hand::from_hole_cards_and_board(hole, board)
|
||||||
|
.expect("hole + board should produce a valid hand");
|
||||||
|
let cards = hand.cards();
|
||||||
|
match cards.len() {
|
||||||
|
5 => {
|
||||||
|
let a: [Card; 5] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let a: [Card; 6] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_6_card(&a)
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
|
let a: [Card; 7] = cards.try_into().unwrap();
|
||||||
|
AlgorithmicEvaluator::evaluate_7_card(&a)
|
||||||
|
}
|
||||||
|
_ => HandValue::new(HandRank::HighCard, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board texture: all visible board cards share the same suit.
|
||||||
|
///
|
||||||
|
/// Port of Java `suitedFlop`.
|
||||||
|
pub fn is_suited_flop(board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let suit = cards[0].suit();
|
||||||
|
cards.iter().all(|c| c.suit() == suit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board texture: exactly two suits present on the board.
|
||||||
|
///
|
||||||
|
/// Port of Java `twoSuitedFlop`.
|
||||||
|
pub fn is_two_suited_flop(board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut suits = [false; 4];
|
||||||
|
for c in cards {
|
||||||
|
suits[c.suit() as usize] = true;
|
||||||
|
}
|
||||||
|
suits.iter().filter(|&&s| s).count() == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board has straight/flush potential that threatens made hands.
|
||||||
|
///
|
||||||
|
/// Returns true if the board has:
|
||||||
|
/// - 3+ cards of the same suit, or
|
||||||
|
/// - 3+ consecutive ranks, or
|
||||||
|
/// - A paired board
|
||||||
|
///
|
||||||
|
/// Port of Java `dangerousBoard`.
|
||||||
|
pub fn is_dangerous_board(board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 3+ of the same suit
|
||||||
|
let mut suit_counts = [0u8; 4];
|
||||||
|
for c in cards {
|
||||||
|
suit_counts[c.suit() as usize] += 1;
|
||||||
|
}
|
||||||
|
if suit_counts.iter().any(|&c| c >= 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 3+ consecutive ranks
|
||||||
|
let mut ranks: Vec<u8> = cards.iter().map(|c| c.rank()).collect();
|
||||||
|
ranks.sort_unstable();
|
||||||
|
ranks.dedup();
|
||||||
|
|
||||||
|
let mut consecutive = 1u8;
|
||||||
|
for i in 1..ranks.len() {
|
||||||
|
if ranks[i] == ranks[i - 1] + 1 {
|
||||||
|
consecutive += 1;
|
||||||
|
if consecutive >= 3 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutive = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for paired board
|
||||||
|
let mut rank_counts = [0u8; 13];
|
||||||
|
for c in cards {
|
||||||
|
rank_counts[c.rank() as usize] += 1;
|
||||||
|
}
|
||||||
|
if rank_counts.iter().any(|&c| c >= 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open-ended straight draw on the board (4 consecutive ranks using
|
||||||
|
/// combined hole+board cards).
|
||||||
|
///
|
||||||
|
/// Port of Java `openEndedStraightDraw`.
|
||||||
|
pub fn is_open_ended_straight_draw(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let mut ranks: Vec<u8> = Vec::new();
|
||||||
|
ranks.push(hole.cards[0].rank());
|
||||||
|
ranks.push(hole.cards[1].rank());
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
ranks.push(bc.rank());
|
||||||
|
}
|
||||||
|
ranks.sort_unstable();
|
||||||
|
ranks.dedup();
|
||||||
|
|
||||||
|
// Check for 4 consecutive ranks
|
||||||
|
if ranks.len() < 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut consecutive = 1u8;
|
||||||
|
for i in 1..ranks.len() {
|
||||||
|
if ranks[i] == ranks[i - 1] + 1 {
|
||||||
|
consecutive += 1;
|
||||||
|
if consecutive >= 4 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutive = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush draw on the board: 4+ cards of the same suit in combined
|
||||||
|
/// hole+board.
|
||||||
|
///
|
||||||
|
/// Port of Java `flushDraw`.
|
||||||
|
pub fn is_flush_draw_board(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let mut counts = [0u8; 4];
|
||||||
|
counts[hole.cards[0].suit() as usize] += 1;
|
||||||
|
counts[hole.cards[1].suit() as usize] += 1;
|
||||||
|
for bc in board.visible_cards() {
|
||||||
|
counts[bc.suit() as usize] += 1;
|
||||||
|
}
|
||||||
|
counts.iter().any(|&c| c >= 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count drawing outs — estimate of cards that improve the hand.
|
||||||
|
///
|
||||||
|
/// Simplified port of Java `drawingOuts`. Counts:
|
||||||
|
/// - 8 outs for open-ended straight draw
|
||||||
|
/// - 9 outs for flush draw
|
||||||
|
/// - 2 outs for pocket pair (set draw)
|
||||||
|
/// - 6 outs for two overcards
|
||||||
|
/// - 3 outs for one overcard
|
||||||
|
pub fn drawing_outs(hole: &HoleCards, board: &Board) -> u32 {
|
||||||
|
let mut outs = 0u32;
|
||||||
|
|
||||||
|
let has_oesd = Self::is_open_ended_straight_draw(hole, board);
|
||||||
|
let has_flush_draw = Self::is_flush_draw_board(hole, board);
|
||||||
|
|
||||||
|
if has_oesd {
|
||||||
|
outs += 8;
|
||||||
|
}
|
||||||
|
if has_flush_draw {
|
||||||
|
outs += 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pocket pair → set draw
|
||||||
|
if hole.cards[0].rank() == hole.cards[1].rank() {
|
||||||
|
outs += 2;
|
||||||
|
} else if Self::is_overcards(hole, board) {
|
||||||
|
outs += 6;
|
||||||
|
} else if Self::is_one_overcard(hole, board) {
|
||||||
|
outs += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
outs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate hand strength, returning the [`HandRank`].
|
||||||
|
pub fn evaluate_hand(hole: &HoleCards, board: &Board) -> HandRank {
|
||||||
|
Self::evaluate(hole, board).rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the highest hole card pairs with the highest board card.
|
||||||
|
///
|
||||||
|
/// Port of Java `topPair`.
|
||||||
|
pub fn is_top_pair(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
|
||||||
|
let r0 = hole.cards[0].rank();
|
||||||
|
let r1 = hole.cards[1].rank();
|
||||||
|
r0 == highest_board_rank || r1 == highest_board_rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if pocket pair is above all board cards.
|
||||||
|
///
|
||||||
|
/// Port of Java `overpair`.
|
||||||
|
pub fn is_overpair(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let r0 = hole.cards[0].rank();
|
||||||
|
let r1 = hole.cards[1].rank();
|
||||||
|
if r0 != r1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
|
||||||
|
r0 > highest_board_rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if both hole cards are higher than the highest board card.
|
||||||
|
///
|
||||||
|
/// Port of Java `overcards`.
|
||||||
|
pub fn is_overcards(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
|
||||||
|
hole.cards[0].rank() > highest_board_rank && hole.cards[1].rank() > highest_board_rank
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if exactly one hole card is higher than the highest board card.
|
||||||
|
fn is_one_overcard(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
|
||||||
|
let r0_over = hole.cards[0].rank() > highest_board_rank;
|
||||||
|
let r1_over = hole.cards[1].rank() > highest_board_rank;
|
||||||
|
r0_over ^ r1_over // exactly one
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if hand is top pair top kicker or better.
|
||||||
|
///
|
||||||
|
/// Port of Java `tptkOrBetter`.
|
||||||
|
pub fn is_tptk_or_better(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let hv = Self::evaluate(hole, board);
|
||||||
|
// Two pair or better
|
||||||
|
if hv.rank > HandRank::Pair {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Overpair counts
|
||||||
|
if Self::is_overpair(hole, board) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Top pair with top kicker
|
||||||
|
if Self::is_top_pair(hole, board) && Self::is_top_kicker(hole, board) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if hand is three of a kind or better.
|
||||||
|
///
|
||||||
|
/// Port of Java `setOrBetter`.
|
||||||
|
pub fn is_set_or_better(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let hv = Self::evaluate(hole, board);
|
||||||
|
if hv.rank > HandRank::ThreeOfAKind {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if hv.rank == HandRank::ThreeOfAKind && hole.cards[0].rank() == hole.cards[1].rank() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if hand is full house or better.
|
||||||
|
///
|
||||||
|
/// Port of Java `fullHouseOrBetter`.
|
||||||
|
pub fn is_full_house_or_better(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
Self::evaluate(hole, board).rank >= HandRank::FullHouse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the highest hole card that pairs the board is the top kicker.
|
||||||
|
fn is_top_kicker(hole: &HoleCards, board: &Board) -> bool {
|
||||||
|
let cards = board.visible_cards();
|
||||||
|
if cards.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
|
||||||
|
let second_highest_board_rank = cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.rank())
|
||||||
|
.filter(|&r| r != highest_board_rank)
|
||||||
|
.max();
|
||||||
|
|
||||||
|
// Find which hole card pairs with the board high card
|
||||||
|
let kicker_rank = if hole.cards[0].rank() == highest_board_rank {
|
||||||
|
hole.cards[1].rank()
|
||||||
|
} else if hole.cards[1].rank() == highest_board_rank {
|
||||||
|
hole.cards[0].rank()
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kicker is top if it's an Ace, or higher than second-highest board card
|
||||||
|
match second_highest_board_rank {
|
||||||
|
Some(second) => kicker_rank >= second,
|
||||||
|
None => true, // All board cards same rank, any kicker is "top"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill Phil hand grouping (1–10 scale, 1 = best).
|
||||||
|
///
|
||||||
|
/// Groups preflop hands by strength tiers based on the Kill Phil system.
|
||||||
|
/// Returns 0 if the hand doesn't fit any group.
|
||||||
|
///
|
||||||
|
/// Port of Java `getKPGroup`.
|
||||||
|
pub fn kill_phil_group(hole: &HoleCards) -> u8 {
|
||||||
|
let r0 = hole.cards[0].rank(); // higher rank (HoleCards stores desc)
|
||||||
|
let r1 = hole.cards[1].rank(); // lower rank
|
||||||
|
let suited = hole.cards[0].suit() == hole.cards[1].suit();
|
||||||
|
let pair = r0 == r1;
|
||||||
|
|
||||||
|
// Helper: rank constants (0=Two .. 12=Ace)
|
||||||
|
const ACE: u8 = 12;
|
||||||
|
const KING: u8 = 11;
|
||||||
|
const QUEEN: u8 = 10;
|
||||||
|
const JACK: u8 = 9;
|
||||||
|
const TEN: u8 = 8;
|
||||||
|
const NINE: u8 = 7;
|
||||||
|
const EIGHT: u8 = 6;
|
||||||
|
const SEVEN: u8 = 5;
|
||||||
|
const SIX: u8 = 4;
|
||||||
|
const FOUR: u8 = 2;
|
||||||
|
const THREE: u8 = 1;
|
||||||
|
|
||||||
|
// Group 1: AA, KK
|
||||||
|
if pair && (r0 == ACE || r0 == KING) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 2: QQ, AK (any suit)
|
||||||
|
if pair && r0 == QUEEN {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if r0 == ACE && r1 == KING {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 3: JJ, TT, 99
|
||||||
|
if pair && (r0 == JACK || r0 == TEN || r0 == NINE) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 4: AQs, 88, 77, 66
|
||||||
|
if r0 == ACE && r1 == QUEEN && suited {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if pair && (r0 == EIGHT || r0 == SEVEN || r0 == SIX) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 5: AQo, AJs, ATs
|
||||||
|
if r0 == ACE && r1 == QUEEN {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if r0 == ACE && r1 == JACK && suited {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
if r0 == ACE && r1 == TEN && suited {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 6: Suited connectors with both cards ≥ Five (rank 3)
|
||||||
|
if suited && r1 >= FOUR && r0 == r1 + 1 {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 7: AJo, ATo, KQ
|
||||||
|
if r0 == ACE && r1 == JACK {
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
if r0 == ACE && r1 == TEN {
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == QUEEN {
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 8: Any pocket pair, AXs, AX with both ≥ Seven, KJs, KJ, KTs
|
||||||
|
if pair {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if r0 == ACE && suited {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if r0 == ACE && r1 >= SIX {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == JACK && suited {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == JACK {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == TEN && suited {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 9: Suited one-gappers with both ≥ Five, AX
|
||||||
|
if suited && r1 >= FOUR && r0 == r1 + 2 {
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
if r0 == ACE {
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 10: Suited two-gappers with both ≥ Four, KXs with both ≥ Seven,
|
||||||
|
// Q8s, KTo, QTo, JTo, K9o, QJo
|
||||||
|
if suited && r1 >= THREE && r0 == r1 + 3 {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == KING && suited && r1 >= SIX {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == QUEEN && r1 == EIGHT && suited {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == TEN {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == QUEEN && r1 == TEN {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == JACK && r1 == TEN {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == KING && r1 == NINE {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if r0 == QUEEN && r1 == JACK {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
0 // Doesn't fit any group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn make_board(cards: &[&str]) -> Board {
|
||||||
|
let mut board = Board::new();
|
||||||
|
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
|
||||||
|
if parsed.len() >= 3 {
|
||||||
|
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 4 {
|
||||||
|
board.deal_turn(parsed[3]).unwrap();
|
||||||
|
}
|
||||||
|
if parsed.len() >= 5 {
|
||||||
|
board.deal_river(parsed[4]).unwrap();
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||||
|
HoleCards::new(
|
||||||
|
Card::from_str(c1).unwrap(),
|
||||||
|
Card::from_str(c2).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_suited_flop() {
|
||||||
|
let board = make_board(&["Ah", "Kh", "Qh"]);
|
||||||
|
assert!(HandHelper::is_suited_flop(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_suited_flop() {
|
||||||
|
let board = make_board(&["Ah", "Kd", "Qh"]);
|
||||||
|
assert!(!HandHelper::is_suited_flop(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_two_suited_flop() {
|
||||||
|
let board = make_board(&["Ah", "Kh", "Qd"]);
|
||||||
|
assert!(HandHelper::is_two_suited_flop(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_two_suited_flop() {
|
||||||
|
let board = make_board(&["Ah", "Kd", "Qc"]);
|
||||||
|
assert!(!HandHelper::is_two_suited_flop(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_dangerous_board_suited() {
|
||||||
|
let board = make_board(&["Ah", "Kh", "Qh"]);
|
||||||
|
assert!(HandHelper::is_dangerous_board(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_dangerous_board_connected() {
|
||||||
|
let board = make_board(&["Ts", "9d", "8c"]);
|
||||||
|
assert!(HandHelper::is_dangerous_board(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_dangerous_board_paired() {
|
||||||
|
let board = make_board(&["As", "Ad", "Kc"]);
|
||||||
|
assert!(HandHelper::is_dangerous_board(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_dangerous_board() {
|
||||||
|
let board = make_board(&["As", "7d", "2c"]);
|
||||||
|
assert!(!HandHelper::is_dangerous_board(&board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_top_pair() {
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Ah", "Qc", "Js"]);
|
||||||
|
assert!(HandHelper::is_top_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_top_pair() {
|
||||||
|
let hole = make_hole("Ks", "Qd");
|
||||||
|
let board = make_board(&["Ah", "Jc", "Ts"]);
|
||||||
|
assert!(!HandHelper::is_top_pair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_overpair() {
|
||||||
|
let hole = make_hole("Ks", "Kh");
|
||||||
|
let board = make_board(&["Qd", "Jc", "Ts"]);
|
||||||
|
assert!(HandHelper::is_overpair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_overpair() {
|
||||||
|
let hole = make_hole("Ks", "Kh");
|
||||||
|
let board = make_board(&["Ad", "Jc", "Ts"]);
|
||||||
|
assert!(!HandHelper::is_overpair(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_overcards() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Qd", "Jc", "Ts"]);
|
||||||
|
assert!(HandHelper::is_overcards(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_set_or_better() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Ad", "Kc", "Qs"]);
|
||||||
|
assert!(HandHelper::is_set_or_better(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_set_or_better() {
|
||||||
|
let hole = make_hole("As", "Kd");
|
||||||
|
let board = make_board(&["Ah", "Qc", "Js"]);
|
||||||
|
assert!(!HandHelper::is_set_or_better(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_full_house_or_better() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
let board = make_board(&["Ad", "Kc", "Ks"]);
|
||||||
|
assert!(HandHelper::is_full_house_or_better(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_tptk_or_better() {
|
||||||
|
// Top pair (A) with top kicker (K)
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Ad", "Qc", "Js"]);
|
||||||
|
assert!(HandHelper::is_tptk_or_better(&hole, &board));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_aa() {
|
||||||
|
let hole = make_hole("As", "Ah");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_kk() {
|
||||||
|
let hole = make_hole("Ks", "Kh");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_qq() {
|
||||||
|
let hole = make_hole("Qs", "Qh");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_ak() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_jj() {
|
||||||
|
let hole = make_hole("Js", "Jh");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_aqs() {
|
||||||
|
let hole = make_hole("As", "Qs");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_aqo() {
|
||||||
|
let hole = make_hole("As", "Qh");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_suited_connectors() {
|
||||||
|
let hole = make_hole("Ts", "9s");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_kill_phil_group_55() {
|
||||||
|
// Small pair → group 8
|
||||||
|
let hole = make_hole("5s", "5h");
|
||||||
|
assert_eq!(HandHelper::kill_phil_group(&hole), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_hand() {
|
||||||
|
let hole = make_hole("As", "Kh");
|
||||||
|
let board = make_board(&["Qh", "Jc", "Ts"]);
|
||||||
|
assert_eq!(HandHelper::evaluate_hand(&hole, &board), HandRank::Straight);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drawing_outs() {
|
||||||
|
let hole = make_hole("Ks", "Qd");
|
||||||
|
let board = make_board(&["Jh", "Tc", "4s"]);
|
||||||
|
// OESD → 8 outs
|
||||||
|
assert!(HandHelper::drawing_outs(&hole, &board) >= 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
holdem_core/src/game/strategy/mod.rs
Normal file
20
holdem_core/src/game/strategy/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//! Bot strategy framework.
|
||||||
|
//!
|
||||||
|
//! Provides the [`Strategy`] trait, [`ModularStrategy`] composite pattern,
|
||||||
|
//! [`StrategyContext`] for game state queries, and analysis tools
|
||||||
|
//! ([`HandAnalyzer`], [`HandHelper`]) for building sophisticated poker bots.
|
||||||
|
//!
|
||||||
|
//! Ported from the Java Supersonic framework.
|
||||||
|
|
||||||
|
pub mod context;
|
||||||
|
pub mod modular;
|
||||||
|
pub mod action_recorder;
|
||||||
|
pub mod hand_analyzer;
|
||||||
|
pub mod hand_helper;
|
||||||
|
|
||||||
|
// Re-export primary types for convenience
|
||||||
|
pub use action_recorder::{ActionRecorder, RecordedAction};
|
||||||
|
pub use context::StrategyContext;
|
||||||
|
pub use hand_analyzer::HandAnalyzer;
|
||||||
|
pub use hand_helper::HandHelper;
|
||||||
|
pub use modular::{ModularStrategy, Strategy, VotingMode};
|
||||||
759
holdem_core/src/game/strategy/modular.rs
Normal file
759
holdem_core/src/game/strategy/modular.rs
Normal file
|
|
@ -0,0 +1,759 @@
|
||||||
|
//! Modular strategy — composite pattern for combining strategy components.
|
||||||
|
//!
|
||||||
|
//! Provides the [`Strategy`] trait and [`ModularStrategy`] composite pattern,
|
||||||
|
//! ported from the Java Supersonic framework's `ModularStrategy` class.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! Strategies form a tree where each node can combine its children's decisions
|
||||||
|
//! using different voting mechanisms:
|
||||||
|
//!
|
||||||
|
//! - **[`VotingMode::FirstApplicable`]**: Returns the first applicable sub-strategy's action.
|
||||||
|
//! - **[`VotingMode::Democratic`]**: Majority vote among applicable strategies.
|
||||||
|
//! - **[`VotingMode::Random`]**: Weighted random selection among applicable strategies.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use holdem_core::game::strategy::modular::{ModularStrategy, VotingMode};
|
||||||
|
//!
|
||||||
|
//! // Build a composite strategy with no children (defaults to Fold)
|
||||||
|
//! let strategy = ModularStrategy::new("root", VotingMode::FirstApplicable);
|
||||||
|
//! assert!(strategy.is_empty());
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::game::action::Action;
|
||||||
|
use crate::game::strategy::context::StrategyContext;
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
/// A single strategy that can produce a betting action.
|
||||||
|
///
|
||||||
|
/// Strategies are composable via [`ModularStrategy`] which uses voting
|
||||||
|
/// mechanisms to combine multiple sub-strategies.
|
||||||
|
///
|
||||||
|
/// # Implementors
|
||||||
|
///
|
||||||
|
/// Implementors must be `Send + Sync` because bots are used across threads
|
||||||
|
/// in simulations.
|
||||||
|
pub trait Strategy: Send + Sync {
|
||||||
|
/// Whether this strategy should be consulted in the current context.
|
||||||
|
///
|
||||||
|
/// Called before [`get_action`](Self::get_action). If this returns `false`,
|
||||||
|
/// the strategy is skipped entirely.
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool;
|
||||||
|
|
||||||
|
/// Produce a betting action. Only called when [`is_applicable`](Self::is_applicable) returns `true`.
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action;
|
||||||
|
|
||||||
|
/// Whether a `Fold` action should be treated as "no opinion" (not applicable).
|
||||||
|
///
|
||||||
|
/// When `true`, a parent [`ModularStrategy`] using
|
||||||
|
/// [`FirstApplicable`](VotingMode::FirstApplicable) voting will skip this
|
||||||
|
/// strategy if it returns [`Action::Fold`] and continue to the next child.
|
||||||
|
///
|
||||||
|
/// Default: `false` (fold is a valid action).
|
||||||
|
fn consider_fold_as_not_applicable(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional weight for voting mechanisms.
|
||||||
|
///
|
||||||
|
/// Used by [`VotingMode::Democratic`] and [`VotingMode::Random`] to
|
||||||
|
/// give some strategies more influence than others.
|
||||||
|
///
|
||||||
|
/// Default: `1`.
|
||||||
|
fn weight(&self) -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How sub-strategies' actions are combined in a [`ModularStrategy`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VotingMode {
|
||||||
|
/// Use the first applicable sub-strategy's action.
|
||||||
|
///
|
||||||
|
/// Iterates children in order. The first child where
|
||||||
|
/// [`Strategy::is_applicable`] returns `true` (and whose action is not
|
||||||
|
/// skipped due to [`consider_fold_as_not_applicable`](Strategy::consider_fold_as_not_applicable))
|
||||||
|
/// wins.
|
||||||
|
FirstApplicable,
|
||||||
|
|
||||||
|
/// Majority vote among applicable strategies.
|
||||||
|
///
|
||||||
|
/// Each applicable strategy contributes its action type (fold/check/call/raise).
|
||||||
|
/// Actions are grouped and counted (weighted by [`Strategy::weight`]).
|
||||||
|
/// The group with the most votes wins. Ties are broken by priority:
|
||||||
|
/// fold < check < call < raise.
|
||||||
|
Democratic,
|
||||||
|
|
||||||
|
/// Weighted random selection among applicable strategies.
|
||||||
|
///
|
||||||
|
/// Each applicable strategy's [`Strategy::weight`] determines its
|
||||||
|
/// probability of being selected. Uses the `rand` crate for randomness.
|
||||||
|
Random,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite strategy that delegates to sub-strategies via a voting mechanism.
|
||||||
|
///
|
||||||
|
/// Port of the Java `ModularStrategy` class. This is the core of the
|
||||||
|
/// Supersonic bot framework — strategies are composed in a tree where
|
||||||
|
/// each node can combine its children's decisions using different voting modes.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use holdem_core::game::strategy::modular::{ModularStrategy, VotingMode};
|
||||||
|
///
|
||||||
|
/// let strategy = ModularStrategy::new("my_bot", VotingMode::FirstApplicable);
|
||||||
|
/// assert!(strategy.is_empty());
|
||||||
|
/// ```
|
||||||
|
pub struct ModularStrategy {
|
||||||
|
/// Child strategies in priority order.
|
||||||
|
sub_strategies: Vec<Box<dyn Strategy>>,
|
||||||
|
/// How to combine sub-strategy actions.
|
||||||
|
voting_mode: VotingMode,
|
||||||
|
/// Name for debugging/logging.
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModularStrategy {
|
||||||
|
/// Create a new `ModularStrategy` with the given name and voting mode.
|
||||||
|
pub fn new(name: impl Into<String>, voting_mode: VotingMode) -> Self {
|
||||||
|
Self {
|
||||||
|
sub_strategies: Vec::new(),
|
||||||
|
voting_mode,
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a sub-strategy (builder pattern).
|
||||||
|
pub fn with_sub_strategy(mut self, strategy: Box<dyn Strategy>) -> Self {
|
||||||
|
self.sub_strategies.push(strategy);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a sub-strategy (mutable reference).
|
||||||
|
pub fn add_sub_strategy(&mut self, strategy: Box<dyn Strategy>) {
|
||||||
|
self.sub_strategies.push(strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of sub-strategies.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.sub_strategies.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if there are no sub-strategies.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sub_strategies.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the voting mode.
|
||||||
|
pub fn voting_mode(&self) -> VotingMode {
|
||||||
|
self.voting_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strategy name.
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all applicable sub-strategies with their actions.
|
||||||
|
///
|
||||||
|
/// Returns a vector of `(action, weight, index)` tuples where `index` is
|
||||||
|
/// the position in `sub_strategies` (used for tiebreaking).
|
||||||
|
fn collect_applicable<'a>(
|
||||||
|
&'a self,
|
||||||
|
ctx: &StrategyContext,
|
||||||
|
) -> Vec<(Action, u32, usize)> {
|
||||||
|
self.sub_strategies
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, s)| s.is_applicable(ctx))
|
||||||
|
.map(|(i, s)| (s.get_action(ctx), s.weight(), i))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-applicable voting: return the first applicable strategy's action.
|
||||||
|
///
|
||||||
|
/// If `consider_fold_as_not_applicable` is true and the action is a fold
|
||||||
|
/// or check, skip to the next strategy.
|
||||||
|
fn vote_first_applicable(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
for strategy in &self.sub_strategies {
|
||||||
|
if strategy.is_applicable(ctx) {
|
||||||
|
let action = strategy.get_action(ctx);
|
||||||
|
if strategy.consider_fold_as_not_applicable()
|
||||||
|
&& matches!(action, Action::Fold | Action::Check)
|
||||||
|
{
|
||||||
|
// Skip — treat as "no opinion"
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No applicable strategy found — default to fold
|
||||||
|
Action::Fold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Democratic voting: count action types, return the majority.
|
||||||
|
///
|
||||||
|
/// Groups actions into fold, check/call, and raise categories. Each
|
||||||
|
/// strategy contributes its weight. Ties broken by priority:
|
||||||
|
/// fold < check/call < raise.
|
||||||
|
fn vote_democratic(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
let applicable = self.collect_applicable(ctx);
|
||||||
|
|
||||||
|
if applicable.is_empty() {
|
||||||
|
return Action::Fold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted vote counts for each action category.
|
||||||
|
let mut fold_weight: u32 = 0;
|
||||||
|
let mut check_call_weight: u32 = 0;
|
||||||
|
let mut raise_weight: u32 = 0;
|
||||||
|
// For raise actions, track the average raise amount.
|
||||||
|
let mut raise_total: u64 = 0;
|
||||||
|
let mut raise_count: u32 = 0;
|
||||||
|
|
||||||
|
for (action, weight, _idx) in &applicable {
|
||||||
|
match action {
|
||||||
|
Action::Fold => {
|
||||||
|
fold_weight += weight;
|
||||||
|
}
|
||||||
|
Action::Check | Action::Call => {
|
||||||
|
check_call_weight += weight;
|
||||||
|
}
|
||||||
|
Action::Raise { total } => {
|
||||||
|
raise_weight += weight;
|
||||||
|
raise_total += total;
|
||||||
|
raise_count += 1;
|
||||||
|
}
|
||||||
|
Action::AllIn => {
|
||||||
|
raise_weight += weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the action type with the most votes.
|
||||||
|
// Ties broken by priority: raise > check/call > fold.
|
||||||
|
if raise_weight > 0 && raise_weight >= check_call_weight && raise_weight >= fold_weight {
|
||||||
|
if raise_count > 0 {
|
||||||
|
Action::Raise {
|
||||||
|
total: raise_total / raise_count as u64,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Action::AllIn
|
||||||
|
}
|
||||||
|
} else if check_call_weight > 0 && check_call_weight >= fold_weight {
|
||||||
|
Action::Call
|
||||||
|
} else {
|
||||||
|
Action::Fold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random voting: weighted random selection among applicable strategies.
|
||||||
|
///
|
||||||
|
/// Each strategy's weight determines its probability of being chosen.
|
||||||
|
fn vote_random(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
let applicable = self.collect_applicable(ctx);
|
||||||
|
|
||||||
|
if applicable.is_empty() {
|
||||||
|
return Action::Fold;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_weight: u32 = applicable.iter().map(|(_, w, _)| *w).sum();
|
||||||
|
if total_weight == 0 {
|
||||||
|
return Action::Fold;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let mut selection = rng.random_range(0..total_weight);
|
||||||
|
|
||||||
|
for (action, weight, _idx) in &applicable {
|
||||||
|
if selection < *weight {
|
||||||
|
return action.clone();
|
||||||
|
}
|
||||||
|
selection -= weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (should not reach here)
|
||||||
|
applicable[0].0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for ModularStrategy {
|
||||||
|
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||||
|
// Applicable if ANY sub-strategy is applicable.
|
||||||
|
self.sub_strategies.iter().any(|s| s.is_applicable(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||||
|
match self.voting_mode {
|
||||||
|
VotingMode::FirstApplicable => self.vote_first_applicable(ctx),
|
||||||
|
VotingMode::Democratic => self.vote_democratic(ctx),
|
||||||
|
VotingMode::Random => self.vote_random(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consider_fold_as_not_applicable(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weight(&self) -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ModularStrategy {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ModularStrategy")
|
||||||
|
.field("name", &self.name)
|
||||||
|
.field("voting_mode", &self.voting_mode)
|
||||||
|
.field("num_sub_strategies", &self.sub_strategies.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game::action::Action;
|
||||||
|
use crate::game::betting::BetLimit;
|
||||||
|
use crate::game::game_info::GameInfo;
|
||||||
|
use crate::game::player_info::{PlayerInfo, PlayerState};
|
||||||
|
use crate::game::strategy::action_recorder::ActionRecorder;
|
||||||
|
use crate::game::types::PlayerId;
|
||||||
|
use crate::board::Street;
|
||||||
|
|
||||||
|
// --- Mock strategies ---
|
||||||
|
|
||||||
|
/// Always returns `Action::Fold`, always applicable.
|
||||||
|
struct AlwaysFold;
|
||||||
|
|
||||||
|
impl Strategy for AlwaysFold {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
Action::Fold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always returns `Action::Call`, always applicable.
|
||||||
|
struct AlwaysCall;
|
||||||
|
|
||||||
|
impl Strategy for AlwaysCall {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
Action::Call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always returns `Action::Raise { total: 200 }`, always applicable.
|
||||||
|
struct AlwaysRaise;
|
||||||
|
|
||||||
|
impl Strategy for AlwaysRaise {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
Action::Raise { total: 200 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Never applicable.
|
||||||
|
struct NeverApplicable;
|
||||||
|
|
||||||
|
impl Strategy for NeverApplicable {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
Action::Fold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds but considers fold as not applicable.
|
||||||
|
struct FoldAsNotApplicable;
|
||||||
|
|
||||||
|
impl Strategy for FoldAsNotApplicable {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
Action::Fold
|
||||||
|
}
|
||||||
|
fn consider_fold_as_not_applicable(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weighted strategy with configurable weight.
|
||||||
|
struct WeightedStrategy {
|
||||||
|
action: Action,
|
||||||
|
weight: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeightedStrategy {
|
||||||
|
fn new(action: Action, weight: u32) -> Self {
|
||||||
|
Self { action, weight }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strategy for WeightedStrategy {
|
||||||
|
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn get_action(&self, _ctx: &StrategyContext) -> Action {
|
||||||
|
self.action.clone()
|
||||||
|
}
|
||||||
|
fn weight(&self) -> u32 {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test context builder ---
|
||||||
|
|
||||||
|
fn make_test_context() -> StrategyContext<'static> {
|
||||||
|
// We need to leak here to get 'static lifetime for tests.
|
||||||
|
// This is fine for tests only.
|
||||||
|
let gi = Box::leak(Box::new(GameInfo {
|
||||||
|
num_seats: 2,
|
||||||
|
bet_limit: BetLimit::NoLimit { min_raise: 100 },
|
||||||
|
small_blind: 50,
|
||||||
|
big_blind: 100,
|
||||||
|
ante: 0,
|
||||||
|
street: Street::Flop,
|
||||||
|
board: vec![],
|
||||||
|
pot: 200,
|
||||||
|
current_bet: 0,
|
||||||
|
min_raise: 100,
|
||||||
|
button_seat: 1,
|
||||||
|
small_blind_seat: 0,
|
||||||
|
big_blind_seat: 1,
|
||||||
|
next_to_act: Some(0),
|
||||||
|
players: vec![
|
||||||
|
Some(PlayerInfo {
|
||||||
|
id: PlayerId(1),
|
||||||
|
name: "P1".into(),
|
||||||
|
seat: 0,
|
||||||
|
stack: 1000,
|
||||||
|
wagered_this_round: 0,
|
||||||
|
wagered_this_hand: 0,
|
||||||
|
state: PlayerState::Active,
|
||||||
|
has_acted_this_round: false,
|
||||||
|
is_dealer: false,
|
||||||
|
is_small_blind: true,
|
||||||
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
|
}),
|
||||||
|
Some(PlayerInfo {
|
||||||
|
id: PlayerId(2),
|
||||||
|
name: "P2".into(),
|
||||||
|
seat: 1,
|
||||||
|
stack: 1000,
|
||||||
|
wagered_this_round: 0,
|
||||||
|
wagered_this_hand: 0,
|
||||||
|
state: PlayerState::Active,
|
||||||
|
has_acted_this_round: false,
|
||||||
|
is_dealer: true,
|
||||||
|
is_small_blind: false,
|
||||||
|
is_big_blind: true,
|
||||||
|
bankroll: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
actions_this_hand: vec![vec![], vec![]],
|
||||||
|
num_raises: 0,
|
||||||
|
hand_number: 1,
|
||||||
|
active_players: 2,
|
||||||
|
}));
|
||||||
|
let recorder = Box::leak(Box::new(ActionRecorder::new()));
|
||||||
|
|
||||||
|
StrategyContext {
|
||||||
|
game_info: gi,
|
||||||
|
hole_cards: None,
|
||||||
|
our_seat: 0,
|
||||||
|
our_id: PlayerId(1),
|
||||||
|
our_position: 0,
|
||||||
|
num_active: 2,
|
||||||
|
i_am_aggressor: false,
|
||||||
|
i_am_preflop_aggressor: false,
|
||||||
|
num_raises: 0,
|
||||||
|
num_callers: 0,
|
||||||
|
aggressor_seat: None,
|
||||||
|
recorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FirstApplicable tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_applicable_returns_first() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
// First applicable is AlwaysFold
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_applicable_skips_inapplicable() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(NeverApplicable))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
// NeverApplicable is skipped, AlwaysCall is first applicable
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_applicable_no_applicable_defaults_to_fold() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(NeverApplicable));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_applicable_fold_as_not_applicable() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(FoldAsNotApplicable))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
// FoldAsNotApplicable returns Fold but is treated as not applicable,
|
||||||
|
// so we fall through to AlwaysCall.
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_applicable_fold_as_not_applicable_all_fold() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(FoldAsNotApplicable))
|
||||||
|
.with_sub_strategy(Box::new(FoldAsNotApplicable));
|
||||||
|
|
||||||
|
// All strategies return fold-as-not-applicable → default to Fold
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Democratic tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_majority_call() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// 2 calls vs 1 fold → call wins
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_majority_raise() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// 2 raises vs 1 call → raise wins
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysRaise))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysRaise))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Raise { total: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_majority_fold() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// 2 folds vs 1 call → fold wins
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_tie_breaks_to_raise() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// 1 raise vs 1 call → raise wins (raise > call priority)
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysRaise))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Raise { total: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_tie_breaks_to_call_over_fold() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// 1 call vs 1 fold → call wins (call > fold priority)
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_weighted() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// Fold with weight 3 vs Call with weight 1 → fold wins
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Fold, 3)))
|
||||||
|
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Call, 1)));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_democratic_no_applicable() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(NeverApplicable));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Random tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_does_not_crash() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Random)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysRaise));
|
||||||
|
|
||||||
|
// Run many times to verify no crash
|
||||||
|
for _ in 0..100 {
|
||||||
|
let action = strategy.get_action(&ctx);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
Action::Fold | Action::Call | Action::Raise { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_weighted() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
// Call with weight 100, Fold with weight 1 → almost always call
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Random)
|
||||||
|
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Call, 100)))
|
||||||
|
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Fold, 1)));
|
||||||
|
|
||||||
|
let mut call_count = 0;
|
||||||
|
for _ in 0..1000 {
|
||||||
|
if strategy.get_action(&ctx) == Action::Call {
|
||||||
|
call_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Should be overwhelmingly calls (> 90%)
|
||||||
|
assert!(call_count > 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_no_applicable() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Random)
|
||||||
|
.with_sub_strategy(Box::new(NeverApplicable));
|
||||||
|
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- General tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_strategy() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("empty", VotingMode::FirstApplicable);
|
||||||
|
|
||||||
|
assert!(strategy.is_empty());
|
||||||
|
assert_eq!(strategy.len(), 0);
|
||||||
|
assert!(!strategy.is_applicable(&ctx));
|
||||||
|
// get_action defaults to fold when no sub-strategies
|
||||||
|
assert_eq!(strategy.get_action(&ctx), Action::Fold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_applicable_true() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold));
|
||||||
|
|
||||||
|
assert!(strategy.is_applicable(&ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_applicable_false() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(NeverApplicable));
|
||||||
|
|
||||||
|
assert!(!strategy.is_applicable(&ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builder_pattern() {
|
||||||
|
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
assert_eq!(strategy.len(), 2);
|
||||||
|
assert_eq!(strategy.name(), "test");
|
||||||
|
assert_eq!(strategy.voting_mode(), VotingMode::Democratic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_sub_strategy_mut() {
|
||||||
|
let mut strategy = ModularStrategy::new("test", VotingMode::FirstApplicable);
|
||||||
|
assert!(strategy.is_empty());
|
||||||
|
|
||||||
|
strategy.add_sub_strategy(Box::new(AlwaysFold));
|
||||||
|
assert_eq!(strategy.len(), 1);
|
||||||
|
|
||||||
|
strategy.add_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
assert_eq!(strategy.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nested_composite() {
|
||||||
|
let ctx = make_test_context();
|
||||||
|
|
||||||
|
// Inner composite: always calls
|
||||||
|
let inner = ModularStrategy::new("inner", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
// Outer composite: first applicable is inner, then a raise strategy
|
||||||
|
let outer = ModularStrategy::new("outer", VotingMode::FirstApplicable)
|
||||||
|
.with_sub_strategy(Box::new(inner));
|
||||||
|
|
||||||
|
// Inner is applicable and returns Call
|
||||||
|
assert_eq!(outer.get_action(&ctx), Action::Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_debug_format() {
|
||||||
|
let strategy = ModularStrategy::new("test_bot", VotingMode::Democratic)
|
||||||
|
.with_sub_strategy(Box::new(AlwaysFold))
|
||||||
|
.with_sub_strategy(Box::new(AlwaysCall));
|
||||||
|
|
||||||
|
let debug = format!("{:?}", strategy);
|
||||||
|
assert!(debug.contains("test_bot"));
|
||||||
|
assert!(debug.contains("Democratic"));
|
||||||
|
assert!(debug.contains("2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,4 +10,9 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
holdem_core = { path = "../holdem_core" }
|
holdem_core = { path = "../holdem_core" }
|
||||||
|
holdem_bots = { path = "../holdem_bots", optional = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["advanced-bots"]
|
||||||
|
advanced-bots = ["holdem_bots"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue