feat: port Java Supersonic bot framework and cash game bots to Rust
Framework (holdem_core::game::strategy): - HandAnalyzer: 15 hand analysis methods (overpair, flush draw, OESD, outs, etc.) - HandHelper: 14 board texture and hand strength methods (Kill Phil grouping, etc.) - ActionRecorder: per-street action tracking with player filtering - StrategyContext: read-only game state snapshot with position/pot odds helpers - Strategy trait + ModularStrategy: composite pattern with 3 voting modes (FirstApplicable, Democratic, Random) Cash Game Bots (holdem_bots crate — private, exclude from public mirrors): - PostFlopStrategy: shared postflop logic using hand strength approximation - BigStackBot: NL cash strategy for stack > 25 BB (preflop + postflop) - ShortStackBot: NL cash strategy for stack ≤ 25 BB (preflop + postflop) - Full preflop decision trees ported from Java Supersonic framework Repository restructuring: - Created holdem_bots crate with feature-gated dependency from holdem_sim - Added game::strategy module to holdem_core - Cleaned up 4 unused evaluator stubs - Moved plans/ to docs/, added proptest-regressions/ to .gitignore - Added ARCHITECTURE_BOT_FRAMEWORK.md documentation Tests: 58 new strategy tests + 17 bot tests (all passing)
This commit is contained in:
parent
7c79ae4966
commit
f81f0579d1
27 changed files with 4731 additions and 27 deletions
9
holdem_bots/Cargo.toml
Normal file
9
holdem_bots/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "holdem_bots"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Advanced poker bot strategies for rusty-marvin (private — not for public mirrors)"
|
||||
|
||||
[dependencies]
|
||||
holdem_core = { path = "../holdem_core" }
|
||||
log = "0.4"
|
||||
67
holdem_bots/src/cash/big_stack.rs
Normal file
67
holdem_bots/src/cash/big_stack.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! Big stack cash game strategy (stack > 25 BB).
|
||||
//!
|
||||
//! Port of Java `BigStackStrategy` which combines a preflop strategy
|
||||
//! with the shared postflop strategy using a `ModularStrategy` composite.
|
||||
|
||||
use holdem_core::game::action::Action;
|
||||
use holdem_core::game::strategy::{ModularStrategy, Strategy, StrategyContext, VotingMode};
|
||||
|
||||
use super::postflop::PostFlopStrategy;
|
||||
use super::BigStackPreFlop;
|
||||
|
||||
/// Big stack cash game strategy (stack > 25 BB).
|
||||
///
|
||||
/// Uses a [`ModularStrategy`] with [`VotingMode::FirstApplicable`] to combine:
|
||||
/// - [`BigStackPreFlop`] — handles all preflop decisions
|
||||
/// - [`PostFlopStrategy`] — handles all postflop decisions
|
||||
///
|
||||
/// # Applicability
|
||||
///
|
||||
/// Only applicable when:
|
||||
/// - The game is No-Limit
|
||||
/// - Our stack is greater than 25 big blinds
|
||||
pub struct BigStackBot {
|
||||
inner: ModularStrategy,
|
||||
}
|
||||
|
||||
impl BigStackBot {
|
||||
pub fn new() -> Self {
|
||||
let inner = ModularStrategy::new("BigStack", VotingMode::FirstApplicable)
|
||||
.with_sub_strategy(Box::new(BigStackPreFlop))
|
||||
.with_sub_strategy(Box::new(PostFlopStrategy));
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BigStackBot {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for BigStackBot {
|
||||
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||
// Applicable for NL games with stack > 25 BB
|
||||
ctx.stack_in_bb() > 25.0
|
||||
}
|
||||
|
||||
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||
self.inner.get_action(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_big_stack_bot_default() {
|
||||
let bot = BigStackBot::default();
|
||||
assert_eq!(bot.inner.name(), "BigStack");
|
||||
assert_eq!(bot.inner.len(), 2);
|
||||
}
|
||||
}
|
||||
364
holdem_bots/src/cash/big_stack_preflop.rs
Normal file
364
holdem_bots/src/cash/big_stack_preflop.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
//! Big stack preflop strategy for NL cash games.
|
||||
//!
|
||||
//! Port of Java `BigStackPreFlopStrategy`. Implements a tight-aggressive
|
||||
//! preflop strategy suitable for deep-stacked play (> 25 BB).
|
||||
|
||||
use holdem_core::game::action::Action;
|
||||
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||
use holdem_core::hole_cards::HoleCards;
|
||||
|
||||
// Rank constants (0=Two .. 12=Ace) matching our card representation
|
||||
const ACE: u8 = 12;
|
||||
const KING: u8 = 11;
|
||||
const QUEEN: u8 = 10;
|
||||
const TEN: u8 = 8;
|
||||
const FIVE: u8 = 3;
|
||||
|
||||
/// Big stack preflop strategy.
|
||||
///
|
||||
/// Decision tree ported from Java `BigStackPreFlopStrategy`:
|
||||
///
|
||||
/// 1. **Got re-raised (i_am_aggressor):** AA/KK → all-in, pairs with deep stacks → call, else fold
|
||||
/// 2. **No raise in front (num_raises ≤ 1):**
|
||||
/// - AA-TT → Raise
|
||||
/// - AK → Raise
|
||||
/// - Pairs in late position → Call/Raise based on position
|
||||
/// - AT+ first-in from late → Raise
|
||||
/// - Suited aces/connectors from late → Raise/Call
|
||||
/// 3. **One raise in front (num_raises == 2):** AA-QQ → Raise, pairs deep → Call
|
||||
/// 4. **Multiple raises (num_raises > 2):** AA/KK → All-in
|
||||
///
|
||||
/// Raise sizing: `(4 + num_callers) × BB - amount_to_call`
|
||||
pub struct BigStackPreFlop;
|
||||
|
||||
impl Strategy for BigStackPreFlop {
|
||||
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||
// Only applicable preflop when we have hole cards
|
||||
ctx.hole_cards.is_some() && ctx.game_info.is_preflop()
|
||||
}
|
||||
|
||||
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||
let hole = match ctx.hole_cards {
|
||||
Some(h) => h,
|
||||
None => return Action::Fold,
|
||||
};
|
||||
|
||||
let c0 = hole.cards[0];
|
||||
let c1 = hole.cards[1];
|
||||
let r0 = c0.rank(); // higher rank (HoleCards stores desc)
|
||||
let r1 = c1.rank(); // lower rank
|
||||
let suited = c0.suit() == c1.suit();
|
||||
let pair = r0 == r1;
|
||||
|
||||
let to_call = ctx.amount_to_call();
|
||||
let bb = ctx.big_blind();
|
||||
let our_stack = ctx.our_stack();
|
||||
let num_raises = ctx.num_raises;
|
||||
let num_callers = ctx.num_callers;
|
||||
let num_players = ctx.game_info.get_num_seats();
|
||||
let our_position = ctx.our_position;
|
||||
|
||||
// In Java: BIG_BLIND = 1, position 0 = SB, 1 = BB
|
||||
// our_position: 0 = SB, 1 = BB, ..., N-1 = Button
|
||||
let is_blind = our_position <= 1;
|
||||
let is_mid_or_later = is_blind || our_position > (num_players as u32 - 5);
|
||||
let is_late_or_blind = is_blind || our_position >= (num_players as u32 - 2);
|
||||
|
||||
// --- Got re-raised (we were the aggressor, now facing a raise) ---
|
||||
if ctx.i_am_aggressor {
|
||||
if pair {
|
||||
// All-in with AA-KK
|
||||
if r0 >= KING {
|
||||
return Action::AllIn;
|
||||
}
|
||||
// Call min-raises, and call with pairs if both us and the raiser
|
||||
// have 20 BB left
|
||||
let raiser_stack = get_raiser_stack(ctx);
|
||||
if (our_stack as f64 > 20.0 * bb as f64
|
||||
&& raiser_stack as f64 > 20.0 * bb as f64)
|
||||
|| to_call <= bb
|
||||
{
|
||||
return Action::Call;
|
||||
}
|
||||
}
|
||||
// Non-pair: fold
|
||||
return Action::check_or_fold(to_call);
|
||||
}
|
||||
|
||||
// --- Our first action in this hand ---
|
||||
let mut raise = false;
|
||||
let mut all_in = false;
|
||||
let mut call = false;
|
||||
|
||||
// Raise with AA-KK, go all-in with two or more raises in front
|
||||
if pair && r0 >= KING {
|
||||
raise = true;
|
||||
if num_raises > 2 {
|
||||
all_in = true;
|
||||
}
|
||||
}
|
||||
|
||||
// One raise so far (num_raises == 2; BB counts as raise in Java)
|
||||
if num_raises == 2 {
|
||||
if pair {
|
||||
// Raise with AA-QQ
|
||||
if r0 > QUEEN {
|
||||
raise = true;
|
||||
} else if get_raiser_stack(ctx) as f64 >= 20.0 * bb as f64
|
||||
&& our_stack as f64 >= 20.0 * bb as f64
|
||||
{
|
||||
// Call with pairs if both deep enough
|
||||
call = true;
|
||||
}
|
||||
}
|
||||
// AK (both king+)
|
||||
if r0 >= KING && r1 >= KING {
|
||||
raise = true;
|
||||
}
|
||||
}
|
||||
|
||||
// No raise in front (num_raises <= 1; BB counts as raise in Java)
|
||||
if num_raises <= 1 {
|
||||
// Raise with AA-TT
|
||||
if pair && r0 >= TEN {
|
||||
raise = true;
|
||||
}
|
||||
|
||||
// Raise with AK
|
||||
if r0 >= KING && r1 >= KING {
|
||||
raise = true;
|
||||
}
|
||||
|
||||
// Call with pairs in mid position and later, raise on no callers in late
|
||||
if pair && is_mid_or_later {
|
||||
call = true;
|
||||
if num_callers == 0 && is_late_or_blind {
|
||||
raise = true;
|
||||
}
|
||||
}
|
||||
|
||||
// AT+ (both cards broadway with ace)
|
||||
if r0 >= ACE && r1 >= TEN {
|
||||
// Raise first in from mid or later
|
||||
if num_callers == 0 && is_mid_or_later {
|
||||
raise = true;
|
||||
}
|
||||
// Raise with callers in late position
|
||||
if num_callers >= 1 && is_late_or_blind {
|
||||
raise = true;
|
||||
}
|
||||
// Call in the blinds with callers
|
||||
if num_callers >= 1 && is_blind {
|
||||
call = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Suited aces (Ax suited)
|
||||
if r0 >= ACE && suited {
|
||||
// Raise first in from late or blinds
|
||||
if num_callers == 0 && is_late_or_blind {
|
||||
raise = true;
|
||||
}
|
||||
// Call with callers from late or blinds
|
||||
if num_callers >= 1 && is_late_or_blind {
|
||||
call = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadway cards (both >= Ten)
|
||||
if r0 >= TEN && r1 >= TEN {
|
||||
// Raise first in from late or blinds
|
||||
if num_callers == 0 && is_late_or_blind {
|
||||
raise = true;
|
||||
}
|
||||
|
||||
if suited {
|
||||
// Call with callers from mid or later with suited broadway
|
||||
if num_callers >= 1 && is_mid_or_later {
|
||||
call = true;
|
||||
}
|
||||
} else {
|
||||
// Call with callers in blinds with offsuit broadway
|
||||
if num_callers >= 1 && is_blind {
|
||||
call = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suited connectors (high card >= Five, gap of 1, suited)
|
||||
if r0 >= FIVE && r0 == r1 + 1 && suited {
|
||||
// Raise with suited connectors in late position on no callers
|
||||
if num_callers == 0 && is_late_or_blind {
|
||||
raise = true;
|
||||
}
|
||||
// Call with suited connectors from mid or later on callers
|
||||
if num_callers >= 1 && is_mid_or_later {
|
||||
call = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute decision
|
||||
if raise || all_in {
|
||||
if all_in {
|
||||
return Action::AllIn;
|
||||
}
|
||||
let raise_amount =
|
||||
((4 + num_callers) as u64 * bb as u64).saturating_sub(to_call);
|
||||
if raise_amount == 0 {
|
||||
return Action::Call;
|
||||
}
|
||||
return Action::Raise {
|
||||
total: to_call + raise_amount,
|
||||
};
|
||||
} else if call {
|
||||
return Action::Call;
|
||||
} else {
|
||||
return Action::check_or_fold(to_call);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the stack of the last raiser (aggressor).
|
||||
fn get_raiser_stack(ctx: &StrategyContext) -> u64 {
|
||||
ctx.aggressor_seat
|
||||
.and_then(|seat| ctx.game_info.player_at_seat(seat))
|
||||
.map(|p| p.stack)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hole card classification helpers (for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if hole cards are a pocket pair of the given rank or higher.
|
||||
#[allow(dead_code)]
|
||||
fn is_pair_at_least(hole: &HoleCards, min_rank: u8) -> bool {
|
||||
hole.cards[0].rank() == hole.cards[1].rank() && hole.cards[0].rank() >= min_rank
|
||||
}
|
||||
|
||||
/// Check if hole cards are AK (suited or offsuit).
|
||||
#[allow(dead_code)]
|
||||
fn is_ako(hole: &HoleCards) -> bool {
|
||||
let r0 = hole.cards[0].rank();
|
||||
let r1 = hole.cards[1].rank();
|
||||
r0 == ACE && r1 == KING
|
||||
}
|
||||
|
||||
/// Check if hole cards are AKs (suited).
|
||||
#[allow(dead_code)]
|
||||
fn is_aks(hole: &HoleCards) -> bool {
|
||||
is_ako(hole) && hole.cards[0].suit() == hole.cards[1].suit()
|
||||
}
|
||||
|
||||
/// Check if both cards are >= given rank.
|
||||
#[allow(dead_code)]
|
||||
fn is_broadway(hole: &HoleCards, min_rank: u8) -> bool {
|
||||
hole.cards[0].rank() >= min_rank && hole.cards[1].rank() >= min_rank
|
||||
}
|
||||
|
||||
/// Check if hole cards are suited.
|
||||
#[allow(dead_code)]
|
||||
fn is_suited(hole: &HoleCards) -> bool {
|
||||
hole.cards[0].suit() == hole.cards[1].suit()
|
||||
}
|
||||
|
||||
/// Get the higher and lower rank from hole cards.
|
||||
#[allow(dead_code)]
|
||||
fn rank_high_low(hole: &HoleCards) -> (u8, u8) {
|
||||
(hole.cards[0].rank(), hole.cards[1].rank())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use holdem_core::card::Card;
|
||||
use holdem_core::game::strategy::hand_helper::HandHelper;
|
||||
use holdem_core::hole_cards::HoleCards;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||
HoleCards::new(
|
||||
Card::from_str(c1).unwrap(),
|
||||
Card::from_str(c2).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// --- Classification helper tests ---
|
||||
|
||||
#[test]
|
||||
fn test_is_pair_at_least() {
|
||||
let aa = make_hole("As", "Ah");
|
||||
assert!(is_pair_at_least(&aa, ACE));
|
||||
assert!(is_pair_at_least(&aa, KING));
|
||||
|
||||
let kk = make_hole("Ks", "Kh");
|
||||
assert!(!is_pair_at_least(&kk, ACE));
|
||||
assert!(is_pair_at_least(&kk, KING));
|
||||
|
||||
let ak = make_hole("As", "Kh");
|
||||
assert!(!is_pair_at_least(&ak, ACE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_ako() {
|
||||
let ak = make_hole("As", "Kh");
|
||||
let ka = make_hole("Ks", "Ah");
|
||||
assert!(is_ako(&ak));
|
||||
assert!(is_ako(&ka));
|
||||
|
||||
let aq = make_hole("As", "Qh");
|
||||
assert!(!is_ako(&aq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_aks() {
|
||||
let aks = make_hole("As", "Ks");
|
||||
let ako = make_hole("As", "Kh");
|
||||
assert!(is_aks(&aks));
|
||||
assert!(!is_aks(&ako));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_broadway() {
|
||||
let ak = make_hole("As", "Kh");
|
||||
assert!(is_broadway(&ak, KING));
|
||||
assert!(!is_broadway(&ak, ACE));
|
||||
|
||||
let jt = make_hole("Js", "Th");
|
||||
assert!(is_broadway(&jt, TEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_suited() {
|
||||
let suited = make_hole("As", "Ks");
|
||||
let offsuit = make_hole("As", "Kh");
|
||||
assert!(is_suited(&suited));
|
||||
assert!(!is_suited(&offsuit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rank_high_low() {
|
||||
let ak = make_hole("Ks", "Ah");
|
||||
let (hi, lo) = rank_high_low(&ak);
|
||||
assert_eq!(hi, ACE);
|
||||
assert_eq!(lo, KING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kill_phil_group_aa() {
|
||||
let aa = make_hole("As", "Ah");
|
||||
assert_eq!(HandHelper::kill_phil_group(&aa), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kill_phil_group_72o() {
|
||||
let trash = make_hole("7s", "2h");
|
||||
assert_eq!(HandHelper::kill_phil_group(&trash), 0);
|
||||
}
|
||||
}
|
||||
21
holdem_bots/src/cash/mod.rs
Normal file
21
holdem_bots/src/cash/mod.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! Cash game (ring NL) bot strategies.
|
||||
//!
|
||||
//! Provides two bot strategies based on stack depth:
|
||||
//! - [`BigStackBot`] — for stacks > 25 BB (tight-aggressive, deep stack play)
|
||||
//! - [`ShortStackBot`] — for stacks ≤ 25 BB (push/fold oriented)
|
||||
//!
|
||||
//! Both bots use a shared [`PostFlopStrategy`] and separate preflop strategies
|
||||
//! tailored to their stack depth.
|
||||
|
||||
pub mod postflop;
|
||||
pub mod big_stack_preflop;
|
||||
pub mod short_stack_preflop;
|
||||
pub mod big_stack;
|
||||
pub mod short_stack;
|
||||
|
||||
// Re-export the main bot types for convenience
|
||||
pub use big_stack::BigStackBot;
|
||||
pub use short_stack::ShortStackBot;
|
||||
pub use big_stack_preflop::BigStackPreFlop;
|
||||
pub use short_stack_preflop::ShortStackPreFlop;
|
||||
pub use postflop::PostFlopStrategy;
|
||||
287
holdem_bots/src/cash/postflop.rs
Normal file
287
holdem_bots/src/cash/postflop.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! Shared postflop strategy for cash game bots.
|
||||
//!
|
||||
//! Simplified port of Java `PostFlopStrategyV3` that uses `HandAnalyzer`/`HandHelper`
|
||||
//! instead of `HandPotential` (which we don't have in Rust yet).
|
||||
//!
|
||||
//! # Decision Logic
|
||||
//!
|
||||
//! 1. Classify hand strength (trips+, two pair, top pair, overpair, draw)
|
||||
//! 2. Based on strength, position, and pot odds, decide fold/call/raise
|
||||
//! 3. Use outs-based calling when facing bets
|
||||
|
||||
use holdem_core::board::Board;
|
||||
use holdem_core::game::action::Action;
|
||||
use holdem_core::game::strategy::hand_analyzer::HandAnalyzer;
|
||||
use holdem_core::game::strategy::hand_helper::HandHelper;
|
||||
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||
use holdem_core::hole_cards::HoleCards;
|
||||
|
||||
/// Shared postflop strategy using hand strength analysis.
|
||||
///
|
||||
/// This is a simplified port of Java `PostFlopStrategyV3` that uses
|
||||
/// [`HandAnalyzer`]/[`HandHelper`] instead of `HandPotential`.
|
||||
///
|
||||
/// The original Java code used `HandPotential` to compute raw hand strength (hs),
|
||||
/// positive potential (ppot), and negative potential (npot), then derived
|
||||
/// `winProb = hs * (1 - npot) + (1 - hs) * ppot`. Since we don't have
|
||||
/// `HandPotential` yet, we approximate win probability using hand classification
|
||||
/// and outs counting.
|
||||
pub struct PostFlopStrategy;
|
||||
|
||||
/// Approximate win probability based on hand classification.
|
||||
///
|
||||
/// Returns a value in [0.0, 1.0] representing our estimated equity against
|
||||
/// a single opponent. This is a rough approximation that maps hand categories
|
||||
/// to equity estimates.
|
||||
fn approximate_win_prob(hole: &HoleCards, board: &Board) -> f64 {
|
||||
// Monster hands: very high equity
|
||||
if HandAnalyzer::is_trips_or_higher(hole, board) {
|
||||
return 0.90;
|
||||
}
|
||||
if HandAnalyzer::is_real_two_pair(hole, board) {
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
// Strong hands
|
||||
if HandHelper::is_overpair(hole, board) {
|
||||
return 0.70;
|
||||
}
|
||||
if HandHelper::is_top_pair(hole, board) {
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
// Drawing hands — estimate based on outs
|
||||
let outs = HandAnalyzer::num_outs_to_monster(hole, board);
|
||||
if outs >= 12 {
|
||||
return 0.50;
|
||||
}
|
||||
if outs >= 9 {
|
||||
return 0.40;
|
||||
}
|
||||
if outs >= 6 {
|
||||
return 0.30;
|
||||
}
|
||||
|
||||
// Weak made hands
|
||||
if HandAnalyzer::is_paired(hole, board) {
|
||||
return 0.25;
|
||||
}
|
||||
|
||||
// Overcards
|
||||
if HandAnalyzer::is_two_over_cards(hole, board) {
|
||||
return 0.20;
|
||||
}
|
||||
|
||||
// Nothing
|
||||
0.10
|
||||
}
|
||||
|
||||
/// Compute the "effective" win probability adjusted for number of opponents.
|
||||
///
|
||||
/// In the Java code: `max(winProb, hs) ^ (numActivePlayers - 1)`.
|
||||
/// This adjusts for the fact that equity drops with more opponents.
|
||||
fn effective_equity(win_prob: f64, num_opponents: usize) -> f64 {
|
||||
if num_opponents == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
win_prob.powi(num_opponents as i32)
|
||||
}
|
||||
|
||||
impl Strategy for PostFlopStrategy {
|
||||
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||
// Applicable when we have hole cards and we're postflop
|
||||
ctx.hole_cards.is_some() && ctx.game_info.is_postflop()
|
||||
}
|
||||
|
||||
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||
let hole = match ctx.hole_cards {
|
||||
Some(h) => h,
|
||||
None => return Action::Fold,
|
||||
};
|
||||
|
||||
let board = build_board_from_game_info(ctx);
|
||||
let num_opponents = ctx.num_active.saturating_sub(1);
|
||||
let win_prob = approximate_win_prob(&hole, &board);
|
||||
let equity = effective_equity(win_prob, num_opponents);
|
||||
|
||||
let to_call = ctx.amount_to_call();
|
||||
let pot = ctx.pot_size();
|
||||
let our_stack = ctx.our_stack();
|
||||
let min_raise = ctx.big_blind(); // Use BB as min raise in NL
|
||||
let is_flop = ctx.game_info.is_flop();
|
||||
|
||||
let mut raise_amount: f64 = 0.0;
|
||||
|
||||
// First in (nothing to call)
|
||||
if to_call == 0 {
|
||||
if ctx.i_am_preflop_aggressor && is_flop {
|
||||
// Continuation bet: bet if equity is decent
|
||||
if equity > 0.30 {
|
||||
raise_amount = (pot as f64 / 2.0).round();
|
||||
// Round to min_raise
|
||||
if min_raise > 0 {
|
||||
raise_amount = (raise_amount / min_raise as f64).floor()
|
||||
* min_raise as f64;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bet out with strong hands
|
||||
let threshold = if is_flop { 0.50 } else { 0.75 };
|
||||
if equity > threshold {
|
||||
raise_amount = (pot as f64 * 2.0 / 3.0).round();
|
||||
if min_raise > 0 {
|
||||
raise_amount = (raise_amount / min_raise as f64).floor()
|
||||
* min_raise as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Facing a bet
|
||||
if ctx.num_raises > 1 {
|
||||
// Re-raise with very strong hands
|
||||
if equity > 0.94 {
|
||||
raise_amount = pot as f64;
|
||||
}
|
||||
} else {
|
||||
// Raise with strong hands
|
||||
if equity > 0.89 {
|
||||
raise_amount = pot as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bet more than half my stack → all-in
|
||||
if raise_amount * 2.0 >= our_stack as f64 && raise_amount > 0.0 {
|
||||
return Action::AllIn;
|
||||
}
|
||||
|
||||
// Committed to pot: amount in pot + raise >= 2 * remaining stack
|
||||
let wagered = ctx
|
||||
.game_info
|
||||
.player_by_id(ctx.our_id)
|
||||
.map(|p| p.wagered_this_hand)
|
||||
.unwrap_or(0);
|
||||
if raise_amount > 0.0
|
||||
&& raise_amount + wagered as f64
|
||||
>= 2.0 * (our_stack as f64 - raise_amount)
|
||||
{
|
||||
return Action::AllIn;
|
||||
}
|
||||
|
||||
if raise_amount > 0.0 {
|
||||
// Been raised already (we were aggressor but someone re-raised) → all-in
|
||||
if ctx.i_am_aggressor && ctx.num_raises > 0 {
|
||||
return Action::AllIn;
|
||||
}
|
||||
let total = to_call + raise_amount as u64;
|
||||
return Action::Raise { total };
|
||||
}
|
||||
|
||||
// Call decision: equity / 4 > to_call / pot
|
||||
if to_call > 0 && equity / 4.0 > (to_call as f64).min(our_stack as f64) / pot as f64 {
|
||||
// Committed to pot when calling
|
||||
let wagered = ctx
|
||||
.game_info
|
||||
.player_by_id(ctx.our_id)
|
||||
.map(|p| p.wagered_this_hand)
|
||||
.unwrap_or(0);
|
||||
if to_call as f64 + wagered as f64
|
||||
>= 1.5 * (our_stack as f64 - to_call as f64)
|
||||
{
|
||||
return Action::AllIn;
|
||||
}
|
||||
return Action::Call;
|
||||
}
|
||||
|
||||
// Check or fold
|
||||
Action::check_or_fold(to_call)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Board` from the `GameInfo` board cards.
|
||||
fn build_board_from_game_info(ctx: &StrategyContext) -> Board {
|
||||
let mut board = Board::new();
|
||||
let cards = &ctx.game_info.board;
|
||||
if cards.len() >= 3 {
|
||||
let _ = board.deal_flop(cards[0..3].to_vec());
|
||||
}
|
||||
if cards.len() >= 4 {
|
||||
let _ = board.deal_turn(cards[3]);
|
||||
}
|
||||
if cards.len() >= 5 {
|
||||
let _ = board.deal_river(cards[4]);
|
||||
}
|
||||
board
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use holdem_core::card::Card;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||
HoleCards::new(
|
||||
Card::from_str(c1).unwrap(),
|
||||
Card::from_str(c2).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_board(cards: &[&str]) -> Board {
|
||||
let mut board = Board::new();
|
||||
let parsed: Vec<Card> = cards.iter().map(|s| Card::from_str(s).unwrap()).collect();
|
||||
if parsed.len() >= 3 {
|
||||
board.deal_flop(vec![parsed[0], parsed[1], parsed[2]]).unwrap();
|
||||
}
|
||||
if parsed.len() >= 4 {
|
||||
board.deal_turn(parsed[3]).unwrap();
|
||||
}
|
||||
if parsed.len() >= 5 {
|
||||
board.deal_river(parsed[4]).unwrap();
|
||||
}
|
||||
board
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approximate_win_prob_trips() {
|
||||
let hole = make_hole("As", "Ah");
|
||||
let board = make_board(&["Ad", "5c", "9h"]);
|
||||
let wp = approximate_win_prob(&hole, &board);
|
||||
assert!(wp >= 0.85, "Trips should have high win prob, got {}", wp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approximate_win_prob_two_pair() {
|
||||
let hole = make_hole("As", "Kh");
|
||||
let board = make_board(&["Ad", "Kc", "9h"]);
|
||||
let wp = approximate_win_prob(&hole, &board);
|
||||
assert!(wp >= 0.70, "Two pair should have good win prob, got {}", wp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approximate_win_prob_weak() {
|
||||
let hole = make_hole("2s", "7d");
|
||||
let board = make_board(&["Ah", "Kc", "Qh"]);
|
||||
let wp = approximate_win_prob(&hole, &board);
|
||||
assert!(wp < 0.20, "Weak hand should have low win prob, got {}", wp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_equity_multiple_opponents() {
|
||||
// Equity should drop with more opponents
|
||||
let e1 = effective_equity(0.70, 1);
|
||||
let e3 = effective_equity(0.70, 3);
|
||||
assert!(e3 < e1, "Equity should drop with more opponents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_equity_zero_opponents() {
|
||||
let e = effective_equity(0.5, 0);
|
||||
assert_eq!(e, 1.0, "With no opponents, equity should be 1.0");
|
||||
}
|
||||
}
|
||||
67
holdem_bots/src/cash/short_stack.rs
Normal file
67
holdem_bots/src/cash/short_stack.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! Short stack cash game strategy (stack ≤ 25 BB).
|
||||
//!
|
||||
//! Port of Java `ShortStackStrategy` which combines a preflop strategy
|
||||
//! with the shared postflop strategy using a `ModularStrategy` composite.
|
||||
|
||||
use holdem_core::game::action::Action;
|
||||
use holdem_core::game::strategy::{ModularStrategy, Strategy, StrategyContext, VotingMode};
|
||||
|
||||
use super::postflop::PostFlopStrategy;
|
||||
use super::ShortStackPreFlop;
|
||||
|
||||
/// Short stack cash game strategy (stack ≤ 25 BB).
|
||||
///
|
||||
/// Uses a [`ModularStrategy`] with [`VotingMode::FirstApplicable`] to combine:
|
||||
/// - [`ShortStackPreFlop`] — handles all preflop decisions
|
||||
/// - [`PostFlopStrategy`] — handles all postflop decisions
|
||||
///
|
||||
/// # Applicability
|
||||
///
|
||||
/// Only applicable when:
|
||||
/// - The game is No-Limit
|
||||
/// - Our stack is 25 big blinds or less
|
||||
pub struct ShortStackBot {
|
||||
inner: ModularStrategy,
|
||||
}
|
||||
|
||||
impl ShortStackBot {
|
||||
pub fn new() -> Self {
|
||||
let inner = ModularStrategy::new("ShortStack", VotingMode::FirstApplicable)
|
||||
.with_sub_strategy(Box::new(ShortStackPreFlop))
|
||||
.with_sub_strategy(Box::new(PostFlopStrategy));
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShortStackBot {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for ShortStackBot {
|
||||
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||
// Applicable for NL games with stack <= 25 BB
|
||||
ctx.stack_in_bb() <= 25.0
|
||||
}
|
||||
|
||||
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||
self.inner.get_action(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_short_stack_bot_default() {
|
||||
let bot = ShortStackBot::default();
|
||||
assert_eq!(bot.inner.name(), "ShortStack");
|
||||
assert_eq!(bot.inner.len(), 2);
|
||||
}
|
||||
}
|
||||
204
holdem_bots/src/cash/short_stack_preflop.rs
Normal file
204
holdem_bots/src/cash/short_stack_preflop.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//! Short stack preflop strategy for NL cash games.
|
||||
//!
|
||||
//! Port of Java `ShortStackPreFlopStrategy`. Implements a push/fold oriented
|
||||
//! preflop strategy suitable for short-stacked play (≤ 25 BB).
|
||||
|
||||
use holdem_core::game::action::Action;
|
||||
use holdem_core::game::strategy::{Strategy, StrategyContext};
|
||||
|
||||
// Rank constants (0=Two .. 12=Ace)
|
||||
const ACE: u8 = 12;
|
||||
const KING: u8 = 11;
|
||||
const QUEEN: u8 = 10;
|
||||
const JACK: u8 = 9;
|
||||
const TEN: u8 = 8;
|
||||
const NINE: u8 = 7;
|
||||
const EIGHT: u8 = 6;
|
||||
const SEVEN: u8 = 5;
|
||||
|
||||
/// Short stack preflop strategy.
|
||||
///
|
||||
/// Decision tree ported from Java `ShortStackPreFlopStrategy`:
|
||||
///
|
||||
/// 1. **Already raised and got re-raised (didRaise):**
|
||||
/// - If stack/raise_amount >= 4: all-in with AA/KK/QQ/JJ/AK, fold otherwise
|
||||
/// - If stack/raise_amount > 2.5: all-in with AA/KK/QQ/JJ/TT/AK/AQ, fold otherwise
|
||||
/// - Otherwise: all-in with everything (pot-committed)
|
||||
///
|
||||
/// 2. **First action:**
|
||||
/// - AA/KK → Always raise
|
||||
/// - QQ/JJ/AK → Raise if max one raise
|
||||
/// - TT/99/AQ → Raise if no raise from mid/late
|
||||
/// - 88/77/AJ/KQ → Raise from late/blinds
|
||||
///
|
||||
/// Raise sizing: `(4 + num_callers) × BB - amount_to_call`
|
||||
/// If raise × 2 > stack → all-in
|
||||
pub struct ShortStackPreFlop;
|
||||
|
||||
impl Strategy for ShortStackPreFlop {
|
||||
fn is_applicable(&self, ctx: &StrategyContext) -> bool {
|
||||
// Only applicable preflop when we have hole cards
|
||||
ctx.hole_cards.is_some() && ctx.game_info.is_preflop()
|
||||
}
|
||||
|
||||
fn get_action(&self, ctx: &StrategyContext) -> Action {
|
||||
let hole = match ctx.hole_cards {
|
||||
Some(h) => h,
|
||||
None => return Action::Fold,
|
||||
};
|
||||
|
||||
let r0 = hole.cards[0].rank(); // higher rank
|
||||
let r1 = hole.cards[1].rank(); // lower rank
|
||||
let pair = r0 == r1;
|
||||
|
||||
let to_call = ctx.amount_to_call();
|
||||
let bb = ctx.big_blind();
|
||||
let our_stack = ctx.our_stack();
|
||||
let num_raises = ctx.num_raises;
|
||||
let num_callers = ctx.num_callers;
|
||||
let num_players = ctx.game_info.get_num_seats();
|
||||
let our_position = ctx.our_position;
|
||||
|
||||
let is_blind = our_position <= 1;
|
||||
let is_mid_or_later = is_blind || our_position > (num_players as u32 - 5);
|
||||
let is_late_or_blind = is_blind || our_position >= (num_players as u32 - 2);
|
||||
|
||||
// --- Already raised and got re-raised ---
|
||||
// In Java: didRaise is set when we raised previously. In our framework,
|
||||
// i_am_aggressor means we were the last raiser. If num_raises > 0 and
|
||||
// i_am_aggressor, it means we raised and someone else re-raised.
|
||||
// But actually in the Java code, didRaise is set when the PLAYER raised,
|
||||
// and it persists. In our framework, we need to check if we raised and
|
||||
// now face a re-raise (someone raised after us).
|
||||
//
|
||||
// Simplified: if we're the aggressor and there's a bet to call, we got re-raised.
|
||||
if ctx.i_am_aggressor && to_call > 0 {
|
||||
// Estimate the raise amount we put in (use BB as approximation)
|
||||
// In Java: bankrollDivRaiseAmount = bankroll / didRaiseAmount
|
||||
// We approximate didRaiseAmount as the standard raise size
|
||||
let did_raise_amount = (4 + num_callers) as f64 * bb as f64;
|
||||
let ratio = our_stack as f64 / did_raise_amount;
|
||||
|
||||
if ratio >= 4.0 {
|
||||
// All-in with AA, KK, QQ, JJ, AK; fold otherwise
|
||||
if (pair && r0 >= JACK) || (r0 == ACE && r1 == KING) {
|
||||
return Action::AllIn;
|
||||
} else {
|
||||
return Action::check_or_fold(to_call);
|
||||
}
|
||||
} else if ratio > 2.5 {
|
||||
// All-in with AA, KK, QQ, JJ, TT, AK, AQ; fold otherwise
|
||||
if (pair && r0 >= TEN)
|
||||
|| (r0 == ACE && r1 == KING)
|
||||
|| (r0 == ACE && r1 == QUEEN)
|
||||
{
|
||||
return Action::AllIn;
|
||||
} else {
|
||||
return Action::check_or_fold(to_call);
|
||||
}
|
||||
} else {
|
||||
// Pot committed: all-in with everything we raised with
|
||||
return Action::AllIn;
|
||||
}
|
||||
}
|
||||
|
||||
// --- First action in this hand ---
|
||||
let mut raise = false;
|
||||
|
||||
// AA, KK: always raise
|
||||
if pair && r0 >= KING {
|
||||
raise = true;
|
||||
}
|
||||
|
||||
// Max one raise so far (num_raises <= 2 in Java convention where BB = 1 raise)
|
||||
if num_raises <= 2 {
|
||||
// QQ, AK, JJ
|
||||
if (pair && r0 == QUEEN)
|
||||
|| (r0 == ACE && r1 == KING)
|
||||
|| (pair && r0 == JACK)
|
||||
{
|
||||
raise = true;
|
||||
}
|
||||
|
||||
// No raise so far; mid position, late position, or blinds
|
||||
if num_raises <= 1 && is_mid_or_later {
|
||||
// TT, 99, AQ
|
||||
if (pair && r0 == TEN)
|
||||
|| (pair && r0 == NINE)
|
||||
|| (r0 == ACE && r1 == QUEEN)
|
||||
{
|
||||
raise = true;
|
||||
}
|
||||
|
||||
// No raise so far; late position or blinds
|
||||
if is_late_or_blind {
|
||||
// 88, 77, AJ, KQ
|
||||
if (pair && r0 == EIGHT)
|
||||
|| (pair && r0 == SEVEN)
|
||||
|| (r0 == ACE && r1 == JACK)
|
||||
|| (r0 == KING && r1 == QUEEN)
|
||||
{
|
||||
raise = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if raise {
|
||||
let raise_amount =
|
||||
((4 + num_callers) as u64 * bb as u64).saturating_sub(to_call);
|
||||
|
||||
// If raise would commit more than half our stack, just go all-in
|
||||
if raise_amount * 2 > our_stack {
|
||||
return Action::AllIn;
|
||||
}
|
||||
|
||||
if raise_amount == 0 {
|
||||
return Action::Call;
|
||||
}
|
||||
return Action::Raise {
|
||||
total: to_call + raise_amount,
|
||||
};
|
||||
} else {
|
||||
return Action::check_or_fold(to_call);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use holdem_core::card::Card;
|
||||
use holdem_core::hole_cards::HoleCards;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn make_hole(c1: &str, c2: &str) -> HoleCards {
|
||||
HoleCards::new(
|
||||
Card::from_str(c1).unwrap(),
|
||||
Card::from_str(c2).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_stack_preflop_classifies_aa() {
|
||||
let aa = make_hole("As", "Ah");
|
||||
let r0 = aa.cards[0].rank();
|
||||
let r1 = aa.cards[1].rank();
|
||||
assert!(r0 == r1 && r0 >= KING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_stack_preflop_classifies_72o() {
|
||||
let trash = make_hole("7s", "2h");
|
||||
let r0 = trash.cards[0].rank();
|
||||
let r1 = trash.cards[1].rank();
|
||||
let pair = r0 == r1;
|
||||
assert!(!pair);
|
||||
assert!(r0 < ACE || r1 < KING); // Not AK
|
||||
}
|
||||
}
|
||||
7
holdem_bots/src/lib.rs
Normal file
7
holdem_bots/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Advanced poker bot strategies.
|
||||
//!
|
||||
//! This crate contains sophisticated bot implementations ported from the Java
|
||||
//! Supersonic framework. It is **not** intended for public distribution —
|
||||
//! exclude from public git mirrors via `.gitignore` or separate repository.
|
||||
|
||||
pub mod cash;
|
||||
Loading…
Add table
Add a link
Reference in a new issue