diff --git a/holdem_core/src/game/bot_factory.rs b/holdem_core/src/game/bot_factory.rs index ddfa055..5e6e88c 100644 --- a/holdem_core/src/game/bot_factory.rs +++ b/holdem_core/src/game/bot_factory.rs @@ -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)) //! }); //! ``` diff --git a/holdem_core/src/game/bots/mod.rs b/holdem_core/src/game/bots/mod.rs index d1b40d2..049fc73 100644 --- a/holdem_core/src/game/bots/mod.rs +++ b/holdem_core/src/game/bots/mod.rs @@ -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; diff --git a/holdem_core/src/game/bots/strategy_bot.rs b/holdem_core/src/game/bots/strategy_bot.rs deleted file mode 100644 index 3933e4d..0000000 --- a/holdem_core/src/game/bots/strategy_bot.rs +++ /dev/null @@ -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`) 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, - hole_cards: Option, - 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) -> 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); - } -} diff --git a/holdem_core/src/game/mod.rs b/holdem_core/src/game/mod.rs index 607306a..1657819 100644 --- a/holdem_core/src/game/mod.rs +++ b/holdem_core/src/game/mod.rs @@ -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; diff --git a/holdem_core/src/game/strategy/action_recorder.rs b/holdem_core/src/game/strategy/action_recorder.rs deleted file mode 100644 index e27f5c0..0000000 --- a/holdem_core/src/game/strategy/action_recorder.rs +++ /dev/null @@ -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, - 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 deleted file mode 100644 index b5b54e7..0000000 --- a/holdem_core/src/game/strategy/context.rs +++ /dev/null @@ -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, - /// 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 deleted file mode 100644 index 568071c..0000000 --- a/holdem_core/src/game/strategy/hand_analyzer.rs +++ /dev/null @@ -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 = 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 deleted file mode 100644 index e9c4cdd..0000000 --- a/holdem_core/src/game/strategy/hand_helper.rs +++ /dev/null @@ -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 = 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 deleted file mode 100644 index c4ef904..0000000 --- a/holdem_core/src/game/strategy/mod.rs +++ /dev/null @@ -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}; diff --git a/holdem_core/src/game/strategy/modular.rs b/holdem_core/src/game/strategy/modular.rs deleted file mode 100644 index 86e1ebb..0000000 --- a/holdem_core/src/game/strategy/modular.rs +++ /dev/null @@ -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>, - /// 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")); - } -}