refactor: remove strategy framework from holdem_core

Remove the entire strategy module (StrategyBot, ModularStrategy,
StrategyContext, ActionRecorder, HandAnalyzer, HandHelper) from
holdem_core. Strategy implementations will live in external crates
(e.g., super_marvin) and register via bot_factory::register_bot_type.

- Delete holdem_core/src/game/strategy/ (6 files, ~1900 lines)
- Delete holdem_core/src/game/bots/strategy_bot.rs
- Remove strategy_bot from bots/mod.rs module declarations and re-exports
- Update bot_factory.rs doc example to show generic custom bot pattern
This commit is contained in:
Jan-Peter von Hunnius 2026-05-25 12:47:47 +02:00
parent 14c56485e7
commit 2da48d43fb
10 changed files with 2 additions and 3420 deletions

View file

@ -18,17 +18,15 @@
//! # Custom Bot Registration
//!
//! External crates can register custom bot types using [`register_bot_type`].
//! This is useful for strategy-based bots (e.g., from `holdem_bots`) that
//! This is useful for custom bots (e.g., from `super_marvin`) that
//! cannot be added to the built-in factory due to dependency direction.
//!
//! ```rust,ignore
//! use holdem_core::game::bot_factory::register_bot_type;
//! use holdem_core::game::bots::StrategyBot;
//! use holdem_core::game::strategy::modular::Strategy;
//! use holdem_core::game::types::PlayerId;
//!
//! register_bot_type("my_custom_bot", |id, name| {
//! Box::new(StrategyBot::new(id, name, Box::new(my_strategy())))
//! Box::new(my_custom_bot(id, name))
//! });
//! ```

View file

@ -36,7 +36,6 @@ pub mod flock_bot;
pub mod fold_bot;
pub mod random_bot;
pub mod raise_bot;
pub mod strategy_bot;
// Re-export all bots at the module level for convenience.
pub use call_bot::CallBot;
@ -45,4 +44,3 @@ pub use flock_bot::FlockBot;
pub use fold_bot::FoldBot;
pub use random_bot::RandomBot;
pub use raise_bot::RaiseBot;
pub use strategy_bot::StrategyBot;

View file

@ -1,379 +0,0 @@
//! Adapter that wraps a [`Strategy`] into a [`HoldemPlayer`].
//!
//! [`StrategyBot`] bridges the gap between the strategy trait system
//! (used by composable strategy modules) and the player trait expected
//! by the game engine. It maintains per-hand state (hole cards, action
//! recorder) and constructs a [`StrategyContext`] on each call to
//! [`act`](HoldemPlayer::act).
use crate::game::action::Action;
use crate::game::game_info::GameInfo;
use crate::game::player::HoldemPlayer;
use crate::game::strategy::action_recorder::ActionRecorder;
use crate::game::strategy::context::StrategyContext;
use crate::game::strategy::modular::Strategy;
use crate::game::types::PlayerId;
use crate::{HandValue, HoleCards};
/// Adapter that wraps a [`Strategy`] into a [`HoldemPlayer`].
///
/// This allows any `Strategy` implementation to be used as a full
/// game player. The adapter maintains:
/// - Hole cards (updated via [`hole_cards_dealt`](HoldemPlayer::hole_cards_dealt))
/// - An [`ActionRecorder`] tracking all observed actions
/// - Aggressor flags derived from the action history
///
/// # Lifetime of the inner strategy
///
/// The strategy is owned (`Box<dyn Strategy>`) so the adapter is
/// self-contained and `Send + Sync`.
///
/// # Examples
///
/// ```rust
/// use holdem_core::game::bots::StrategyBot;
/// use holdem_core::game::strategy::modular::{ModularStrategy, VotingMode};
/// use holdem_core::game::player::HoldemPlayer;
/// use holdem_core::game::types::PlayerId;
///
/// let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable);
/// let bot = StrategyBot::new(PlayerId(1), "Strategy-1".to_string(), Box::new(strategy));
/// assert_eq!(bot.id(), PlayerId(1));
/// assert_eq!(bot.name(), "Strategy-1");
/// ```
pub struct StrategyBot {
id: PlayerId,
name: String,
strategy: Box<dyn Strategy>,
hole_cards: Option<HoleCards>,
recorder: ActionRecorder,
/// Whether we raised preflop (tracked across actions within a hand).
i_am_preflop_aggressor: bool,
}
impl StrategyBot {
/// Creates a new [`StrategyBot`] wrapping the given strategy.
pub fn new(id: PlayerId, name: String, strategy: Box<dyn Strategy>) -> Self {
Self {
id,
name,
strategy,
hole_cards: None,
recorder: ActionRecorder::new(),
i_am_preflop_aggressor: false,
}
}
/// Returns the street actions from the recorder for the given stage index.
fn street_actions(&self, stage_index: usize) -> &[crate::game::strategy::action_recorder::RecordedAction] {
match stage_index {
0 => self.recorder.preflop_actions(),
1 => self.recorder.flop_actions(),
2 => self.recorder.turn_actions(),
3 => self.recorder.river_actions(),
_ => &[],
}
}
/// Constructs a [`StrategyContext`] from the current game state.
///
/// Derives position, aggressor flags, and action counts from the
/// `GameInfo` and the internal `ActionRecorder`.
fn build_context<'a>(&'a self, game_info: &'a GameInfo) -> StrategyContext<'a> {
// Find our seat and position info.
let our_info = game_info.player_by_id(self.id);
let our_seat = our_info.map(|p| p.seat).unwrap_or(0);
let our_position = our_info
.map(|p| {
// Position relative to button: 0 = SB, ..., N-1 = Button.
let num_seats = game_info.num_seats as u32;
if num_seats == 0 {
return 0;
}
(p.seat as u32 + num_seats - game_info.button_seat as u32 - 1) % num_seats
})
.unwrap_or(0);
let num_active = game_info.get_num_active_players();
// Determine if we are the aggressor (last raiser) on the current street.
let stage_index = game_info.stage_index();
let our_street_actions = self.street_actions(stage_index);
let i_am_aggressor = our_street_actions
.iter()
.rev()
.any(|ra| ra.player_id == self.id && matches!(ra.action, Action::Raise { .. }));
// Find the seat of the last raiser on the current street.
let aggressor_seat = our_street_actions
.iter()
.rev()
.find(|ra| matches!(ra.action, Action::Raise { .. }))
.and_then(|ra| game_info.player_by_id(ra.player_id).map(|p| p.seat));
// Count raises and callers on the current street.
let num_raises = our_street_actions
.iter()
.filter(|ra| matches!(ra.action, Action::Raise { .. }))
.count() as u32;
let num_callers = our_street_actions
.iter()
.filter(|ra| matches!(ra.action, Action::Call))
.count() as u32;
StrategyContext {
game_info,
hole_cards: self.hole_cards,
our_seat,
our_id: self.id,
our_position,
num_active,
i_am_aggressor,
i_am_preflop_aggressor: self.i_am_preflop_aggressor,
num_raises,
num_callers,
aggressor_seat,
recorder: &self.recorder,
}
}
}
impl std::fmt::Debug for StrategyBot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StrategyBot")
.field("id", &self.id)
.field("name", &self.name)
.finish()
}
}
impl HoldemPlayer for StrategyBot {
fn id(&self) -> PlayerId {
self.id
}
fn name(&self) -> &str {
&self.name
}
fn init(&mut self, _game_info: &GameInfo) {
// No initialization needed beyond construction.
}
fn game_start_event(&mut self, _game_info: &GameInfo) {
// Reset per-hand state.
self.hole_cards = None;
self.recorder.reset();
self.i_am_preflop_aggressor = false;
}
fn hole_cards_dealt(&mut self, hole_cards: &HoleCards, _game_info: &GameInfo) {
self.hole_cards = Some(*hole_cards);
}
fn stage_event(&mut self, _game_info: &GameInfo) {
// No special handling needed — context is rebuilt on each act().
}
fn act(&mut self, game_info: &GameInfo) -> Action {
let ctx = self.build_context(game_info);
if self.strategy.is_applicable(&ctx) {
let action = self.strategy.get_action(&ctx);
// Track preflop aggression.
if game_info.is_preflop() && matches!(action, Action::Raise { .. }) {
self.i_am_preflop_aggressor = true;
}
action
} else {
// Strategy not applicable — default to fold.
Action::Fold
}
}
fn action_event(
&mut self,
player_id: PlayerId,
action: &Action,
game_info: &GameInfo,
) {
// Record the action for context building.
self.recorder.record(game_info.street, player_id, action.clone());
}
fn showdown_event(
&mut self,
_player_id: PlayerId,
_hole_cards: Option<&HoleCards>,
_hand_value: Option<&HandValue>,
_game_info: &GameInfo,
) {
// No special handling.
}
fn game_over_event(&mut self, _game_info: &GameInfo) {
// Reset per-hand state.
self.hole_cards = None;
self.recorder.reset();
self.i_am_preflop_aggressor = false;
}
fn on_eliminated(&self, _placement: u32) {
// No special handling.
}
}
// ---------------------------------------------------------------------------
// 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::board::Street;
use crate::Card;
use std::str::FromStr;
/// A simple strategy that always calls.
struct AlwaysCall;
impl Strategy for AlwaysCall {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Call
}
}
/// A strategy that is never applicable (should default to fold).
struct NeverApplicable;
impl Strategy for NeverApplicable {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
false
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Call
}
}
fn make_game_info(player_id: PlayerId) -> GameInfo {
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: 100,
min_raise: 100,
button_seat: 1,
small_blind_seat: 0,
big_blind_seat: 1,
next_to_act: Some(0),
players: vec![
Some(PlayerInfo {
id: player_id,
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: 100,
wagered_this_hand: 100,
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,
}
}
#[test]
fn strategy_bot_returns_call_from_strategy() {
let mut bot = StrategyBot::new(PlayerId(1), "TestBot".into(), Box::new(AlwaysCall));
let gi = make_game_info(PlayerId(1));
let action = bot.act(&gi);
assert_eq!(action, Action::Call);
}
#[test]
fn strategy_bot_folds_when_not_applicable() {
let mut bot = StrategyBot::new(PlayerId(1), "TestBot".into(), Box::new(NeverApplicable));
let gi = make_game_info(PlayerId(1));
let action = bot.act(&gi);
assert_eq!(action, Action::Fold);
}
#[test]
fn strategy_bot_tracks_hole_cards() {
let mut bot = StrategyBot::new(PlayerId(1), "TestBot".into(), Box::new(AlwaysCall));
let gi = make_game_info(PlayerId(1));
let cards = HoleCards::new(
Card::from_str("As").unwrap(),
Card::from_str("Kh").unwrap(),
).unwrap();
bot.hole_cards_dealt(&cards, &gi);
// The context built in act() should now have hole_cards = Some(...)
let action = bot.act(&gi);
assert_eq!(action, Action::Call);
}
#[test]
fn strategy_bot_resets_on_game_start() {
let mut bot = StrategyBot::new(PlayerId(1), "TestBot".into(), Box::new(AlwaysCall));
let gi = make_game_info(PlayerId(1));
let cards = HoleCards::new(
Card::from_str("As").unwrap(),
Card::from_str("Kh").unwrap(),
).unwrap();
bot.hole_cards_dealt(&cards, &gi);
// Game start should reset hole cards.
bot.game_start_event(&gi);
// After reset, act should still work (hole_cards will be None).
let action = bot.act(&gi);
assert_eq!(action, Action::Call);
}
#[test]
fn strategy_bot_records_actions() {
let mut bot = StrategyBot::new(PlayerId(1), "TestBot".into(), Box::new(AlwaysCall));
let gi = make_game_info(PlayerId(1));
// Simulate an action event from another player.
bot.action_event(PlayerId(2), &Action::Raise { total: 200 }, &gi);
// The recorder should have recorded it.
assert_eq!(bot.recorder.flop_actions().len(), 1);
}
}

View file

@ -31,7 +31,6 @@ 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;

View file

@ -1,242 +0,0 @@
//! Action recorder — tracks actions for post-hand analysis and opponent modeling.
//!
//! Ported from the Java `ActionRecorder` class in the Supersonic framework.
//! Records all player actions in a hand, segmented by betting round (street).
use crate::board::Street;
use crate::game::action::Action;
use crate::game::types::PlayerId;
/// A recorded action with the acting player.
#[derive(Debug, Clone)]
pub struct RecordedAction {
/// The player who performed the action.
pub player_id: PlayerId,
/// The action performed.
pub action: Action,
}
/// Records all actions in a hand, segmented by betting round.
///
/// # Examples
///
/// ```
/// use holdem_core::game::strategy::action_recorder::{ActionRecorder, RecordedAction};
/// use holdem_core::game::action::Action;
/// use holdem_core::game::types::PlayerId;
/// use holdem_core::board::Street;
///
/// let mut recorder = ActionRecorder::new();
///
/// recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 100 });
/// recorder.record(Street::Preflop, PlayerId(2), Action::Call);
/// recorder.record(Street::Flop, PlayerId(1), Action::Check);
/// recorder.record(Street::Flop, PlayerId(2), Action::Check);
///
/// assert_eq!(recorder.preflop_actions().len(), 2);
/// assert_eq!(recorder.flop_actions().len(), 2);
/// assert_eq!(recorder.all_actions().len(), 4);
/// ```
#[derive(Debug, Clone)]
pub struct ActionRecorder {
preflop: Vec<RecordedAction>,
flop: Vec<RecordedAction>,
turn: Vec<RecordedAction>,
river: Vec<RecordedAction>,
}
impl ActionRecorder {
/// Create a new empty action recorder.
pub fn new() -> Self {
Self {
preflop: Vec::new(),
flop: Vec::new(),
turn: Vec::new(),
river: Vec::new(),
}
}
/// Record an action for the current street.
pub fn record(&mut self, street: Street, player_id: PlayerId, action: Action) {
let recorded = RecordedAction { player_id, action };
match street {
Street::Preflop => &mut self.preflop,
Street::Flop => &mut self.flop,
Street::Turn => &mut self.turn,
Street::River => &mut self.river,
}
.push(recorded);
}
/// Clear all recorded actions (new hand).
pub fn reset(&mut self) {
self.preflop.clear();
self.flop.clear();
self.turn.clear();
self.river.clear();
}
/// Get all actions across all streets, in order (preflop → flop → turn → river).
pub fn all_actions(&self) -> Vec<&RecordedAction> {
let mut all = Vec::new();
all.extend(self.preflop.iter());
all.extend(self.flop.iter());
all.extend(self.turn.iter());
all.extend(self.river.iter());
all
}
/// Get preflop actions.
pub fn preflop_actions(&self) -> &[RecordedAction] {
&self.preflop
}
/// Get flop actions.
pub fn flop_actions(&self) -> &[RecordedAction] {
&self.flop
}
/// Get turn actions.
pub fn turn_actions(&self) -> &[RecordedAction] {
&self.turn
}
/// Get river actions.
pub fn river_actions(&self) -> &[RecordedAction] {
&self.river
}
/// Get actions for a specific player across all streets.
pub fn actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
self.all_actions()
.into_iter()
.filter(|ra| ra.player_id == player_id)
.collect()
}
/// Get preflop actions for a specific player.
pub fn preflop_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
self.preflop
.iter()
.filter(|ra| ra.player_id == player_id)
.collect()
}
/// Get flop actions for a specific player.
pub fn flop_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
self.flop
.iter()
.filter(|ra| ra.player_id == player_id)
.collect()
}
/// Get turn actions for a specific player.
pub fn turn_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
self.turn
.iter()
.filter(|ra| ra.player_id == player_id)
.collect()
}
/// Get river actions for a specific player.
pub fn river_actions_for_player(&self, player_id: PlayerId) -> Vec<&RecordedAction> {
self.river
.iter()
.filter(|ra| ra.player_id == player_id)
.collect()
}
}
impl Default for ActionRecorder {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game::action::Action;
#[test]
fn test_record_and_retrieve() {
let mut recorder = ActionRecorder::new();
recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 100 });
recorder.record(Street::Preflop, PlayerId(2), Action::Call);
recorder.record(Street::Flop, PlayerId(1), Action::Check);
recorder.record(Street::Flop, PlayerId(2), Action::Check);
assert_eq!(recorder.preflop_actions().len(), 2);
assert_eq!(recorder.flop_actions().len(), 2);
assert_eq!(recorder.turn_actions().len(), 0);
assert_eq!(recorder.river_actions().len(), 0);
assert_eq!(recorder.all_actions().len(), 4);
}
#[test]
fn test_actions_for_player() {
let mut recorder = ActionRecorder::new();
recorder.record(Street::Preflop, PlayerId(1), Action::Raise { total: 50 });
recorder.record(Street::Preflop, PlayerId(2), Action::Call);
recorder.record(Street::Flop, PlayerId(1), Action::Check);
recorder.record(Street::Flop, PlayerId(2), Action::Raise { total: 100 });
let p1_actions = recorder.actions_for_player(PlayerId(1));
assert_eq!(p1_actions.len(), 2);
assert_eq!(p1_actions[0].player_id, PlayerId(1));
}
#[test]
fn test_preflop_actions_for_player() {
let mut recorder = ActionRecorder::new();
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
recorder.record(Street::Preflop, PlayerId(2), Action::Fold);
recorder.record(Street::Flop, PlayerId(1), Action::Check);
let p1_preflop = recorder.preflop_actions_for_player(PlayerId(1));
assert_eq!(p1_preflop.len(), 1);
assert!(matches!(p1_preflop[0].action, Action::Call));
}
#[test]
fn test_reset() {
let mut recorder = ActionRecorder::new();
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
recorder.record(Street::Flop, PlayerId(1), Action::Check);
assert_eq!(recorder.all_actions().len(), 2);
recorder.reset();
assert_eq!(recorder.all_actions().len(), 0);
assert_eq!(recorder.preflop_actions().len(), 0);
}
#[test]
fn test_all_streets() {
let mut recorder = ActionRecorder::new();
recorder.record(Street::Preflop, PlayerId(1), Action::Call);
recorder.record(Street::Flop, PlayerId(1), Action::Check);
recorder.record(Street::Turn, PlayerId(1), Action::Check);
recorder.record(Street::River, PlayerId(1), Action::AllIn);
assert_eq!(recorder.preflop_actions().len(), 1);
assert_eq!(recorder.flop_actions().len(), 1);
assert_eq!(recorder.turn_actions().len(), 1);
assert_eq!(recorder.river_actions().len(), 1);
assert_eq!(recorder.all_actions().len(), 4);
}
#[test]
fn test_default() {
let recorder = ActionRecorder::default();
assert_eq!(recorder.all_actions().len(), 0);
}
}

