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:
Jan-Peter von Hunnius 2026-05-22 16:50:11 +02:00
parent 7c79ae4966
commit f81f0579d1
27 changed files with 4731 additions and 27 deletions

9
holdem_bots/Cargo.toml Normal file
View 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"

View 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);
}
}

View 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);
}
}

View 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;

View 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");
}
}

View 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);
}
}

View 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
View 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;