From f81f0579d1693a820b193bc8d19639a69ca1eff8 Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Fri, 22 May 2026 16:50:11 +0200 Subject: [PATCH] feat: port Java Supersonic bot framework and cash game bots to Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 7 + Cargo.lock | 9 + Cargo.toml | 1 + docs/ARCHITECTURE_BOT_FRAMEWORK.md | 648 +++++++++++++++ {plans => docs}/meerkat-alignment-fixes.md | 0 holdem_bots/Cargo.toml | 9 + holdem_bots/src/cash/big_stack.rs | 67 ++ holdem_bots/src/cash/big_stack_preflop.rs | 364 +++++++++ holdem_bots/src/cash/mod.rs | 21 + holdem_bots/src/cash/postflop.rs | 287 +++++++ holdem_bots/src/cash/short_stack.rs | 67 ++ holdem_bots/src/cash/short_stack_preflop.rs | 204 +++++ holdem_bots/src/lib.rs | 7 + .../evaluator/property_tests.txt | 7 - holdem_core/src/evaluator/benchmark_utils.rs | 4 - .../src/evaluator/integration_utils.rs | 4 - holdem_core/src/evaluator/mod.rs | 4 - holdem_core/src/evaluator/tables_new.rs | 4 - holdem_core/src/evaluator/test_utils.rs | 4 - holdem_core/src/game/mod.rs | 1 + .../src/game/strategy/action_recorder.rs | 242 ++++++ holdem_core/src/game/strategy/context.rs | 714 ++++++++++++++++ .../src/game/strategy/hand_analyzer.rs | 621 ++++++++++++++ holdem_core/src/game/strategy/hand_helper.rs | 678 ++++++++++++++++ holdem_core/src/game/strategy/mod.rs | 20 + holdem_core/src/game/strategy/modular.rs | 759 ++++++++++++++++++ holdem_sim/Cargo.toml | 5 + 27 files changed, 4731 insertions(+), 27 deletions(-) create mode 100644 docs/ARCHITECTURE_BOT_FRAMEWORK.md rename {plans => docs}/meerkat-alignment-fixes.md (100%) create mode 100644 holdem_bots/Cargo.toml create mode 100644 holdem_bots/src/cash/big_stack.rs create mode 100644 holdem_bots/src/cash/big_stack_preflop.rs create mode 100644 holdem_bots/src/cash/mod.rs create mode 100644 holdem_bots/src/cash/postflop.rs create mode 100644 holdem_bots/src/cash/short_stack.rs create mode 100644 holdem_bots/src/cash/short_stack_preflop.rs create mode 100644 holdem_bots/src/lib.rs delete mode 100644 holdem_core/proptest-regressions/evaluator/property_tests.txt delete mode 100644 holdem_core/src/evaluator/benchmark_utils.rs delete mode 100644 holdem_core/src/evaluator/integration_utils.rs delete mode 100644 holdem_core/src/evaluator/tables_new.rs delete mode 100644 holdem_core/src/evaluator/test_utils.rs create mode 100644 holdem_core/src/game/strategy/action_recorder.rs create mode 100644 holdem_core/src/game/strategy/context.rs create mode 100644 holdem_core/src/game/strategy/hand_analyzer.rs create mode 100644 holdem_core/src/game/strategy/hand_helper.rs create mode 100644 holdem_core/src/game/strategy/mod.rs create mode 100644 holdem_core/src/game/strategy/modular.rs diff --git a/.gitignore b/.gitignore index adc91bf..fa6fe49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,10 @@ # OS .DS_Store 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/ diff --git a/Cargo.lock b/Cargo.lock index cc1ee46..8143791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,14 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "holdem_bots" +version = "0.1.0" +dependencies = [ + "holdem_core", + "log", +] + [[package]] name = "holdem_core" version = "0.1.0" @@ -392,6 +400,7 @@ name = "holdem_sim" version = "0.1.0" dependencies = [ "clap", + "holdem_bots", "holdem_core", ] diff --git a/Cargo.toml b/Cargo.toml index ae55dff..811d29a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,4 +3,5 @@ resolver = "2" members = [ "holdem_core", "holdem_sim", + "holdem_bots", ] diff --git a/docs/ARCHITECTURE_BOT_FRAMEWORK.md b/docs/ARCHITECTURE_BOT_FRAMEWORK.md new file mode 100644 index 0000000..de4a8fb --- /dev/null +++ b/docs/ARCHITECTURE_BOT_FRAMEWORK.md @@ -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` +- 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 { + <> + +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 { + <> + +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 { + <> + 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, + 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>, + voting_mode: VotingMode, + // Internal state managed across the hand lifecycle + hole_cards: Option, + 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` 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` | Idiomatic throughout | +| `Vector` | `Vec` | 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, String>; + +static REGISTRY: RwLock> = 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, 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`; 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. diff --git a/plans/meerkat-alignment-fixes.md b/docs/meerkat-alignment-fixes.md similarity index 100% rename from plans/meerkat-alignment-fixes.md rename to docs/meerkat-alignment-fixes.md diff --git a/holdem_bots/Cargo.toml b/holdem_bots/Cargo.toml new file mode 100644 index 0000000..9fea17b --- /dev/null +++ b/holdem_bots/Cargo.toml @@ -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" diff --git a/holdem_bots/src/cash/big_stack.rs b/holdem_bots/src/cash/big_stack.rs new file mode 100644 index 0000000..8c43450 --- /dev/null +++ b/holdem_bots/src/cash/big_stack.rs @@ -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); + } +} diff --git a/holdem_bots/src/cash/big_stack_preflop.rs b/holdem_bots/src/cash/big_stack_preflop.rs new file mode 100644 index 0000000..a41c60e --- /dev/null +++ b/holdem_bots/src/cash/big_stack_preflop.rs @@ -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); + } +} diff --git a/holdem_bots/src/cash/mod.rs b/holdem_bots/src/cash/mod.rs new file mode 100644 index 0000000..01ae423 --- /dev/null +++ b/holdem_bots/src/cash/mod.rs @@ -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; diff --git a/holdem_bots/src/cash/postflop.rs b/holdem_bots/src/cash/postflop.rs new file mode 100644 index 0000000..3c3a101 --- /dev/null +++ b/holdem_bots/src/cash/postflop.rs @@ -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 = 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"); + } +} diff --git a/holdem_bots/src/cash/short_stack.rs b/holdem_bots/src/cash/short_stack.rs new file mode 100644 index 0000000..40d8b86 --- /dev/null +++ b/holdem_bots/src/cash/short_stack.rs @@ -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); + } +} diff --git a/holdem_bots/src/cash/short_stack_preflop.rs b/holdem_bots/src/cash/short_stack_preflop.rs new file mode 100644 index 0000000..428b9d1 --- /dev/null +++ b/holdem_bots/src/cash/short_stack_preflop.rs @@ -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 + } +} diff --git a/holdem_bots/src/lib.rs b/holdem_bots/src/lib.rs new file mode 100644 index 0000000..81d5683 --- /dev/null +++ b/holdem_bots/src/lib.rs @@ -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; diff --git a/holdem_core/proptest-regressions/evaluator/property_tests.txt b/holdem_core/proptest-regressions/evaluator/property_tests.txt deleted file mode 100644 index 3aa240f..0000000 --- a/holdem_core/proptest-regressions/evaluator/property_tests.txt +++ /dev/null @@ -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 }] diff --git a/holdem_core/src/evaluator/benchmark_utils.rs b/holdem_core/src/evaluator/benchmark_utils.rs deleted file mode 100644 index 31fb8a2..0000000 --- a/holdem_core/src/evaluator/benchmark_utils.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Benchmark utilities for the hand evaluator. -//! -//! This module provides helper functions and data structures for benchmarking -//! hand evaluation performance. diff --git a/holdem_core/src/evaluator/integration_utils.rs b/holdem_core/src/evaluator/integration_utils.rs deleted file mode 100644 index 5f6751d..0000000 --- a/holdem_core/src/evaluator/integration_utils.rs +++ /dev/null @@ -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. diff --git a/holdem_core/src/evaluator/mod.rs b/holdem_core/src/evaluator/mod.rs index 55b1638..97decf7 100644 --- a/holdem_core/src/evaluator/mod.rs +++ b/holdem_core/src/evaluator/mod.rs @@ -14,18 +14,14 @@ //! - **`examples`**: Usage examples and performance benchmarks pub mod algorithmic_evaluator; -pub mod benchmark_utils; pub mod errors; pub mod evaluator; pub mod examples; pub mod file_io; pub mod integration; -pub mod integration_utils; pub mod property_tests; pub mod singleton; pub mod tables; -pub mod tables_new; -pub mod test_utils; #[cfg(test)] mod algorithmic_evaluator_test; diff --git a/holdem_core/src/evaluator/tables_new.rs b/holdem_core/src/evaluator/tables_new.rs deleted file mode 100644 index a91b355..0000000 --- a/holdem_core/src/evaluator/tables_new.rs +++ /dev/null @@ -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. diff --git a/holdem_core/src/evaluator/test_utils.rs b/holdem_core/src/evaluator/test_utils.rs deleted file mode 100644 index 304f062..0000000 --- a/holdem_core/src/evaluator/test_utils.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Test utilities for the hand evaluator. -//! -//! This module provides helper functions and data structures for testing -//! hand evaluation correctness. diff --git a/holdem_core/src/game/mod.rs b/holdem_core/src/game/mod.rs index 1657819..607306a 100644 --- a/holdem_core/src/game/mod.rs +++ b/holdem_core/src/game/mod.rs @@ -31,6 +31,7 @@ pub mod player_info; pub mod pot; pub mod simulation; pub mod stats; +pub mod strategy; pub mod table; pub mod tournament; pub mod types; diff --git a/holdem_core/src/game/strategy/action_recorder.rs b/holdem_core/src/game/strategy/action_recorder.rs new file mode 100644 index 0000000..e27f5c0 --- /dev/null +++ b/holdem_core/src/game/strategy/action_recorder.rs @@ -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, + flop: Vec, + turn: Vec, + river: Vec, +} + +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); + } +} diff --git a/holdem_core/src/game/strategy/context.rs b/holdem_core/src/game/strategy/context.rs new file mode 100644 index 0000000..b5b54e7 --- /dev/null +++ b/holdem_core/src/game/strategy/context.rs @@ -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, + /// 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, + /// 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>, + ) -> 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)); + } +} diff --git a/holdem_core/src/game/strategy/hand_analyzer.rs b/holdem_core/src/game/strategy/hand_analyzer.rs new file mode 100644 index 0000000..568071c --- /dev/null +++ b/holdem_core/src/game/strategy/hand_analyzer.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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)); + } +} diff --git a/holdem_core/src/game/strategy/hand_helper.rs b/holdem_core/src/game/strategy/hand_helper.rs new file mode 100644 index 0000000..e9c4cdd --- /dev/null +++ b/holdem_core/src/game/strategy/hand_helper.rs @@ -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 = 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 = 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 = 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); + } +} diff --git a/holdem_core/src/game/strategy/mod.rs b/holdem_core/src/game/strategy/mod.rs new file mode 100644 index 0000000..c4ef904 --- /dev/null +++ b/holdem_core/src/game/strategy/mod.rs @@ -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}; diff --git a/holdem_core/src/game/strategy/modular.rs b/holdem_core/src/game/strategy/modular.rs new file mode 100644 index 0000000..86e1ebb --- /dev/null +++ b/holdem_core/src/game/strategy/modular.rs @@ -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>, + /// 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, 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) -> Self { + self.sub_strategies.push(strategy); + self + } + + /// Add a sub-strategy (mutable reference). + pub fn add_sub_strategy(&mut self, strategy: Box) { + 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")); + } +} diff --git a/holdem_sim/Cargo.toml b/holdem_sim/Cargo.toml index 684761d..c9d42ae 100644 --- a/holdem_sim/Cargo.toml +++ b/holdem_sim/Cargo.toml @@ -10,4 +10,9 @@ path = "src/main.rs" [dependencies] holdem_core = { path = "../holdem_core" } +holdem_bots = { path = "../holdem_bots", optional = true } clap = { version = "4", features = ["derive"] } + +[features] +default = ["advanced-bots"] +advanced-bots = ["holdem_bots"]