View file

@ -1,714 +0,0 @@
//! Strategy context — game state and metadata available to strategy modules.
//!
//! Provides a read-only snapshot of the current game state that strategies can
//! query to make decisions. This replaces the Java pattern where `ModularStrategy`
//! stored all state as protected fields — instead, everything is bundled into a
//! single `StrategyContext` that is passed to each strategy.
//!
//! Ported from the Java Supersonic framework's `ModularStrategy` protected fields.
use crate::game::game_info::GameInfo;
use crate::game::strategy::action_recorder::ActionRecorder;
use crate::game::types::{ChipCount, PlayerId, SeatIndex};
use crate::HoleCards;
/// Read-only context provided to strategies for decision-making.
///
/// Contains the current game state, hole cards, position information,
/// aggressor tracking, and action history. Strategies query this context
/// to make decisions.
///
/// # Lifetimes
///
/// The `'a` lifetime ties this context to the borrowed [`GameInfo`] and
/// [`ActionRecorder`] — the context is a transient snapshot created for
/// a single decision point and does not outlive the game state it references.
pub struct StrategyContext<'a> {
/// Current game state from the engine.
pub game_info: &'a GameInfo,
/// Our hole cards for this hand. `None` if we folded or haven't been dealt yet.
pub hole_cards: Option<HoleCards>,
/// Our seat index.
pub our_seat: SeatIndex,
/// Our player ID.
pub our_id: PlayerId,
/// Our position (0 = SB, 1 = BB, ..., N-1 = Button).
pub our_position: u32,
/// Number of active players still in the hand.
pub num_active: usize,
/// Whether we were the last raiser in the current betting round.
pub i_am_aggressor: bool,
/// Whether we raised preflop.
pub i_am_preflop_aggressor: bool,
/// Number of raises in the current betting round.
pub num_raises: u32,
/// Number of callers since the last raise.
pub num_callers: u32,
/// Seat of the last raiser, if any.
pub aggressor_seat: Option<SeatIndex>,
/// Action recorder for this hand.
pub recorder: &'a ActionRecorder,
}
impl<'a> StrategyContext<'a> {
// --- Stack helpers ---
/// Our remaining chip stack.
pub fn our_stack(&self) -> ChipCount {
self.game_info
.player_by_id(self.our_id)
.map(|p| p.stack)
.unwrap_or(0)
}
/// Big blind amount.
pub fn big_blind(&self) -> ChipCount {
self.game_info.big_blind
}
/// Small blind amount.
pub fn small_blind(&self) -> ChipCount {
self.game_info.small_blind
}
/// Ante amount (0 if no ante).
pub fn ante(&self) -> ChipCount {
self.game_info.ante
}
/// Our stack size expressed in big blinds.
///
/// ```text
/// stack_in_bb = our_stack / big_blind
/// ```
pub fn stack_in_bb(&self) -> f64 {
let bb = self.big_blind() as f64;
if bb == 0.0 {
return 0.0;
}
self.our_stack() as f64 / bb
}
/// Harrington M ratio — stack divided by the cost of one round.
///
/// ```text
/// M = our_stack / (sb + bb + ante × num_active)
/// ```
///
/// If ante is 0, simplifies to `our_stack / (sb + bb)`.
pub fn m_ratio(&self) -> f64 {
let cost_per_round = if self.ante() > 0 {
(self.small_blind() + self.big_blind()
+ self.ante() * self.num_active as u64) as f64
} else {
(self.small_blind() + self.big_blind()) as f64
};
if cost_per_round == 0.0 {
return 0.0;
}
self.our_stack() as f64 / cost_per_round
}
/// Effective M adjusted for table size.
///
/// ```text
/// effective_m = M × sqrt(num_active / 10)
/// ```
///
/// In short-handed games the blinds come around more often, so the
/// effective M is lower than the raw M.
pub fn effective_m(&self) -> f64 {
let m = self.m_ratio();
m * (self.num_active as f64 / 10.0).sqrt()
}
/// Q ratio — our stack relative to the average stack.
///
/// ```text
/// Q = our_stack / average_stack
/// ```
///
/// Returns 1.0 when all stacks are equal. Values > 1.0 mean we have a
/// bigger-than-average stack.
pub fn q_ratio(&self) -> f64 {
let mut total_chips: u64 = 0;
let mut player_count: u64 = 0;
for player_opt in &self.game_info.players {
if let Some(p) = player_opt {
if p.is_in_hand() {
total_chips += p.stack + p.wagered_this_hand;
player_count += 1;
}
}
}
if player_count == 0 {
return 0.0;
}
let avg = total_chips as f64 / player_count as f64;
if avg == 0.0 {
return 0.0;
}
let our_total = self.our_stack() as f64
+ self
.game_info
.player_by_id(self.our_id)
.map(|p| p.wagered_this_hand as f64)
.unwrap_or(0.0);
our_total / avg
}
// --- Pot odds ---
/// Amount we need to call to stay in the hand.
pub fn amount_to_call(&self) -> ChipCount {
self.game_info.get_amount_to_call(self.our_id)
}
/// Current pot size.
pub fn pot_size(&self) -> ChipCount {
self.game_info.pot
}
/// Pot odds as a fraction: `amount_to_call / (pot_size + amount_to_call)`.
///
/// Returns 0.0 when there is nothing to call (we can check).
pub fn pot_odds(&self) -> f64 {
let to_call = self.amount_to_call();
if to_call == 0 {
return 0.0;
}
let pot = self.pot_size();
to_call as f64 / (pot + to_call) as f64
}
// --- Position helpers ---
/// Whether we're the last to act in the current betting round.
///
/// Simplified heuristic: true if no active player with a higher position
/// is still in the hand and hasn't acted yet. For preflop with no raises,
/// the big blind is last to act.
pub fn i_am_last_to_act(&self) -> bool {
let our_seat = self.our_seat;
// If there's a next_to_act and it's not us, we're not last.
if let Some(next) = self.game_info.next_to_act {
if next != our_seat {
return false;
}
}
// We are last if next_to_act is our seat (or None and we're the
// only active player).
true
}
/// Whether any opponent has a stack greater than `factor × our_stack`.
///
/// Useful for determining if we can be covered (e.g., for all-in
/// decisions). A factor of 1.0 checks if anyone simply has us covered.
pub fn we_are_covered(&self, factor: f64) -> bool {
let our_stack = self.our_stack() as f64;
let threshold = factor * our_stack;
for player_opt in &self.game_info.players {
if let Some(p) = player_opt {
// Only consider opponents who are still in the hand
if p.id != self.our_id && p.is_in_hand() {
let opp_total = (p.stack + p.wagered_this_hand) as f64;
if opp_total > threshold {
return true;
}
}
}
}
false
}
/// Effective stack size in big blinds — the smallest active stack in the
/// pot, capped by our own stack.
///
/// This is the most we can win (or lose) in the hand against a single
/// opponent.
pub fn effective_stack_bb(&self) -> f64 {
let our_stack = self.our_stack();
let mut min_stack = our_stack;
for player_opt in &self.game_info.players {
if let Some(p) = player_opt {
if p.id != self.our_id && p.is_in_hand() {
min_stack = min_stack.min(p.stack);
}
}
}
let bb = self.big_blind() as f64;
if bb == 0.0 {
return 0.0;
}
min_stack as f64 / bb
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game::betting::BetLimit;
use crate::game::player_info::{PlayerInfo, PlayerState};
use crate::board::Street;
/// Helper to build a minimal `GameInfo` for testing.
fn make_game_info(
pot: ChipCount,
current_bet: ChipCount,
big_blind: ChipCount,
small_blind: ChipCount,
ante: ChipCount,
players: Vec<Option<PlayerInfo>>,
) -> GameInfo {
GameInfo {
num_seats: players.len() as SeatIndex,
bet_limit: BetLimit::NoLimit {
min_raise: big_blind,
},
small_blind,
big_blind,
ante,
street: Street::Flop,
board: vec![],
pot,
current_bet,
min_raise: big_blind,
button_seat: 2,
small_blind_seat: 0,
big_blind_seat: 1,
next_to_act: Some(0),
players,
actions_this_hand: vec![vec![], vec![]],
num_raises: 1,
hand_number: 1,
active_players: 2,
}
}
fn make_player(
id: u64,
seat: SeatIndex,
stack: ChipCount,
wagered_this_round: ChipCount,
wagered_this_hand: ChipCount,
state: PlayerState,
) -> PlayerInfo {
PlayerInfo {
id: PlayerId(id),
name: format!("Player {}", id),
seat,
stack,
wagered_this_round,
wagered_this_hand,
state,
has_acted_this_round: false,
is_dealer: false,
is_small_blind: seat == 0,
is_big_blind: seat == 1,
bankroll: 0,
}
}
fn make_recorder() -> ActionRecorder {
ActionRecorder::new()
}
#[test]
fn test_stack_in_bb() {
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 300, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
// 500 / 100 = 5.0 BB
assert!((ctx.stack_in_bb() - 5.0).abs() < 1e-9);
}
#[test]
fn test_stack_in_bb_zero_blind() {
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(0, 0, 0, 0, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 1,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
// 0 BB should not panic, returns 0.0
assert!((ctx.stack_in_bb() - 0.0).abs() < 1e-9);
}
#[test]
fn test_pot_odds() {
// Pot is 200, need to call 100 → odds = 100 / (200 + 100) = 0.333...
let players = vec![
Some(make_player(1, 0, 500, 0, 100, PlayerState::Active)),
Some(make_player(2, 1, 500, 100, 100, PlayerState::Active)),
];
let gi = make_game_info(200, 100, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 1,
num_callers: 0,
aggressor_seat: Some(1),
recorder: &recorder,
};
let odds = ctx.pot_odds();
assert!((odds - 1.0 / 3.0).abs() < 1e-9);
}
#[test]
fn test_pot_odds_zero_to_call() {
// Nothing to call → pot odds = 0.0
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 500, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!((ctx.pot_odds() - 0.0).abs() < 1e-9);
}
#[test]
fn test_we_are_covered_true() {
// We have 500, opponent has 1500. factor=1.0 → threshold=500 → covered
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 1500, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!(ctx.we_are_covered(1.0));
}
#[test]
fn test_we_are_covered_false() {
// We have 500, opponent has 400. factor=1.0 → threshold=500 → not covered
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 400, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!(!ctx.we_are_covered(1.0));
}
#[test]
fn test_we_are_covered_with_factor() {
// We have 500, opponent has 800. factor=2.0 → threshold=1000 → not covered
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 800, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!(!ctx.we_are_covered(2.0));
assert!(ctx.we_are_covered(1.0));
}
#[test]
fn test_m_ratio_no_ante() {
// Stack=1000, sb=50, bb=100, no ante → M = 1000 / 150 ≈ 6.67
let players = vec![
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(150, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
let m = ctx.m_ratio();
assert!((m - 1000.0 / 150.0).abs() < 1e-9);
}
#[test]
fn test_m_ratio_with_ante() {
// Stack=1000, sb=50, bb=100, ante=25, 2 players
// cost_per_round = 50 + 100 + 25*2 = 200
// M = 1000 / 200 = 5.0
let players = vec![
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(200, 0, 100, 50, 25, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
let m = ctx.m_ratio();
assert!((m - 5.0).abs() < 1e-9);
}
#[test]
fn test_effective_m() {
// 2 active players, M = 5.0
// effective_m = 5.0 * sqrt(2/10) = 5.0 * sqrt(0.2)
let players = vec![
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(200, 0, 100, 50, 25, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
let eff_m = ctx.effective_m();
let expected = 5.0 * (2.0_f64 / 10.0).sqrt();
assert!((eff_m - expected).abs() < 1e-9);
}
#[test]
fn test_q_ratio() {
// Both have 1000 → Q = 1.0
let players = vec![
Some(make_player(1, 0, 1000, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 1000, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!((ctx.q_ratio() - 1.0).abs() < 1e-9);
}
#[test]
fn test_effective_stack_bb() {
// We have 500, opponent has 300 → effective = min(500, 300) = 300
// 300 / 100 = 3.0 BB
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 300, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!((ctx.effective_stack_bb() - 3.0).abs() < 1e-9);
}
#[test]
fn test_amount_to_call() {
// current_bet=200, we wagered 0 this round → need to call 200
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 500, 200, 200, PlayerState::Active)),
];
let gi = make_game_info(200, 200, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 1,
num_callers: 0,
aggressor_seat: Some(1),
recorder: &recorder,
};
assert_eq!(ctx.amount_to_call(), 200);
}
#[test]
fn test_we_are_covered_ignores_folded() {
// We have 500, one folded opponent with 2000, one active with 300
// → not covered (folded opponent is ignored)
let players = vec![
Some(make_player(1, 0, 500, 0, 0, PlayerState::Active)),
Some(make_player(2, 1, 2000, 0, 0, PlayerState::Folded)),
Some(make_player(3, 2, 300, 0, 0, PlayerState::Active)),
];
let gi = make_game_info(100, 0, 100, 50, 0, players);
let recorder = make_recorder();
let ctx = StrategyContext {
game_info: &gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder: &recorder,
};
assert!(!ctx.we_are_covered(1.0));
}
}

View file

@ -1,621 +0,0 @@
//! Hand analyzer — evaluates hand strength and equity for strategy decisions.
//!
//! Ported from the Java `HandAnalyzer` class in the Supersonic framework.
//! Provides static analysis methods for poker hands combining hole cards and
//! community board cards.
use crate::board::Board;
use crate::card::Card;
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
use crate::evaluator::evaluator::{HandRank, HandValue};
use crate::hand::Hand;
use crate::hole_cards::HoleCards;
/// Stateless hand analysis utility.
///
/// All methods are associated functions (no `&self`) that operate on
/// `&HoleCards` and `&Board` references, delegating to the
/// [`AlgorithmicEvaluator`] for hand ranking.
pub struct HandAnalyzer;
impl HandAnalyzer {
/// Evaluate the combined hand (hole cards + board) and return its [`HandValue`].
fn evaluate(hole: &HoleCards, board: &Board) -> HandValue {
let hand = Hand::from_hole_cards_and_board(hole, board)
.expect("hole + board should produce a valid hand");
let cards = hand.cards();
match cards.len() {
5 => {
let a: [Card; 5] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
}
6 => {
let a: [Card; 6] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_6_card(&a)
}
7 => {
let a: [Card; 7] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_7_card(&a)
}
_ => HandValue::new(HandRank::HighCard, 0),
}
}
/// Evaluate the board-only hand (no hole cards) and return its [`HandValue`].
fn evaluate_board(board: &Board) -> HandValue {
let cards = board.visible_cards();
match cards.len() {
5 => {
let a: [Card; 5] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
}
6 => {
let a: [Card; 6] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_6_card(&a)
}
7 => {
let a: [Card; 7] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_7_card(&a)
}
_ => HandValue::new(HandRank::HighCard, 0),
}
}
/// True if the hole cards form a pocket pair whose rank is strictly above
/// every board card rank.
///
/// Port of Java `isOverPair`.
pub fn is_over_pair(hole: &HoleCards, board: &Board) -> bool {
let c0 = hole.cards[0];
let c1 = hole.cards[1];
if c0.rank() != c1.rank() {
return false;
}
let pair_rank = c0.rank();
for bc in board.visible_cards() {
if bc.rank() >= pair_rank {
return false;
}
}
true
}
/// True if any two cards in the combined hole+board share the same rank.
///
/// Port of Java `isPaired`.
pub fn is_paired(hole: &HoleCards, board: &Board) -> bool {
let mut ranks = Vec::new();
ranks.push(hole.cards[0].rank());
ranks.push(hole.cards[1].rank());
for bc in board.visible_cards() {
ranks.push(bc.rank());
}
// Check for any duplicate rank
for i in 1..ranks.len() {
for j in 0..i {
if ranks[i] == ranks[j] {
return true;
}
}
}
false
}
/// True if the hand is two pair using **both** hole cards (not just a
/// board pair).
///
/// Port of Java `isRealTwoPair`.
pub fn is_real_two_pair(hole: &HoleCards, board: &Board) -> bool {
// If hole cards are a pocket pair, it's not a "real two pair" using both
if hole.cards[0].rank() == hole.cards[1].rank() {
return false;
}
// If the board itself is paired, it's not a "real" two pair
if board_is_paired(board) {
return false;
}
Self::evaluate(hole, board).rank == HandRank::TwoPair
}
/// True if the hand rank is ≥ Three of a Kind.
///
/// Port of Java `isTripsOrHigher`.
pub fn is_trips_or_higher(hole: &HoleCards, board: &Board) -> bool {
Self::evaluate(hole, board).rank >= HandRank::ThreeOfAKind
}
/// Count remaining deck cards that improve the hand to two-pair or better.
///
/// Iterates all 52 cards, skips those already in hole+board, simulates
/// adding each remaining card to the board, and counts cards that improve
/// the hand rank.
///
/// Port of Java `numOutsToMonster`.
pub fn num_outs_to_monster(hole: &HoleCards, board: &Board) -> u32 {
let board_cards = board.visible_cards();
if board_cards.len() >= 5 {
return 0;
}
let current_rank = Self::evaluate(hole, board).rank;
// Collect cards already in play
let mut in_play = [false; 52];
for c in &hole.cards {
in_play[card_index(*c)] = true;
}
for c in board_cards {
in_play[card_index(*c)] = true;
}
let mut outs = 0u32;
for idx in 0..52u8 {
if in_play[idx as usize] {
continue;
}
let test_card = Card::new(idx % 13, idx / 13).unwrap();
// Build a new board with the extra card
let mut new_board_cards: Vec<Card> = board_cards.to_vec();
new_board_cards.push(test_card);
let new_board = build_board(&new_board_cards);
let new_rank = Self::evaluate(hole, &new_board).rank;
if new_rank > current_rank
&& (new_rank >= HandRank::TwoPair
|| Self::is_real_two_pair(hole, &new_board)
|| Self::is_trips_or_higher(hole, &new_board))
{
outs += 1;
}
}
outs
}
/// True if the combined hole+board contains an open-ended straight draw
/// (4 consecutive ranks, not a gutshot).
///
/// Port of Java `isOESD`. Only valid for 3-4 board cards.
pub fn is_oesd(hole: &HoleCards, board: &Board) -> bool {
let board_cards = board.visible_cards();
if board_cards.len() > 4 || board_cards.len() < 3 {
return false;
}
let sc = Self::straight_count(hole, board);
if sc != 4 {
return false;
}
// Get the high card of the straight run
let hi = straight_hi_rank(hole, board);
// Not OESD if the high card is Ace (only gutshot from bottom) or
// the low card would be below 2 (rank 0) — i.e. high card is 3 (rank 1).
// In Java: not OESD if hiCard rank == ACE or hiCard rank == FOUR.
// Ace rank in our system = 12, Four rank = 2.
// The "four" check means the run is A-2-3-4 which is wheel gutshot.
// Actually in Java: straightHiCard rank != ACE && != FOUR.
// Rank 3 (Five) as hi means 5-4-3-2, low would be Ace → also gutshot.
// Let's follow the Java logic: exclude when hi card is Ace (12) or
// when the sequence ends at the bottom (hi rank == 3 = Five, meaning 5-4-3-2).
// Java FOUR = rank index 2 in Meerkat (0-indexed from 2).
// In our system: rank 2 = Four. The Java check is:
// straightHiCard != ACE && straightHiCard != FOUR
// This means: if the 4-sequence high card is Four (rank 2), the sequence
// is 4-3-2-A which is a wheel gutshot. If high is Ace, it's also not OESD.
hi != 12 && hi != 2
}
/// True if the combined hole+board has 4 cards of the same suit (flush draw).
///
/// Port of Java `isFlushDraw`. Only valid for 3-4 board cards.
pub fn is_flush_draw(hole: &HoleCards, board: &Board) -> bool {
let board_cards = board.visible_cards();
if board_cards.len() > 4 || board_cards.len() < 3 {
return false;
}
Self::suit_count(hole, board) == 4
}
/// True if the combined hole+board has 5+ cards of the same suit (complete flush).
///
/// Port of Java `isFlush`. Only valid for 3-5 board cards.
pub fn is_flush(hole: &HoleCards, board: &Board) -> bool {
let board_cards = board.visible_cards();
if board_cards.len() > 5 || board_cards.len() < 3 {
return false;
}
Self::suit_count(hole, board) >= 5
}
/// True if both hole card ranks are strictly above every board card rank.
///
/// Port of Java `isTwoOverCards`.
pub fn is_two_over_cards(hole: &HoleCards, board: &Board) -> bool {
let board_cards = board.visible_cards();
if board_cards.is_empty() {
return false;
}
let r0 = hole.cards[0].rank();
let r1 = hole.cards[1].rank();
for bc in board_cards {
if bc.rank() >= r0 || bc.rank() >= r1 {
return false;
}
}
true
}
/// Max count of any single suit across hole+board.
///
/// Port of Java `suitCount(card0, card1, board)`.
pub fn suit_count(hole: &HoleCards, board: &Board) -> u8 {
let mut counts = [0u8; 4];
counts[hole.cards[0].suit() as usize] += 1;
counts[hole.cards[1].suit() as usize] += 1;
for bc in board.visible_cards() {
counts[bc.suit() as usize] += 1;
}
counts.iter().max().copied().unwrap_or(0)
}
/// Max suit count on board only.
///
/// Port of Java `suitCount(board)`.
pub fn board_suit_count(board: &Board) -> u8 {
let mut counts = [0u8; 4];
for bc in board.visible_cards() {
counts[bc.suit() as usize] += 1;
}
counts.iter().max().copied().unwrap_or(0)
}
/// Length of the longest consecutive rank sequence in hole+board.
///
/// Uses unique sorted ranks and finds the longest run.
/// Port of Java `straightCount(card0, card1, board)`.
pub fn straight_count(hole: &HoleCards, board: &Board) -> u8 {
let mut ranks: Vec<u8> = Vec::new();
ranks.push(hole.cards[0].rank());
ranks.push(hole.cards[1].rank());
for bc in board.visible_cards() {
ranks.push(bc.rank());
}
longest_consecutive_run(&ranks)
}
/// Length of the longest consecutive rank sequence on board only.
///
/// Port of Java `straightCount(board)`.
pub fn board_straight_count(board: &Board) -> u8 {
let ranks: Vec<u8> = board.visible_cards().iter().map(|c| c.rank()).collect();
longest_consecutive_run(&ranks)
}
/// True if the hole cards improve the board's hand rank.
///
/// Compares the evaluation of hole+board vs board-only.
/// Port of Java `weContribute`.
pub fn we_contribute(hole: &HoleCards, board: &Board) -> bool {
let with_us = Self::evaluate(hole, board);
let board_only = Self::evaluate_board(board);
with_us != board_only
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Convert a card to a unique index 0..51 (suit * 13 + rank).
fn card_index(c: Card) -> usize {
(c.suit() as usize) * 13 + (c.rank() as usize)
}
/// Check if the board itself contains a pair.
fn board_is_paired(board: &Board) -> bool {
let cards = board.visible_cards();
for i in 1..cards.len() {
for j in 0..i {
if cards[i].rank() == cards[j].rank() {
return true;
}
}
}
false
}
/// Build a `Board` from a slice of cards (deals flop/turn/river as needed).
fn build_board(cards: &[Card]) -> Board {
let mut board = Board::new();
if cards.len() >= 3 {
board.deal_flop(vec![cards[0], cards[1], cards[2]]).unwrap();
}
if cards.len() >= 4 {
board.deal_turn(cards[3]).unwrap();
}
if cards.len() >= 5 {
board.deal_river(cards[4]).unwrap();
}
board
}
/// Find the length of the longest consecutive run of ranks.
fn longest_consecutive_run(ranks: &[u8]) -> u8 {
if ranks.is_empty() {
return 0;
}
// Deduplicate and sort descending
let mut unique: Vec<u8> = ranks.to_vec();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return 0;
}
let mut best = 1u8;
let mut current = 1u8;
for i in 1..unique.len() {
if unique[i] == unique[i - 1] + 1 {
current += 1;
if current > best {
best = current;
}
} else {
current = 1;
}
}
best
}
/// Return the rank of the highest card in the longest consecutive run.
/// Used by `is_oesd` to determine if the draw is open-ended.
fn straight_hi_rank(hole: &HoleCards, board: &Board) -> u8 {
let mut ranks: Vec<u8> = Vec::new();
ranks.push(hole.cards[0].rank());
ranks.push(hole.cards[1].rank());
for bc in board.visible_cards() {
ranks.push(bc.rank());
}
let mut unique: Vec<u8> = ranks.to_vec();
unique.sort_unstable();
unique.dedup();
if unique.len() < 2 {
return unique.first().copied().unwrap_or(0);
}
let mut best_len = 1u8;
let mut best_hi = unique[0];
let mut current_len = 1u8;
for i in 1..unique.len() {
if unique[i] == unique[i - 1] + 1 {
current_len += 1;
if current_len > best_len {
best_len = current_len;
best_hi = unique[i];
}
} else {
current_len = 1;
}
}
// Return the LOWEST rank of the best run (to match Java's straightHiCard
// which returns the start of the run, i.e. the lowest card in the sequence).
// Actually, Java returns the card at the start of the longest run, which is
// the LOWEST rank. Let's compute that.
// best_hi is the highest. The lowest = best_hi - (best_len - 1).
// But for the OESD check we need the high card. Let's return the high card.
best_hi
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn make_board(cards: &[&str]) -> Board {
let mut board = Board::new();
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
if parsed.len() >= 3 {
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
}
if parsed.len() >= 4 {
board.deal_turn(parsed[3]).unwrap();
}
if parsed.len() >= 5 {
board.deal_river(parsed[4]).unwrap();
}
board
}
fn make_hole(c1: &str, c2: &str) -> HoleCards {
HoleCards::new(
Card::from_str(c1).unwrap(),
Card::from_str(c2).unwrap(),
)
.unwrap()
}
#[test]
fn test_is_over_pair_true() {
// Pocket Kings above a T-high board
let hole = make_hole("Ks", "Kh");
let board = make_board(&["Ts", "7d", "2c"]);
assert!(HandAnalyzer::is_over_pair(&hole, &board));
}
#[test]
fn test_is_over_pair_false_not_pair() {
let hole = make_hole("As", "Kh");
let board = make_board(&["Ts", "7d", "2c"]);
assert!(!HandAnalyzer::is_over_pair(&hole, &board));
}
#[test]
fn test_is_over_pair_false_rank_on_board() {
// Pocket TT with a T on board — not an overpair
let hole = make_hole("Ts", "Th");
let board = make_board(&["Td", "7d", "2c"]);
assert!(!HandAnalyzer::is_over_pair(&hole, &board));
}
#[test]
fn test_is_flush_draw() {
// 4 hearts: Ah Kh from hole, Qh Jh on board
let hole = make_hole("Ah", "Kh");
let board = make_board(&["Qh", "Jh", "2d"]);
assert!(HandAnalyzer::is_flush_draw(&hole, &board));
}
#[test]
fn test_is_not_flush_draw() {
let hole = make_hole("Ah", "Kd");
let board = make_board(&["Qh", "Jh", "2d"]);
assert!(!HandAnalyzer::is_flush_draw(&hole, &board));
}
#[test]
fn test_is_paired_with_hole_pair() {
let hole = make_hole("As", "Ah");
let board = make_board(&["Kd", "Qh", "Jc"]);
assert!(HandAnalyzer::is_paired(&hole, &board));
}
#[test]
fn test_is_paired_with_board_pair() {
let hole = make_hole("As", "Kd");
let board = make_board(&["Qh", "Qc", "Jc"]);
assert!(HandAnalyzer::is_paired(&hole, &board));
}
#[test]
fn test_is_not_paired() {
let hole = make_hole("As", "Kd");
let board = make_board(&["Qh", "Jc", "9s"]);
assert!(!HandAnalyzer::is_paired(&hole, &board));
}
#[test]
fn test_straight_count_five_card_run() {
// A-K-Q-J-T
let hole = make_hole("As", "Kd");
let board = make_board(&["Qh", "Jc", "Ts"]);
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 5);
}
#[test]
fn test_straight_count_four_card_run() {
// K-Q-J-T (no A)
let hole = make_hole("Ks", "Qd");
let board = make_board(&["Jh", "Tc", "4s"]);
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 4);
}
#[test]
fn test_straight_count_no_run() {
let hole = make_hole("As", "Kd");
let board = make_board(&["7h", "2c", "9s"]);
assert_eq!(HandAnalyzer::straight_count(&hole, &board), 2);
}
#[test]
fn test_suit_count() {
let hole = make_hole("Ah", "Kh");
let board = make_board(&["Qh", "Jd", "2c"]);
assert_eq!(HandAnalyzer::suit_count(&hole, &board), 3);
}
#[test]
fn test_board_suit_count() {
let board = make_board(&["Ah", "Kh", "Qh"]);
assert_eq!(HandAnalyzer::board_suit_count(&board), 3);
}
#[test]
fn test_is_two_over_cards() {
let hole = make_hole("As", "Kh");
let board = make_board(&["Qd", "Jc", "Ts"]);
assert!(HandAnalyzer::is_two_over_cards(&hole, &board));
}
#[test]
fn test_is_not_two_over_cards() {
let hole = make_hole("As", "Kh");
let board = make_board(&["Ad", "Jc", "Ts"]);
assert!(!HandAnalyzer::is_two_over_cards(&hole, &board));
}
#[test]
fn test_is_trips_or_higher() {
// Three aces
let hole = make_hole("As", "Ah");
let board = make_board(&["Ad", "Kc", "Qs"]);
assert!(HandAnalyzer::is_trips_or_higher(&hole, &board));
}
#[test]
fn test_is_not_trips_or_higher() {
// Just a pair
let hole = make_hole("As", "Kd");
let board = make_board(&["Ah", "Qc", "Js"]);
assert!(!HandAnalyzer::is_trips_or_higher(&hole, &board));
}
#[test]
fn test_we_contribute() {
// Hole cards improve the board
let hole = make_hole("As", "Ah");
let board = make_board(&["Kd", "Qc", "Js"]);
assert!(HandAnalyzer::we_contribute(&hole, &board));
}
#[test]
fn test_is_flush_complete() {
let hole = make_hole("Ah", "Kh");
let board = make_board(&["Qh", "Jh", "Th"]);
assert!(HandAnalyzer::is_flush(&hole, &board));
}
#[test]
fn test_board_straight_count() {
let board = make_board(&["Ts", "9d", "8c"]);
assert_eq!(HandAnalyzer::board_straight_count(&board), 3);
}
#[test]
fn test_is_real_two_pair() {
// AK with A-K-9-7-2 board → two pair AA-KK (no straight, no board pair)
let hole = make_hole("As", "Kd");
let board = make_board(&["Ah", "Kc", "9s", "7c", "2s"]);
assert!(HandAnalyzer::is_real_two_pair(&hole, &board));
}
#[test]
fn test_is_real_two_pair_false_pocket_pair() {
let hole = make_hole("As", "Ah");
let board = make_board(&["Kd", "Qc", "Js"]);
assert!(!HandAnalyzer::is_real_two_pair(&hole, &board));
}
#[test]
fn test_is_oesd() {
// K-Q-J-T with 4-card straight, open-ended
let hole = make_hole("Ks", "Qd");
let board = make_board(&["Jh", "Tc", "4s"]);
assert!(HandAnalyzer::is_oesd(&hole, &board));
}
#[test]
fn test_is_oesd_false_broadway_top() {
// A-K-Q-J is NOT OESD because high card is Ace
let hole = make_hole("As", "Kd");
let board = make_board(&["Qh", "Jc", "4s"]);
assert!(!HandAnalyzer::is_oesd(&hole, &board));
}
}

View file

@ -1,678 +0,0 @@
//! Hand helper — utility functions for hand evaluation in strategy contexts.
//!
//! Ported from the Java `HandHelper` class. Provides board texture analysis,
//! hand strength categorization, and the Kill Phil hand grouping system.
use crate::board::Board;
use crate::card::Card;
use crate::evaluator::algorithmic_evaluator::AlgorithmicEvaluator;
use crate::evaluator::evaluator::{HandRank, HandValue};
use crate::hand::Hand;
use crate::hole_cards::HoleCards;
/// Static utility class for hand and board analysis.
pub struct HandHelper;
impl HandHelper {
/// Evaluate the combined hand (hole cards + board).
fn evaluate(hole: &HoleCards, board: &Board) -> HandValue {
let hand = Hand::from_hole_cards_and_board(hole, board)
.expect("hole + board should produce a valid hand");
let cards = hand.cards();
match cards.len() {
5 => {
let a: [Card; 5] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_5_card_direct(&a)
}
6 => {
let a: [Card; 6] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_6_card(&a)
}
7 => {
let a: [Card; 7] = cards.try_into().unwrap();
AlgorithmicEvaluator::evaluate_7_card(&a)
}
_ => HandValue::new(HandRank::HighCard, 0),
}
}
/// Board texture: all visible board cards share the same suit.
///
/// Port of Java `suitedFlop`.
pub fn is_suited_flop(board: &Board) -> bool {
let cards = board.visible_cards();
if cards.len() < 3 {
return false;
}
let suit = cards[0].suit();
cards.iter().all(|c| c.suit() == suit)
}
/// Board texture: exactly two suits present on the board.
///
/// Port of Java `twoSuitedFlop`.
pub fn is_two_suited_flop(board: &Board) -> bool {
let cards = board.visible_cards();
if cards.len() < 3 {
return false;
}
let mut suits = [false; 4];
for c in cards {
suits[c.suit() as usize] = true;
}
suits.iter().filter(|&&s| s).count() == 2
}
/// Board has straight/flush potential that threatens made hands.
///
/// Returns true if the board has:
/// - 3+ cards of the same suit, or
/// - 3+ consecutive ranks, or
/// - A paired board
///
/// Port of Java `dangerousBoard`.
pub fn is_dangerous_board(board: &Board) -> bool {
let cards = board.visible_cards();
if cards.len() < 3 {
return false;
}
// Check for 3+ of the same suit
let mut suit_counts = [0u8; 4];
for c in cards {
suit_counts[c.suit() as usize] += 1;
}
if suit_counts.iter().any(|&c| c >= 3) {
return true;
}
// Check for 3+ consecutive ranks
let mut ranks: Vec<u8> = cards.iter().map(|c| c.rank()).collect();
ranks.sort_unstable();
ranks.dedup();
let mut consecutive = 1u8;
for i in 1..ranks.len() {
if ranks[i] == ranks[i - 1] + 1 {
consecutive += 1;
if consecutive >= 3 {
return true;
}
} else {
consecutive = 1;
}
}
// Check for paired board
let mut rank_counts = [0u8; 13];
for c in cards {
rank_counts[c.rank() as usize] += 1;
}
if rank_counts.iter().any(|&c| c >= 2) {
return true;
}
false
}
/// Open-ended straight draw on the board (4 consecutive ranks using
/// combined hole+board cards).
///
/// Port of Java `openEndedStraightDraw`.
pub fn is_open_ended_straight_draw(hole: &HoleCards, board: &Board) -> bool {
let mut ranks: Vec<u8> = Vec::new();
ranks.push(hole.cards[0].rank());
ranks.push(hole.cards[1].rank());
for bc in board.visible_cards() {
ranks.push(bc.rank());
}
ranks.sort_unstable();
ranks.dedup();
// Check for 4 consecutive ranks
if ranks.len() < 4 {
return false;
}
let mut consecutive = 1u8;
for i in 1..ranks.len() {
if ranks[i] == ranks[i - 1] + 1 {
consecutive += 1;
if consecutive >= 4 {
return true;
}
} else {
consecutive = 1;
}
}
false
}
/// Flush draw on the board: 4+ cards of the same suit in combined
/// hole+board.
///
/// Port of Java `flushDraw`.
pub fn is_flush_draw_board(hole: &HoleCards, board: &Board) -> bool {
let mut counts = [0u8; 4];
counts[hole.cards[0].suit() as usize] += 1;
counts[hole.cards[1].suit() as usize] += 1;
for bc in board.visible_cards() {
counts[bc.suit() as usize] += 1;
}
counts.iter().any(|&c| c >= 4)
}
/// Count drawing outs — estimate of cards that improve the hand.
///
/// Simplified port of Java `drawingOuts`. Counts:
/// - 8 outs for open-ended straight draw
/// - 9 outs for flush draw
/// - 2 outs for pocket pair (set draw)
/// - 6 outs for two overcards
/// - 3 outs for one overcard
pub fn drawing_outs(hole: &HoleCards, board: &Board) -> u32 {
let mut outs = 0u32;
let has_oesd = Self::is_open_ended_straight_draw(hole, board);
let has_flush_draw = Self::is_flush_draw_board(hole, board);
if has_oesd {
outs += 8;
}
if has_flush_draw {
outs += 9;
}
// Pocket pair → set draw
if hole.cards[0].rank() == hole.cards[1].rank() {
outs += 2;
} else if Self::is_overcards(hole, board) {
outs += 6;
} else if Self::is_one_overcard(hole, board) {
outs += 3;
}
outs
}
/// Evaluate hand strength, returning the [`HandRank`].
pub fn evaluate_hand(hole: &HoleCards, board: &Board) -> HandRank {
Self::evaluate(hole, board).rank
}
/// True if the highest hole card pairs with the highest board card.
///
/// Port of Java `topPair`.
pub fn is_top_pair(hole: &HoleCards, board: &Board) -> bool {
let cards = board.visible_cards();
if cards.is_empty() {
return false;
}
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
let r0 = hole.cards[0].rank();
let r1 = hole.cards[1].rank();
r0 == highest_board_rank || r1 == highest_board_rank
}
/// True if pocket pair is above all board cards.
///
/// Port of Java `overpair`.
pub fn is_overpair(hole: &HoleCards, board: &Board) -> bool {
let r0 = hole.cards[0].rank();
let r1 = hole.cards[1].rank();
if r0 != r1 {
return false;
}
let cards = board.visible_cards();
if cards.is_empty() {
return true;
}
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
r0 > highest_board_rank
}
/// True if both hole cards are higher than the highest board card.
///
/// Port of Java `overcards`.
pub fn is_overcards(hole: &HoleCards, board: &Board) -> bool {
let cards = board.visible_cards();
if cards.is_empty() {
return false;
}
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
hole.cards[0].rank() > highest_board_rank && hole.cards[1].rank() > highest_board_rank
}
/// True if exactly one hole card is higher than the highest board card.
fn is_one_overcard(hole: &HoleCards, board: &Board) -> bool {
let cards = board.visible_cards();
if cards.is_empty() {
return false;
}
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
let r0_over = hole.cards[0].rank() > highest_board_rank;
let r1_over = hole.cards[1].rank() > highest_board_rank;
r0_over ^ r1_over // exactly one
}
/// True if hand is top pair top kicker or better.
///
/// Port of Java `tptkOrBetter`.
pub fn is_tptk_or_better(hole: &HoleCards, board: &Board) -> bool {
let hv = Self::evaluate(hole, board);
// Two pair or better
if hv.rank > HandRank::Pair {
return true;
}
// Overpair counts
if Self::is_overpair(hole, board) {
return true;
}
// Top pair with top kicker
if Self::is_top_pair(hole, board) && Self::is_top_kicker(hole, board) {
return true;
}
false
}
/// True if hand is three of a kind or better.
///
/// Port of Java `setOrBetter`.
pub fn is_set_or_better(hole: &HoleCards, board: &Board) -> bool {
let hv = Self::evaluate(hole, board);
if hv.rank > HandRank::ThreeOfAKind {
return true;
}
if hv.rank == HandRank::ThreeOfAKind && hole.cards[0].rank() == hole.cards[1].rank() {
return true;
}
false
}
/// True if hand is full house or better.
///
/// Port of Java `fullHouseOrBetter`.
pub fn is_full_house_or_better(hole: &HoleCards, board: &Board) -> bool {
Self::evaluate(hole, board).rank >= HandRank::FullHouse
}
/// Check if the highest hole card that pairs the board is the top kicker.
fn is_top_kicker(hole: &HoleCards, board: &Board) -> bool {
let cards = board.visible_cards();
if cards.is_empty() {
return false;
}
let highest_board_rank = cards.iter().map(|c| c.rank()).max().unwrap();
let second_highest_board_rank = cards
.iter()
.map(|c| c.rank())
.filter(|&r| r != highest_board_rank)
.max();
// Find which hole card pairs with the board high card
let kicker_rank = if hole.cards[0].rank() == highest_board_rank {
hole.cards[1].rank()
} else if hole.cards[1].rank() == highest_board_rank {
hole.cards[0].rank()
} else {
return false;
};
// Kicker is top if it's an Ace, or higher than second-highest board card
match second_highest_board_rank {
Some(second) => kicker_rank >= second,
None => true, // All board cards same rank, any kicker is "top"
}
}
/// Kill Phil hand grouping (1–10 scale, 1 = best).
///
/// Groups preflop hands by strength tiers based on the Kill Phil system.
/// Returns 0 if the hand doesn't fit any group.
///
/// Port of Java `getKPGroup`.
pub fn kill_phil_group(hole: &HoleCards) -> u8 {
let r0 = hole.cards[0].rank(); // higher rank (HoleCards stores desc)
let r1 = hole.cards[1].rank(); // lower rank
let suited = hole.cards[0].suit() == hole.cards[1].suit();
let pair = r0 == r1;
// Helper: rank constants (0=Two .. 12=Ace)
const ACE: u8 = 12;
const KING: u8 = 11;
const QUEEN: u8 = 10;
const JACK: u8 = 9;
const TEN: u8 = 8;
const NINE: u8 = 7;
const EIGHT: u8 = 6;
const SEVEN: u8 = 5;
const SIX: u8 = 4;
const FOUR: u8 = 2;
const THREE: u8 = 1;
// Group 1: AA, KK
if pair && (r0 == ACE || r0 == KING) {
return 1;
}
// Group 2: QQ, AK (any suit)
if pair && r0 == QUEEN {
return 2;
}
if r0 == ACE && r1 == KING {
return 2;
}
// Group 3: JJ, TT, 99
if pair && (r0 == JACK || r0 == TEN || r0 == NINE) {
return 3;
}
// Group 4: AQs, 88, 77, 66
if r0 == ACE && r1 == QUEEN && suited {
return 4;
}
if pair && (r0 == EIGHT || r0 == SEVEN || r0 == SIX) {
return 4;
}
// Group 5: AQo, AJs, ATs
if r0 == ACE && r1 == QUEEN {
return 5;
}
if r0 == ACE && r1 == JACK && suited {
return 5;
}
if r0 == ACE && r1 == TEN && suited {
return 5;
}
// Group 6: Suited connectors with both cards ≥ Five (rank 3)
if suited && r1 >= FOUR && r0 == r1 + 1 {
return 6;
}
// Group 7: AJo, ATo, KQ
if r0 == ACE && r1 == JACK {
return 7;
}
if r0 == ACE && r1 == TEN {
return 7;
}
if r0 == KING && r1 == QUEEN {
return 7;
}
// Group 8: Any pocket pair, AXs, AX with both ≥ Seven, KJs, KJ, KTs
if pair {
return 8;
}
if r0 == ACE && suited {
return 8;
}
if r0 == ACE && r1 >= SIX {
return 8;
}
if r0 == KING && r1 == JACK && suited {
return 8;
}
if r0 == KING && r1 == JACK {
return 8;
}
if r0 == KING && r1 == TEN && suited {
return 8;
}
// Group 9: Suited one-gappers with both ≥ Five, AX
if suited && r1 >= FOUR && r0 == r1 + 2 {
return 9;
}
if r0 == ACE {
return 9;
}
// Group 10: Suited two-gappers with both ≥ Four, KXs with both ≥ Seven,
// Q8s, KTo, QTo, JTo, K9o, QJo
if suited && r1 >= THREE && r0 == r1 + 3 {
return 10;
}
if r0 == KING && suited && r1 >= SIX {
return 10;
}
if r0 == QUEEN && r1 == EIGHT && suited {
return 10;
}
if r0 == KING && r1 == TEN {
return 10;
}
if r0 == QUEEN && r1 == TEN {
return 10;
}
if r0 == JACK && r1 == TEN {
return 10;
}
if r0 == KING && r1 == NINE {
return 10;
}
if r0 == QUEEN && r1 == JACK {
return 10;
}
0 // Doesn't fit any group
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn make_board(cards: &[&str]) -> Board {
let mut board = Board::new();
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
if parsed.len() >= 3 {
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
}
if parsed.len() >= 4 {
board.deal_turn(parsed[3]).unwrap();
}
if parsed.len() >= 5 {
board.deal_river(parsed[4]).unwrap();
}
board
}
fn make_hole(c1: &str, c2: &str) -> HoleCards {
HoleCards::new(
Card::from_str(c1).unwrap(),
Card::from_str(c2).unwrap(),
)
.unwrap()
}
#[test]
fn test_is_suited_flop() {
let board = make_board(&["Ah", "Kh", "Qh"]);
assert!(HandHelper::is_suited_flop(&board));
}
#[test]
fn test_is_not_suited_flop() {
let board = make_board(&["Ah", "Kd", "Qh"]);
assert!(!HandHelper::is_suited_flop(&board));
}
#[test]
fn test_is_two_suited_flop() {
let board = make_board(&["Ah", "Kh", "Qd"]);
assert!(HandHelper::is_two_suited_flop(&board));
}
#[test]
fn test_is_not_two_suited_flop() {
let board = make_board(&["Ah", "Kd", "Qc"]);
assert!(!HandHelper::is_two_suited_flop(&board));
}
#[test]
fn test_is_dangerous_board_suited() {
let board = make_board(&["Ah", "Kh", "Qh"]);
assert!(HandHelper::is_dangerous_board(&board));
}
#[test]
fn test_is_dangerous_board_connected() {
let board = make_board(&["Ts", "9d", "8c"]);
assert!(HandHelper::is_dangerous_board(&board));
}
#[test]
fn test_is_dangerous_board_paired() {
let board = make_board(&["As", "Ad", "Kc"]);
assert!(HandHelper::is_dangerous_board(&board));
}
#[test]
fn test_is_not_dangerous_board() {
let board = make_board(&["As", "7d", "2c"]);
assert!(!HandHelper::is_dangerous_board(&board));
}
#[test]
fn test_is_top_pair() {
let hole = make_hole("As", "Kd");
let board = make_board(&["Ah", "Qc", "Js"]);
assert!(HandHelper::is_top_pair(&hole, &board));
}
#[test]
fn test_is_not_top_pair() {
let hole = make_hole("Ks", "Qd");
let board = make_board(&["Ah", "Jc", "Ts"]);
assert!(!HandHelper::is_top_pair(&hole, &board));
}
#[test]
fn test_is_overpair() {
let hole = make_hole("Ks", "Kh");
let board = make_board(&["Qd", "Jc", "Ts"]);
assert!(HandHelper::is_overpair(&hole, &board));
}
#[test]
fn test_is_not_overpair() {
let hole = make_hole("Ks", "Kh");
let board = make_board(&["Ad", "Jc", "Ts"]);
assert!(!HandHelper::is_overpair(&hole, &board));
}
#[test]
fn test_is_overcards() {
let hole = make_hole("As", "Kh");
let board = make_board(&["Qd", "Jc", "Ts"]);
assert!(HandHelper::is_overcards(&hole, &board));
}
#[test]
fn test_is_set_or_better() {
let hole = make_hole("As", "Ah");
let board = make_board(&["Ad", "Kc", "Qs"]);
assert!(HandHelper::is_set_or_better(&hole, &board));
}
#[test]
fn test_is_not_set_or_better() {
let hole = make_hole("As", "Kd");
let board = make_board(&["Ah", "Qc", "Js"]);
assert!(!HandHelper::is_set_or_better(&hole, &board));
}
#[test]
fn test_is_full_house_or_better() {
let hole = make_hole("As", "Ah");
let board = make_board(&["Ad", "Kc", "Ks"]);
assert!(HandHelper::is_full_house_or_better(&hole, &board));
}
#[test]
fn test_is_tptk_or_better() {
// Top pair (A) with top kicker (K)
let hole = make_hole("As", "Kh");
let board = make_board(&["Ad", "Qc", "Js"]);
assert!(HandHelper::is_tptk_or_better(&hole, &board));
}
#[test]
fn test_kill_phil_group_aa() {
let hole = make_hole("As", "Ah");
assert_eq!(HandHelper::kill_phil_group(&hole), 1);
}
#[test]
fn test_kill_phil_group_kk() {
let hole = make_hole("Ks", "Kh");
assert_eq!(HandHelper::kill_phil_group(&hole), 1);
}
#[test]
fn test_kill_phil_group_qq() {
let hole = make_hole("Qs", "Qh");
assert_eq!(HandHelper::kill_phil_group(&hole), 2);
}
#[test]
fn test_kill_phil_group_ak() {
let hole = make_hole("As", "Kh");
assert_eq!(HandHelper::kill_phil_group(&hole), 2);
}
#[test]
fn test_kill_phil_group_jj() {
let hole = make_hole("Js", "Jh");
assert_eq!(HandHelper::kill_phil_group(&hole), 3);
}
#[test]
fn test_kill_phil_group_aqs() {
let hole = make_hole("As", "Qs");
assert_eq!(HandHelper::kill_phil_group(&hole), 4);
}
#[test]
fn test_kill_phil_group_aqo() {
let hole = make_hole("As", "Qh");
assert_eq!(HandHelper::kill_phil_group(&hole), 5);
}
#[test]
fn test_kill_phil_group_suited_connectors() {
let hole = make_hole("Ts", "9s");
assert_eq!(HandHelper::kill_phil_group(&hole), 6);
}
#[test]
fn test_kill_phil_group_55() {
// Small pair → group 8
let hole = make_hole("5s", "5h");
assert_eq!(HandHelper::kill_phil_group(&hole), 8);
}
#[test]
fn test_evaluate_hand() {
let hole = make_hole("As", "Kh");
let board = make_board(&["Qh", "Jc", "Ts"]);
assert_eq!(HandHelper::evaluate_hand(&hole, &board), HandRank::Straight);
}
#[test]
fn test_drawing_outs() {
let hole = make_hole("Ks", "Qd");
let board = make_board(&["Jh", "Tc", "4s"]);
// OESD → 8 outs
assert!(HandHelper::drawing_outs(&hole, &board) >= 8);
}
}

View file

@ -1,20 +0,0 @@
//! 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};

View file

@ -1,759 +0,0 @@
//! Modular strategy — composite pattern for combining strategy components.
//!
//! Provides the [`Strategy`] trait and [`ModularStrategy`] composite pattern,
//! ported from the Java Supersonic framework's `ModularStrategy` class.
//!
//! # Architecture
//!
//! Strategies form a tree where each node can combine its children's decisions
//! using different voting mechanisms:
//!
//! - **[`VotingMode::FirstApplicable`]**: Returns the first applicable sub-strategy's action.
//! - **[`VotingMode::Democratic`]**: Majority vote among applicable strategies.
//! - **[`VotingMode::Random`]**: Weighted random selection among applicable strategies.
//!
//! # Example
//!
//! ```rust
//! use holdem_core::game::strategy::modular::{ModularStrategy, VotingMode};
//!
//! // Build a composite strategy with no children (defaults to Fold)
//! let strategy = ModularStrategy::new("root", VotingMode::FirstApplicable);
//! assert!(strategy.is_empty());
//! ```
use crate::game::action::Action;
use crate::game::strategy::context::StrategyContext;
use rand::Rng;
/// A single strategy that can produce a betting action.
///
/// Strategies are composable via [`ModularStrategy`] which uses voting
/// mechanisms to combine multiple sub-strategies.
///
/// # Implementors
///
/// Implementors must be `Send + Sync` because bots are used across threads
/// in simulations.
pub trait Strategy: Send + Sync {
/// Whether this strategy should be consulted in the current context.
///
/// Called before [`get_action`](Self::get_action). If this returns `false`,
/// the strategy is skipped entirely.
fn is_applicable(&self, ctx: &StrategyContext) -> bool;
/// Produce a betting action. Only called when [`is_applicable`](Self::is_applicable) returns `true`.
fn get_action(&self, ctx: &StrategyContext) -> Action;
/// Whether a `Fold` action should be treated as "no opinion" (not applicable).
///
/// When `true`, a parent [`ModularStrategy`] using
/// [`FirstApplicable`](VotingMode::FirstApplicable) voting will skip this
/// strategy if it returns [`Action::Fold`] and continue to the next child.
///
/// Default: `false` (fold is a valid action).
fn consider_fold_as_not_applicable(&self) -> bool {
false
}
/// Optional weight for voting mechanisms.
///
/// Used by [`VotingMode::Democratic`] and [`VotingMode::Random`] to
/// give some strategies more influence than others.
///
/// Default: `1`.
fn weight(&self) -> u32 {
1
}
}
/// How sub-strategies' actions are combined in a [`ModularStrategy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VotingMode {
/// Use the first applicable sub-strategy's action.
///
/// Iterates children in order. The first child where
/// [`Strategy::is_applicable`] returns `true` (and whose action is not
/// skipped due to [`consider_fold_as_not_applicable`](Strategy::consider_fold_as_not_applicable))
/// wins.
FirstApplicable,
/// Majority vote among applicable strategies.
///
/// Each applicable strategy contributes its action type (fold/check/call/raise).
/// Actions are grouped and counted (weighted by [`Strategy::weight`]).
/// The group with the most votes wins. Ties are broken by priority:
/// fold < check < call < raise.
Democratic,
/// Weighted random selection among applicable strategies.
///
/// Each applicable strategy's [`Strategy::weight`] determines its
/// probability of being selected. Uses the `rand` crate for randomness.
Random,
}
/// Composite strategy that delegates to sub-strategies via a voting mechanism.
///
/// Port of the Java `ModularStrategy` class. This is the core of the
/// Supersonic bot framework — strategies are composed in a tree where
/// each node can combine its children's decisions using different voting modes.
///
/// # Examples
///
/// ```rust
/// use holdem_core::game::strategy::modular::{ModularStrategy, VotingMode};
///
/// let strategy = ModularStrategy::new("my_bot", VotingMode::FirstApplicable);
/// assert!(strategy.is_empty());
/// ```
pub struct ModularStrategy {
/// Child strategies in priority order.
sub_strategies: Vec<Box<dyn Strategy>>,
/// How to combine sub-strategy actions.
voting_mode: VotingMode,
/// Name for debugging/logging.
name: String,
}
impl ModularStrategy {
/// Create a new `ModularStrategy` with the given name and voting mode.
pub fn new(name: impl Into<String>, voting_mode: VotingMode) -> Self {
Self {
sub_strategies: Vec::new(),
voting_mode,
name: name.into(),
}
}
/// Add a sub-strategy (builder pattern).
pub fn with_sub_strategy(mut self, strategy: Box<dyn Strategy>) -> Self {
self.sub_strategies.push(strategy);
self
}
/// Add a sub-strategy (mutable reference).
pub fn add_sub_strategy(&mut self, strategy: Box<dyn Strategy>) {
self.sub_strategies.push(strategy);
}
/// Returns the number of sub-strategies.
pub fn len(&self) -> usize {
self.sub_strategies.len()
}
/// Returns `true` if there are no sub-strategies.
pub fn is_empty(&self) -> bool {
self.sub_strategies.is_empty()
}
/// Returns the voting mode.
pub fn voting_mode(&self) -> VotingMode {
self.voting_mode
}
/// Returns the strategy name.
pub fn name(&self) -> &str {
&self.name
}
/// Collect all applicable sub-strategies with their actions.
///
/// Returns a vector of `(action, weight, index)` tuples where `index` is
/// the position in `sub_strategies` (used for tiebreaking).
fn collect_applicable<'a>(
&'a self,
ctx: &StrategyContext,
) -> Vec<(Action, u32, usize)> {
self.sub_strategies
.iter()
.enumerate()
.filter(|(_, s)| s.is_applicable(ctx))
.map(|(i, s)| (s.get_action(ctx), s.weight(), i))
.collect()
}
/// First-applicable voting: return the first applicable strategy's action.
///
/// If `consider_fold_as_not_applicable` is true and the action is a fold
/// or check, skip to the next strategy.
fn vote_first_applicable(&self, ctx: &StrategyContext) -> Action {
for strategy in &self.sub_strategies {
if strategy.is_applicable(ctx) {
let action = strategy.get_action(ctx);
if strategy.consider_fold_as_not_applicable()
&& matches!(action, Action::Fold | Action::Check)
{
// Skip — treat as "no opinion"
continue;
}
return action;
}
}
// No applicable strategy found — default to fold
Action::Fold
}
/// Democratic voting: count action types, return the majority.
///
/// Groups actions into fold, check/call, and raise categories. Each
/// strategy contributes its weight. Ties broken by priority:
/// fold < check/call < raise.
fn vote_democratic(&self, ctx: &StrategyContext) -> Action {
let applicable = self.collect_applicable(ctx);
if applicable.is_empty() {
return Action::Fold;
}
// Weighted vote counts for each action category.
let mut fold_weight: u32 = 0;
let mut check_call_weight: u32 = 0;
let mut raise_weight: u32 = 0;
// For raise actions, track the average raise amount.
let mut raise_total: u64 = 0;
let mut raise_count: u32 = 0;
for (action, weight, _idx) in &applicable {
match action {
Action::Fold => {
fold_weight += weight;
}
Action::Check | Action::Call => {
check_call_weight += weight;
}
Action::Raise { total } => {
raise_weight += weight;
raise_total += total;
raise_count += 1;
}
Action::AllIn => {
raise_weight += weight;
}
}
}
// Return the action type with the most votes.
// Ties broken by priority: raise > check/call > fold.
if raise_weight > 0 && raise_weight >= check_call_weight && raise_weight >= fold_weight {
if raise_count > 0 {
Action::Raise {
total: raise_total / raise_count as u64,
}
} else {
Action::AllIn
}
} else if check_call_weight > 0 && check_call_weight >= fold_weight {
Action::Call
} else {
Action::Fold
}
}
/// Random voting: weighted random selection among applicable strategies.
///
/// Each strategy's weight determines its probability of being chosen.
fn vote_random(&self, ctx: &StrategyContext) -> Action {
let applicable = self.collect_applicable(ctx);
if applicable.is_empty() {
return Action::Fold;
}
let total_weight: u32 = applicable.iter().map(|(_, w, _)| *w).sum();
if total_weight == 0 {
return Action::Fold;
}
let mut rng = rand::rng();
let mut selection = rng.random_range(0..total_weight);
for (action, weight, _idx) in &applicable {
if selection < *weight {
return action.clone();
}
selection -= weight;
}
// Fallback (should not reach here)
applicable[0].0.clone()
}
}
impl Strategy for ModularStrategy {
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
// Applicable if ANY sub-strategy is applicable.
self.sub_strategies.iter().any(|s| s.is_applicable(ctx))
}
fn get_action(&self, ctx: &StrategyContext) -> Action {
match self.voting_mode {
VotingMode::FirstApplicable => self.vote_first_applicable(ctx),
VotingMode::Democratic => self.vote_democratic(ctx),
VotingMode::Random => self.vote_random(ctx),
}
}
fn consider_fold_as_not_applicable(&self) -> bool {
false
}
fn weight(&self) -> u32 {
1
}
}
impl std::fmt::Debug for ModularStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ModularStrategy")
.field("name", &self.name)
.field("voting_mode", &self.voting_mode)
.field("num_sub_strategies", &self.sub_strategies.len())
.finish()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game::action::Action;
use crate::game::betting::BetLimit;
use crate::game::game_info::GameInfo;
use crate::game::player_info::{PlayerInfo, PlayerState};
use crate::game::strategy::action_recorder::ActionRecorder;
use crate::game::types::PlayerId;
use crate::board::Street;
// --- Mock strategies ---
/// Always returns `Action::Fold`, always applicable.
struct AlwaysFold;
impl Strategy for AlwaysFold {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Fold
}
}
/// Always returns `Action::Call`, always applicable.
struct AlwaysCall;
impl Strategy for AlwaysCall {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Call
}
}
/// Always returns `Action::Raise { total: 200 }`, always applicable.
struct AlwaysRaise;
impl Strategy for AlwaysRaise {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Raise { total: 200 }
}
}
/// Never applicable.
struct NeverApplicable;
impl Strategy for NeverApplicable {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
false
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Fold
}
}
/// Folds but considers fold as not applicable.
struct FoldAsNotApplicable;
impl Strategy for FoldAsNotApplicable {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
Action::Fold
}
fn consider_fold_as_not_applicable(&self) -> bool {
true
}
}
/// Weighted strategy with configurable weight.
struct WeightedStrategy {
action: Action,
weight: u32,
}
impl WeightedStrategy {
fn new(action: Action, weight: u32) -> Self {
Self { action, weight }
}
}
impl Strategy for WeightedStrategy {
fn is_applicable(&self, _ctx: &StrategyContext) -> bool {
true
}
fn get_action(&self, _ctx: &StrategyContext) -> Action {
self.action.clone()
}
fn weight(&self) -> u32 {
self.weight
}
}
// --- Test context builder ---
fn make_test_context() -> StrategyContext<'static> {
// We need to leak here to get 'static lifetime for tests.
// This is fine for tests only.
let gi = Box::leak(Box::new(GameInfo {
num_seats: 2,
bet_limit: BetLimit::NoLimit { min_raise: 100 },
small_blind: 50,
big_blind: 100,
ante: 0,
street: Street::Flop,
board: vec![],
pot: 200,
current_bet: 0,
min_raise: 100,
button_seat: 1,
small_blind_seat: 0,
big_blind_seat: 1,
next_to_act: Some(0),
players: vec![
Some(PlayerInfo {
id: PlayerId(1),
name: "P1".into(),
seat: 0,
stack: 1000,
wagered_this_round: 0,
wagered_this_hand: 0,
state: PlayerState::Active,
has_acted_this_round: false,
is_dealer: false,
is_small_blind: true,
is_big_blind: false,
bankroll: 0,
}),
Some(PlayerInfo {
id: PlayerId(2),
name: "P2".into(),
seat: 1,
stack: 1000,
wagered_this_round: 0,
wagered_this_hand: 0,
state: PlayerState::Active,
has_acted_this_round: false,
is_dealer: true,
is_small_blind: false,
is_big_blind: true,
bankroll: 0,
}),
],
actions_this_hand: vec![vec![], vec![]],
num_raises: 0,
hand_number: 1,
active_players: 2,
}));
let recorder = Box::leak(Box::new(ActionRecorder::new()));
StrategyContext {
game_info: gi,
hole_cards: None,
our_seat: 0,
our_id: PlayerId(1),
our_position: 0,
num_active: 2,
i_am_aggressor: false,
i_am_preflop_aggressor: false,
num_raises: 0,
num_callers: 0,
aggressor_seat: None,
recorder,
}
}
// --- FirstApplicable tests ---
#[test]
fn test_first_applicable_returns_first() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysCall));
// First applicable is AlwaysFold
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
#[test]
fn test_first_applicable_skips_inapplicable() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(NeverApplicable))
.with_sub_strategy(Box::new(AlwaysCall));
// NeverApplicable is skipped, AlwaysCall is first applicable
assert_eq!(strategy.get_action(&ctx), Action::Call);
}
#[test]
fn test_first_applicable_no_applicable_defaults_to_fold() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(NeverApplicable));
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
#[test]
fn test_first_applicable_fold_as_not_applicable() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(FoldAsNotApplicable))
.with_sub_strategy(Box::new(AlwaysCall));
// FoldAsNotApplicable returns Fold but is treated as not applicable,
// so we fall through to AlwaysCall.
assert_eq!(strategy.get_action(&ctx), Action::Call);
}
#[test]
fn test_first_applicable_fold_as_not_applicable_all_fold() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(FoldAsNotApplicable))
.with_sub_strategy(Box::new(FoldAsNotApplicable));
// All strategies return fold-as-not-applicable → default to Fold
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
// --- Democratic tests ---
#[test]
fn test_democratic_majority_call() {
let ctx = make_test_context();
// 2 calls vs 1 fold → call wins
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysCall))
.with_sub_strategy(Box::new(AlwaysCall))
.with_sub_strategy(Box::new(AlwaysFold));
assert_eq!(strategy.get_action(&ctx), Action::Call);
}
#[test]
fn test_democratic_majority_raise() {
let ctx = make_test_context();
// 2 raises vs 1 call → raise wins
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysRaise))
.with_sub_strategy(Box::new(AlwaysRaise))
.with_sub_strategy(Box::new(AlwaysCall));
assert_eq!(strategy.get_action(&ctx), Action::Raise { total: 200 });
}
#[test]
fn test_democratic_majority_fold() {
let ctx = make_test_context();
// 2 folds vs 1 call → fold wins
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysCall));
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
#[test]
fn test_democratic_tie_breaks_to_raise() {
let ctx = make_test_context();
// 1 raise vs 1 call → raise wins (raise > call priority)
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysRaise))
.with_sub_strategy(Box::new(AlwaysCall));
assert_eq!(strategy.get_action(&ctx), Action::Raise { total: 200 });
}
#[test]
fn test_democratic_tie_breaks_to_call_over_fold() {
let ctx = make_test_context();
// 1 call vs 1 fold → call wins (call > fold priority)
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysCall))
.with_sub_strategy(Box::new(AlwaysFold));
assert_eq!(strategy.get_action(&ctx), Action::Call);
}
#[test]
fn test_democratic_weighted() {
let ctx = make_test_context();
// Fold with weight 3 vs Call with weight 1 → fold wins
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Fold, 3)))
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Call, 1)));
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
#[test]
fn test_democratic_no_applicable() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(NeverApplicable));
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
// --- Random tests ---
#[test]
fn test_random_does_not_crash() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::Random)
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysCall))
.with_sub_strategy(Box::new(AlwaysRaise));
// Run many times to verify no crash
for _ in 0..100 {
let action = strategy.get_action(&ctx);
assert!(matches!(
action,
Action::Fold | Action::Call | Action::Raise { .. }
));
}
}
#[test]
fn test_random_weighted() {
let ctx = make_test_context();
// Call with weight 100, Fold with weight 1 → almost always call
let strategy = ModularStrategy::new("test", VotingMode::Random)
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Call, 100)))
.with_sub_strategy(Box::new(WeightedStrategy::new(Action::Fold, 1)));
let mut call_count = 0;
for _ in 0..1000 {
if strategy.get_action(&ctx) == Action::Call {
call_count += 1;
}
}
// Should be overwhelmingly calls (> 90%)
assert!(call_count > 900);
}
#[test]
fn test_random_no_applicable() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::Random)
.with_sub_strategy(Box::new(NeverApplicable));
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
// --- General tests ---
#[test]
fn test_empty_strategy() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("empty", VotingMode::FirstApplicable);
assert!(strategy.is_empty());
assert_eq!(strategy.len(), 0);
assert!(!strategy.is_applicable(&ctx));
// get_action defaults to fold when no sub-strategies
assert_eq!(strategy.get_action(&ctx), Action::Fold);
}
#[test]
fn test_is_applicable_true() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(AlwaysFold));
assert!(strategy.is_applicable(&ctx));
}
#[test]
fn test_is_applicable_false() {
let ctx = make_test_context();
let strategy = ModularStrategy::new("test", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(NeverApplicable));
assert!(!strategy.is_applicable(&ctx));
}
#[test]
fn test_builder_pattern() {
let strategy = ModularStrategy::new("test", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysCall));
assert_eq!(strategy.len(), 2);
assert_eq!(strategy.name(), "test");
assert_eq!(strategy.voting_mode(), VotingMode::Democratic);
}
#[test]
fn test_add_sub_strategy_mut() {
let mut strategy = ModularStrategy::new("test", VotingMode::FirstApplicable);
assert!(strategy.is_empty());
strategy.add_sub_strategy(Box::new(AlwaysFold));
assert_eq!(strategy.len(), 1);
strategy.add_sub_strategy(Box::new(AlwaysCall));
assert_eq!(strategy.len(), 2);
}
#[test]
fn test_nested_composite() {
let ctx = make_test_context();
// Inner composite: always calls
let inner = ModularStrategy::new("inner", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(AlwaysCall));
// Outer composite: first applicable is inner, then a raise strategy
let outer = ModularStrategy::new("outer", VotingMode::FirstApplicable)
.with_sub_strategy(Box::new(inner));
// Inner is applicable and returns Call
assert_eq!(outer.get_action(&ctx), Action::Call);
}
#[test]
fn test_debug_format() {
let strategy = ModularStrategy::new("test_bot", VotingMode::Democratic)
.with_sub_strategy(Box::new(AlwaysFold))
.with_sub_strategy(Box::new(AlwaysCall));
let debug = format!("{:?}", strategy);
assert!(debug.contains("test_bot"));
assert!(debug.contains("Democratic"));
assert!(debug.contains("2"));
}
}