use std::{
collections::HashMap,
sync::{Arc, RwLock, RwLockReadGuard},
};
use tracing::{instrument, trace};
use super::Historian;
use crate::arena::GameState;
use crate::arena::action::{
Action, AgentAction, AwardPayload, ForcedBetPayload, PlayedActionPayload,
};
use crate::arena::game_state::Round;
use crate::core::PlayerBitSet;
#[derive(Clone, Debug)]
pub struct StatsStorage {
num_players: usize,
pub actions_count: Vec<usize>,
pub vpip_count: Vec<usize>,
pub vpip_total: Vec<f32>,
pub raise_count: Vec<usize>,
pub hands_played: Vec<usize>, pub hands_vpip: Vec<usize>, pub hands_pfr: Vec<usize>,
pub preflop_raise_count: Vec<usize>, pub preflop_actions: Vec<usize>, pub three_bet_count: Vec<usize>, pub three_bet_opportunities: Vec<usize>, pub call_count: Vec<usize>, pub bet_count: Vec<usize>,
pub total_profit: Vec<f32>, pub total_invested: Vec<f32>, pub games_won: Vec<usize>, pub games_lost: Vec<usize>, pub games_breakeven: Vec<usize>,
pub position_games: Vec<HashMap<usize, usize>>, pub position_profit: Vec<HashMap<usize, f32>>,
pub preflop_wins: Vec<usize>, pub flop_wins: Vec<usize>, pub turn_wins: Vec<usize>, pub river_wins: Vec<usize>, pub preflop_completes: Vec<usize>, pub flop_completes: Vec<usize>, pub turn_completes: Vec<usize>, pub river_completes: Vec<usize>,
pub cbet_opportunities: Vec<usize>, pub cbet_count: Vec<usize>,
pub wtsd_opportunities: Vec<usize>, pub wtsd_count: Vec<usize>,
pub showdown_count: Vec<usize>, pub showdown_wins: Vec<usize>,
pub fold_count: Vec<usize>,
pub flop_bets: Vec<usize>,
pub flop_raises: Vec<usize>,
pub flop_calls: Vec<usize>,
pub turn_bets: Vec<usize>,
pub turn_raises: Vec<usize>,
pub turn_calls: Vec<usize>,
pub river_bets: Vec<usize>,
pub river_raises: Vec<usize>,
pub river_calls: Vec<usize>,
pub steal_opportunities: Vec<usize>, pub steal_count: Vec<usize>, }
impl StatsStorage {
pub fn new_with_num_players(num_players: usize) -> Self {
Self {
num_players,
actions_count: vec![0; num_players],
vpip_count: vec![0; num_players],
vpip_total: vec![0.0; num_players],
raise_count: vec![0; num_players],
hands_played: vec![0; num_players],
hands_vpip: vec![0; num_players],
hands_pfr: vec![0; num_players],
preflop_raise_count: vec![0; num_players],
preflop_actions: vec![0; num_players],
three_bet_count: vec![0; num_players],
three_bet_opportunities: vec![0; num_players],
call_count: vec![0; num_players],
bet_count: vec![0; num_players],
total_profit: vec![0.0; num_players],
total_invested: vec![0.0; num_players],
games_won: vec![0; num_players],
games_lost: vec![0; num_players],
games_breakeven: vec![0; num_players],
position_games: vec![HashMap::new(); num_players],
position_profit: vec![HashMap::new(); num_players],
preflop_wins: vec![0; num_players],
flop_wins: vec![0; num_players],
turn_wins: vec![0; num_players],
river_wins: vec![0; num_players],
preflop_completes: vec![0; num_players],
flop_completes: vec![0; num_players],
turn_completes: vec![0; num_players],
river_completes: vec![0; num_players],
cbet_opportunities: vec![0; num_players],
cbet_count: vec![0; num_players],
wtsd_opportunities: vec![0; num_players],
wtsd_count: vec![0; num_players],
showdown_count: vec![0; num_players],
showdown_wins: vec![0; num_players],
fold_count: vec![0; num_players],
flop_bets: vec![0; num_players],
flop_raises: vec![0; num_players],
flop_calls: vec![0; num_players],
turn_bets: vec![0; num_players],
turn_raises: vec![0; num_players],
turn_calls: vec![0; num_players],
river_bets: vec![0; num_players],
river_raises: vec![0; num_players],
river_calls: vec![0; num_players],
steal_opportunities: vec![0; num_players],
steal_count: vec![0; num_players],
}
}
pub fn num_players(&self) -> usize {
self.num_players
}
pub fn vpip_percent(&self, player_idx: usize) -> f32 {
if self.hands_played[player_idx] == 0 {
0.0
} else {
(self.hands_vpip[player_idx] as f32 / self.hands_played[player_idx] as f32) * 100.0
}
}
pub fn pfr_percent(&self, player_idx: usize) -> f32 {
if self.hands_played[player_idx] == 0 {
0.0
} else {
(self.hands_pfr[player_idx] as f32 / self.hands_played[player_idx] as f32) * 100.0
}
}
pub fn three_bet_percent(&self, player_idx: usize) -> f32 {
let opportunities = self.three_bet_opportunities[player_idx];
if opportunities == 0 {
0.0
} else {
(self.three_bet_count[player_idx] as f32 / opportunities as f32) * 100.0
}
}
pub fn aggression_factor(&self, player_idx: usize) -> f32 {
let calls = self.call_count[player_idx];
if calls == 0 {
let aggressive_actions = self.raise_count[player_idx] + self.bet_count[player_idx];
if aggressive_actions == 0 {
0.0
} else {
f32::INFINITY
}
} else {
let aggressive_actions =
(self.raise_count[player_idx] + self.bet_count[player_idx]) as f32;
aggressive_actions / calls as f32
}
}
pub fn profit_per_game(&self, player_idx: usize) -> f32 {
let total_games = self.games_won[player_idx]
+ self.games_lost[player_idx]
+ self.games_breakeven[player_idx];
if total_games == 0 {
0.0
} else {
self.total_profit[player_idx] / total_games as f32
}
}
pub fn roi_percent(&self, player_idx: usize) -> f32 {
let invested = self.total_invested[player_idx];
if invested <= 0.0 {
0.0
} else {
(self.total_profit[player_idx] / invested) * 100.0
}
}
pub fn win_rate(&self, player_idx: usize) -> f32 {
let total_games = self.games_won[player_idx]
+ self.games_lost[player_idx]
+ self.games_breakeven[player_idx];
if total_games == 0 {
0.0
} else {
(self.games_won[player_idx] as f32 / total_games as f32) * 100.0
}
}
pub fn position_stats(&self, player_idx: usize) -> &HashMap<usize, usize> {
&self.position_games[player_idx]
}
pub fn position_profit(&self, player_idx: usize) -> &HashMap<usize, f32> {
&self.position_profit[player_idx]
}
pub fn preflop_win_rate(&self, player_idx: usize) -> f32 {
let completes = self.preflop_completes[player_idx];
if completes == 0 {
0.0
} else {
(self.preflop_wins[player_idx] as f32 / completes as f32) * 100.0
}
}
pub fn flop_win_rate(&self, player_idx: usize) -> f32 {
let completes = self.flop_completes[player_idx];
if completes == 0 {
0.0
} else {
(self.flop_wins[player_idx] as f32 / completes as f32) * 100.0
}
}
pub fn turn_win_rate(&self, player_idx: usize) -> f32 {
let completes = self.turn_completes[player_idx];
if completes == 0 {
0.0
} else {
(self.turn_wins[player_idx] as f32 / completes as f32) * 100.0
}
}
pub fn river_win_rate(&self, player_idx: usize) -> f32 {
let completes = self.river_completes[player_idx];
if completes == 0 {
0.0
} else {
(self.river_wins[player_idx] as f32 / completes as f32) * 100.0
}
}
pub fn cbet_percent(&self, player_idx: usize) -> f32 {
if self.cbet_opportunities[player_idx] == 0 {
0.0
} else {
(self.cbet_count[player_idx] as f32 / self.cbet_opportunities[player_idx] as f32)
* 100.0
}
}
pub fn wtsd_percent(&self, player_idx: usize) -> f32 {
if self.wtsd_opportunities[player_idx] == 0 {
0.0
} else {
(self.wtsd_count[player_idx] as f32 / self.wtsd_opportunities[player_idx] as f32)
* 100.0
}
}
pub fn wsd_percent(&self, player_idx: usize) -> f32 {
if self.showdown_count[player_idx] == 0 {
0.0
} else {
(self.showdown_wins[player_idx] as f32 / self.showdown_count[player_idx] as f32) * 100.0
}
}
pub fn aggression_frequency(&self, player_idx: usize) -> f32 {
let aggressive_actions = self.bet_count[player_idx] + self.raise_count[player_idx];
let total_actions =
aggressive_actions + self.call_count[player_idx] + self.fold_count[player_idx];
if total_actions == 0 {
0.0
} else {
(aggressive_actions as f32 / total_actions as f32) * 100.0
}
}
pub fn flop_aggression_factor(&self, player_idx: usize) -> f32 {
let calls = self.flop_calls[player_idx];
if calls == 0 {
let aggressive = self.flop_bets[player_idx] + self.flop_raises[player_idx];
if aggressive == 0 { 0.0 } else { f32::INFINITY }
} else {
let aggressive = (self.flop_bets[player_idx] + self.flop_raises[player_idx]) as f32;
aggressive / calls as f32
}
}
pub fn turn_aggression_factor(&self, player_idx: usize) -> f32 {
let calls = self.turn_calls[player_idx];
if calls == 0 {
let aggressive = self.turn_bets[player_idx] + self.turn_raises[player_idx];
if aggressive == 0 { 0.0 } else { f32::INFINITY }
} else {
let aggressive = (self.turn_bets[player_idx] + self.turn_raises[player_idx]) as f32;
aggressive / calls as f32
}
}
pub fn river_aggression_factor(&self, player_idx: usize) -> f32 {
let calls = self.river_calls[player_idx];
if calls == 0 {
let aggressive = self.river_bets[player_idx] + self.river_raises[player_idx];
if aggressive == 0 { 0.0 } else { f32::INFINITY }
} else {
let aggressive = (self.river_bets[player_idx] + self.river_raises[player_idx]) as f32;
aggressive / calls as f32
}
}
pub fn steal_percent(&self, player_idx: usize) -> f32 {
if self.steal_opportunities[player_idx] == 0 {
0.0
} else {
(self.steal_count[player_idx] as f32 / self.steal_opportunities[player_idx] as f32)
* 100.0
}
}
pub fn merge(&mut self, other: &StatsStorage) {
assert_eq!(
self.num_players, other.num_players,
"Cannot merge stats with different number of players"
);
for i in 0..self.num_players {
self.actions_count[i] += other.actions_count[i];
self.vpip_count[i] += other.vpip_count[i];
self.vpip_total[i] += other.vpip_total[i];
self.raise_count[i] += other.raise_count[i];
self.hands_played[i] += other.hands_played[i];
self.hands_vpip[i] += other.hands_vpip[i];
self.hands_pfr[i] += other.hands_pfr[i];
self.preflop_raise_count[i] += other.preflop_raise_count[i];
self.preflop_actions[i] += other.preflop_actions[i];
self.three_bet_count[i] += other.three_bet_count[i];
self.three_bet_opportunities[i] += other.three_bet_opportunities[i];
self.call_count[i] += other.call_count[i];
self.bet_count[i] += other.bet_count[i];
self.total_profit[i] += other.total_profit[i];
self.total_invested[i] += other.total_invested[i];
self.games_won[i] += other.games_won[i];
self.games_lost[i] += other.games_lost[i];
self.games_breakeven[i] += other.games_breakeven[i];
for (position, count) in &other.position_games[i] {
*self.position_games[i].entry(*position).or_insert(0) += count;
}
for (position, profit) in &other.position_profit[i] {
*self.position_profit[i].entry(*position).or_insert(0.0) += profit;
}
self.preflop_wins[i] += other.preflop_wins[i];
self.flop_wins[i] += other.flop_wins[i];
self.turn_wins[i] += other.turn_wins[i];
self.river_wins[i] += other.river_wins[i];
self.preflop_completes[i] += other.preflop_completes[i];
self.flop_completes[i] += other.flop_completes[i];
self.turn_completes[i] += other.turn_completes[i];
self.river_completes[i] += other.river_completes[i];
self.cbet_opportunities[i] += other.cbet_opportunities[i];
self.cbet_count[i] += other.cbet_count[i];
self.wtsd_opportunities[i] += other.wtsd_opportunities[i];
self.wtsd_count[i] += other.wtsd_count[i];
self.showdown_count[i] += other.showdown_count[i];
self.showdown_wins[i] += other.showdown_wins[i];
self.fold_count[i] += other.fold_count[i];
self.flop_bets[i] += other.flop_bets[i];
self.flop_raises[i] += other.flop_raises[i];
self.flop_calls[i] += other.flop_calls[i];
self.turn_bets[i] += other.turn_bets[i];
self.turn_raises[i] += other.turn_raises[i];
self.turn_calls[i] += other.turn_calls[i];
self.river_bets[i] += other.river_bets[i];
self.river_raises[i] += other.river_raises[i];
self.river_calls[i] += other.river_calls[i];
self.steal_opportunities[i] += other.steal_opportunities[i];
self.steal_count[i] += other.steal_count[i];
}
}
}
impl Default for StatsStorage {
fn default() -> Self {
StatsStorage::new_with_num_players(9)
}
}
#[derive(Debug, Clone)]
pub struct SharedStatsStorage {
inner: Arc<RwLock<StatsStorage>>,
}
impl SharedStatsStorage {
pub fn new(num_players: usize) -> Self {
Self {
inner: Arc::new(RwLock::new(StatsStorage::new_with_num_players(num_players))),
}
}
pub fn historian(&self) -> StatsTrackingHistorian {
StatsTrackingHistorian::new_with_storage(self.clone())
}
pub fn read(&self) -> RwLockReadGuard<'_, StatsStorage> {
self.inner.read().expect("StatsStorage lock poisoned")
}
pub fn try_read(&self) -> Result<RwLockReadGuard<'_, StatsStorage>, super::HistorianError> {
self.inner
.read()
.map_err(|_| super::HistorianError::LockPoisoned {
lock: super::HistorianLock::StatsStorageRead,
})
}
pub fn snapshot(&self) -> StatsStorage {
self.read().clone()
}
pub fn merge_stats(&self, other: &StatsStorage) {
let mut storage = self.inner.write().expect("StatsStorage lock poisoned");
storage.merge(other);
}
pub fn inner(&self) -> &Arc<RwLock<StatsStorage>> {
&self.inner
}
}
#[derive(Debug, Clone, Default)]
struct PlayerHandStats {
actions_count: usize,
vpip_occurred: bool, vpip_count_legacy: usize,
vpip_total_legacy: f32,
raise_count: usize,
bet_count: usize,
call_count: usize,
fold_count: usize,
preflop_raise_count: usize, preflop_actions: usize,
three_bet_count: usize,
three_bet_opportunities: usize,
invested: f32,
flop_bets: usize,
flop_raises: usize,
flop_calls: usize,
turn_bets: usize,
turn_raises: usize,
turn_calls: usize,
river_bets: usize,
river_raises: usize,
river_calls: usize,
cbet_opportunity: bool,
cbet_taken: bool,
steal_opportunity: bool,
steal_taken: bool,
}
#[derive(Debug)]
struct HandAccumulator {
player_stats: Vec<PlayerHandStats>,
}
impl HandAccumulator {
fn new(num_players: usize) -> Self {
Self {
player_stats: vec![PlayerHandStats::default(); num_players],
}
}
fn reset(&mut self) {
for stats in &mut self.player_stats {
*stats = PlayerHandStats::default();
}
}
}
pub struct StatsTrackingHistorian {
storage: SharedStatsStorage,
dealer_idx: usize,
starting_stacks: Vec<f32>,
current_round: Round,
recorded_profit: Vec<f32>, accumulator: HandAccumulator,
preflop_aggressor: Option<usize>, preflop_raise_number: usize, saw_flop: Vec<bool>, }
impl StatsTrackingHistorian {
pub fn get_storage(&self) -> SharedStatsStorage {
self.storage.clone()
}
pub fn new_with_storage(storage: SharedStatsStorage) -> Self {
let num_players = storage.read().num_players();
Self {
storage,
dealer_idx: 0,
starting_stacks: vec![0.0; num_players],
current_round: Round::Starting,
recorded_profit: vec![0.0; num_players],
accumulator: HandAccumulator::new(num_players),
preflop_aggressor: None,
preflop_raise_number: 0,
saw_flop: vec![false; num_players],
}
}
fn is_steal_position(player_idx: usize, dealer_idx: usize, num_players: usize) -> bool {
if num_players < 3 {
return true;
}
let btn = dealer_idx;
let co = (dealer_idx + num_players - 1) % num_players;
let sb = (dealer_idx + 1) % num_players;
player_idx == btn || player_idx == co || player_idx == sb
}
fn is_folded_to(
player_idx: usize,
dealer_idx: usize,
num_players: usize,
player_active: &PlayerBitSet,
) -> bool {
let first_to_act = if num_players == 2 {
dealer_idx } else {
(dealer_idx + 3) % num_players };
if player_idx == first_to_act {
return true;
}
let mut pos = first_to_act;
while pos != player_idx {
if player_active.get(pos) {
return false;
}
pos = (pos + 1) % num_players;
}
true
}
fn record_forced_bet(
&mut self,
payload: &ForcedBetPayload,
) -> Result<(), super::HistorianError> {
self.accumulator.player_stats[payload.idx].invested += payload.bet;
Ok(())
}
fn record_played_action(
&mut self,
games_state: &GameState,
payload: PlayedActionPayload,
) -> Result<(), super::HistorianError> {
let num_players = games_state.num_players;
let player_stats = &mut self.accumulator.player_stats[payload.idx];
player_stats.actions_count += 1;
if payload.round == Round::Preflop {
player_stats.preflop_actions += 1;
}
let is_raise = payload.final_bet > payload.starting_bet;
let is_bet = payload.starting_bet == 0.0 && payload.final_bet > 0.0;
if payload.round == Round::Preflop && self.preflop_raise_number == 1 {
player_stats.three_bet_opportunities += 1;
}
match payload.action {
AgentAction::Bet(bet_amount) => {
let put_into_pot = bet_amount - payload.starting_player_bet;
if put_into_pot > 0.0 {
player_stats.invested += put_into_pot;
player_stats.vpip_count_legacy += 1;
player_stats.vpip_total_legacy += put_into_pot;
if payload.round == Round::Preflop && !player_stats.vpip_occurred {
player_stats.vpip_occurred = true;
}
player_stats.bet_count += 1;
match payload.round {
Round::Flop => player_stats.flop_bets += 1,
Round::Turn => player_stats.turn_bets += 1,
Round::River => player_stats.river_bets += 1,
_ => {}
}
}
if is_raise {
player_stats.raise_count += 1;
match payload.round {
Round::Flop => player_stats.flop_raises += 1,
Round::Turn => player_stats.turn_raises += 1,
Round::River => player_stats.river_raises += 1,
_ => {}
}
if payload.round == Round::Preflop {
if self.preflop_raise_number == 1 {
player_stats.three_bet_count += 1;
}
player_stats.preflop_raise_count += 1;
self.preflop_raise_number += 1;
self.preflop_aggressor = Some(payload.idx);
}
}
if payload.round == Round::Preflop
&& Self::is_steal_position(payload.idx, self.dealer_idx, num_players)
&& Self::is_folded_to(
payload.idx,
self.dealer_idx,
num_players,
&games_state.player_active,
)
{
player_stats.steal_opportunity = true;
if is_raise {
player_stats.steal_taken = true;
}
}
if payload.round == Round::Flop
&& payload.starting_bet == 0.0
&& self.preflop_aggressor == Some(payload.idx)
{
player_stats.cbet_opportunity = true;
if is_bet || is_raise {
player_stats.cbet_taken = true;
}
}
}
AgentAction::Call => {
player_stats.call_count += 1;
match payload.round {
Round::Flop => player_stats.flop_calls += 1,
Round::Turn => player_stats.turn_calls += 1,
Round::River => player_stats.river_calls += 1,
_ => {}
}
let put_into_pot = payload.final_player_bet - payload.starting_player_bet;
if put_into_pot > 0.0 {
player_stats.invested += put_into_pot;
player_stats.vpip_count_legacy += 1;
player_stats.vpip_total_legacy += put_into_pot;
if payload.round == Round::Preflop && !player_stats.vpip_occurred {
player_stats.vpip_occurred = true;
}
}
}
AgentAction::Fold => {
player_stats.fold_count += 1;
if payload.round == Round::Flop
&& payload.starting_bet == 0.0
&& self.preflop_aggressor == Some(payload.idx)
{
player_stats.cbet_opportunity = true;
}
}
AgentAction::AllIn => {
player_stats.bet_count += 1;
match payload.round {
Round::Flop => player_stats.flop_bets += 1,
Round::Turn => player_stats.turn_bets += 1,
Round::River => player_stats.river_bets += 1,
_ => {}
}
let put_into_pot = payload.final_player_bet - payload.starting_player_bet;
if put_into_pot > 0.0 {
player_stats.invested += put_into_pot;
player_stats.vpip_count_legacy += 1;
player_stats.vpip_total_legacy += put_into_pot;
if payload.round == Round::Preflop && !player_stats.vpip_occurred {
player_stats.vpip_occurred = true;
}
}
if is_raise {
player_stats.raise_count += 1;
match payload.round {
Round::Flop => player_stats.flop_raises += 1,
Round::Turn => player_stats.turn_raises += 1,
Round::River => player_stats.river_raises += 1,
_ => {}
}
if payload.round == Round::Preflop {
if self.preflop_raise_number == 1 {
player_stats.three_bet_count += 1;
}
player_stats.preflop_raise_count += 1;
self.preflop_raise_number += 1;
self.preflop_aggressor = Some(payload.idx);
}
}
if payload.round == Round::Flop
&& payload.starting_bet == 0.0
&& self.preflop_aggressor == Some(payload.idx)
{
player_stats.cbet_opportunity = true;
player_stats.cbet_taken = true; }
}
}
Ok(())
}
fn record_round_advance(
&mut self,
round: Round,
game_state: &GameState,
) -> Result<(), super::HistorianError> {
self.current_round = round;
let mut storage =
self.storage
.inner()
.write()
.map_err(|_| super::HistorianError::LockPoisoned {
lock: super::HistorianLock::StatsStorageWrite,
})?;
let num_players = storage.num_players();
match round {
Round::Preflop => {
for i in 0..num_players {
storage.preflop_completes[i] += 1;
}
}
Round::Flop => {
for i in 0..num_players {
storage.flop_completes[i] += 1;
if game_state.player_active.get(i) {
self.saw_flop[i] = true;
}
}
}
Round::Turn => {
for i in 0..num_players {
storage.turn_completes[i] += 1;
}
}
Round::River => {
for i in 0..num_players {
storage.river_completes[i] += 1;
}
}
_ => {}
}
Ok(())
}
fn flush_accumulated_stats(&mut self) -> Result<(), super::HistorianError> {
let mut storage =
self.storage
.inner()
.write()
.map_err(|_| super::HistorianError::LockPoisoned {
lock: super::HistorianLock::StatsStorageWrite,
})?;
for (player_idx, player_stats) in self.accumulator.player_stats.iter().enumerate() {
storage.actions_count[player_idx] += player_stats.actions_count;
storage.vpip_count[player_idx] += player_stats.vpip_count_legacy;
storage.vpip_total[player_idx] += player_stats.vpip_total_legacy;
storage.raise_count[player_idx] += player_stats.raise_count;
storage.bet_count[player_idx] += player_stats.bet_count;
storage.call_count[player_idx] += player_stats.call_count;
storage.fold_count[player_idx] += player_stats.fold_count;
storage.preflop_raise_count[player_idx] += player_stats.preflop_raise_count;
storage.preflop_actions[player_idx] += player_stats.preflop_actions;
storage.three_bet_count[player_idx] += player_stats.three_bet_count;
storage.three_bet_opportunities[player_idx] += player_stats.three_bet_opportunities;
storage.total_invested[player_idx] += player_stats.invested;
storage.flop_bets[player_idx] += player_stats.flop_bets;
storage.flop_raises[player_idx] += player_stats.flop_raises;
storage.flop_calls[player_idx] += player_stats.flop_calls;
storage.turn_bets[player_idx] += player_stats.turn_bets;
storage.turn_raises[player_idx] += player_stats.turn_raises;
storage.turn_calls[player_idx] += player_stats.turn_calls;
storage.river_bets[player_idx] += player_stats.river_bets;
storage.river_raises[player_idx] += player_stats.river_raises;
storage.river_calls[player_idx] += player_stats.river_calls;
if player_stats.cbet_opportunity {
storage.cbet_opportunities[player_idx] += 1;
if player_stats.cbet_taken {
storage.cbet_count[player_idx] += 1;
}
}
if player_stats.steal_opportunity {
storage.steal_opportunities[player_idx] += 1;
if player_stats.steal_taken {
storage.steal_count[player_idx] += 1;
}
}
if player_stats.vpip_occurred {
storage.hands_vpip[player_idx] += 1;
}
if player_stats.preflop_raise_count >= 1 {
storage.hands_pfr[player_idx] += 1;
}
}
Ok(())
}
fn record_game_complete(
&mut self,
game_state: &GameState,
) -> Result<(), super::HistorianError> {
self.flush_accumulated_stats()?;
let mut storage =
self.storage
.inner()
.write()
.map_err(|_| super::HistorianError::LockPoisoned {
lock: super::HistorianLock::StatsStorageWrite,
})?;
let in_hand =
|idx: usize| game_state.player_active.get(idx) || game_state.player_all_in.get(idx);
let players_in_hand = game_state.player_active.count() + game_state.player_all_in.count();
let went_to_showdown = game_state.round == Round::Complete && players_in_hand > 1;
for player_idx in 0..game_state.num_players {
let final_profit = game_state.player_reward(player_idx);
storage.hands_played[player_idx] += 1;
storage.total_profit[player_idx] += final_profit;
if self.saw_flop[player_idx] {
storage.wtsd_opportunities[player_idx] += 1;
if went_to_showdown && in_hand(player_idx) {
storage.wtsd_count[player_idx] += 1;
}
}
if went_to_showdown && in_hand(player_idx) {
storage.showdown_count[player_idx] += 1;
if final_profit > 0.01 {
storage.showdown_wins[player_idx] += 1;
}
}
if final_profit > 0.01 {
storage.games_won[player_idx] += 1;
match game_state.round_before {
Round::Preflop => storage.preflop_wins[player_idx] += 1,
Round::Flop => storage.flop_wins[player_idx] += 1,
Round::Turn => storage.turn_wins[player_idx] += 1,
Round::River => storage.river_wins[player_idx] += 1,
_ => {}
}
} else if final_profit < -0.01 {
storage.games_lost[player_idx] += 1;
} else {
storage.games_breakeven[player_idx] += 1;
}
*storage.position_games[player_idx]
.entry(player_idx)
.or_insert(0) += 1;
*storage.position_profit[player_idx]
.entry(player_idx)
.or_insert(0.0) += final_profit;
}
Ok(())
}
fn record_award_without_profit(
&mut self,
_game_state: &GameState,
_payload: &AwardPayload,
) -> Result<(), super::HistorianError> {
Ok(())
}
fn record_game_start(&mut self, game_state: &GameState) -> Result<(), super::HistorianError> {
self.starting_stacks = game_state.starting_stacks.to_vec();
self.dealer_idx = game_state.dealer_idx;
self.current_round = Round::Starting;
self.recorded_profit = vec![0.0; game_state.num_players];
self.accumulator.reset();
self.preflop_aggressor = None;
self.preflop_raise_number = 0;
self.saw_flop = vec![false; game_state.num_players];
Ok(())
}
pub fn new_with_num_players(num_players: usize) -> Self {
SharedStatsStorage::new(num_players).historian()
}
}
impl Default for StatsTrackingHistorian {
fn default() -> Self {
SharedStatsStorage::new(9).historian()
}
}
#[async_trait::async_trait]
impl Historian for StatsTrackingHistorian {
#[instrument(level = "trace", skip(self, game_state))]
async fn record_action(
&mut self,
_id: u128,
game_state: &GameState,
action: &Action,
) -> Result<(), super::HistorianError> {
trace!(?action, "StatsTrackingHistorian processing action");
match action {
Action::GameStart(_) => self.record_game_start(game_state),
Action::PlayedAction(payload) => self.record_played_action(game_state, payload.clone()),
Action::FailedAction(failed_action_payload) => {
self.record_played_action(game_state, failed_action_payload.result.clone())
}
Action::ForcedBet(payload) => self.record_forced_bet(payload),
Action::RoundAdvance(round) => {
if *round == Round::Complete {
self.record_game_complete(game_state)?;
}
self.record_round_advance(*round, game_state)
}
Action::Award(payload) => {
self.record_award_without_profit(game_state, payload)
}
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use crate::arena::{
Agent, HoldemSimulationBuilder,
agent::{AllInAgent, CallingAgent, FoldingAgent, VecReplayAgent},
};
use super::*;
use crate::arena::GameStateBuilder;
#[tokio::test]
async fn test_all_in_agents_had_actions_counted() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<AllInAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
assert!(storage.read().actions_count.iter().all(|&count| count == 1));
}
#[tokio::test]
async fn test_calling_agents_had_actions_counted() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
assert!(storage.read().actions_count.iter().all(|&count| count == 4));
}
#[tokio::test]
async fn test_folding_agents_had_actions_counted() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let actions_count = &storage.read().actions_count;
assert_eq!(actions_count.first(), Some(&1));
assert_eq!(actions_count.get(1), Some(&0));
}
#[tokio::test]
async fn test_replay_agents_had_raises_counted() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"replay-agent-0",
vec![AgentAction::Bet(10.0), AgentAction::Bet(40.0)],
AgentAction::Bet(0.0),
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"replay-agent-1",
vec![
AgentAction::Bet(10.0),
AgentAction::Bet(20.0),
AgentAction::Bet(40.0),
],
AgentAction::Bet(0.0),
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
assert_eq!(storage.read().raise_count, vec![1, 1]);
}
#[tokio::test]
async fn test_pfr_tracking() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![AgentAction::Call], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
assert_eq!(borrowed.preflop_raise_count[0], 1);
assert_eq!(borrowed.preflop_raise_count[1], 0);
assert!(borrowed.preflop_actions[0] > 0);
assert!(borrowed.preflop_actions[1] > 0);
let pfr_0 = borrowed.pfr_percent(0);
assert!(pfr_0 > 0.0);
}
#[tokio::test]
async fn test_call_tracking() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(3));
let storage = hist.get_storage();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller-1",
vec![AgentAction::Call], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller-2",
vec![AgentAction::Call], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
assert!(
borrowed.call_count[1] > 0 || borrowed.call_count[2] > 0,
"Expected at least one caller to have calls tracked, got: {:?}",
borrowed.call_count
);
}
#[tokio::test]
async fn test_vpip_calculation() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
let vpip_0 = borrowed.vpip_percent(0);
assert!(vpip_0 > 0.0);
let vpip_1 = borrowed.vpip_percent(1);
assert_eq!(vpip_1, 0.0); }
#[test]
fn test_merge_stats() {
let mut stats1 = StatsStorage::new_with_num_players(2);
stats1.actions_count[0] = 5;
stats1.vpip_count[0] = 3;
stats1.total_profit[0] = 100.0;
stats1.games_won[0] = 2;
let mut stats2 = StatsStorage::new_with_num_players(2);
stats2.actions_count[0] = 3;
stats2.vpip_count[0] = 2;
stats2.total_profit[0] = 50.0;
stats2.games_won[0] = 1;
stats1.merge(&stats2);
assert_eq!(stats1.actions_count[0], 8);
assert_eq!(stats1.vpip_count[0], 5);
assert_eq!(stats1.total_profit[0], 150.0);
assert_eq!(stats1.games_won[0], 3);
}
#[test]
fn test_merge_position_stats() {
let mut stats1 = StatsStorage::new_with_num_players(2);
stats1.position_games[0].insert(0, 5);
stats1.position_profit[0].insert(0, 100.0);
let mut stats2 = StatsStorage::new_with_num_players(2);
stats2.position_games[0].insert(0, 3);
stats2.position_profit[0].insert(0, 50.0);
stats1.merge(&stats2);
assert_eq!(stats1.position_games[0].get(&0), Some(&8));
assert_eq!(stats1.position_profit[0].get(&0), Some(&150.0));
}
#[test]
fn test_aggression_factor_no_calls() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.raise_count[0] = 5;
stats.bet_count[0] = 3;
stats.call_count[0] = 0;
let af = stats.aggression_factor(0);
assert_eq!(af, f32::INFINITY);
}
#[test]
fn test_aggression_factor_with_calls() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.raise_count[0] = 4;
stats.bet_count[0] = 2;
stats.call_count[0] = 2;
let af = stats.aggression_factor(0);
assert_eq!(af, 3.0); }
#[test]
fn test_profit_per_game() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_profit[0] = 300.0;
stats.games_won[0] = 5;
stats.games_lost[0] = 3;
stats.games_breakeven[0] = 2;
let ppg = stats.profit_per_game(0);
assert_eq!(ppg, 30.0); }
#[test]
fn test_win_rate() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.games_won[0] = 7;
stats.games_lost[0] = 2;
stats.games_breakeven[0] = 1;
let wr = stats.win_rate(0);
assert_eq!(wr, 70.0); }
#[test]
fn test_edge_case_empty_stats() {
let stats = StatsStorage::new_with_num_players(2);
assert_eq!(stats.vpip_percent(0), 0.0);
assert_eq!(stats.pfr_percent(0), 0.0);
assert_eq!(stats.three_bet_percent(0), 0.0);
assert_eq!(stats.aggression_factor(0), 0.0);
assert_eq!(stats.profit_per_game(0), 0.0);
assert_eq!(stats.win_rate(0), 0.0);
}
#[test]
fn test_round_win_rates() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.preflop_wins[0] = 2;
stats.preflop_completes[0] = 10;
stats.flop_wins[0] = 3;
stats.flop_completes[0] = 8;
stats.turn_wins[0] = 1;
stats.turn_completes[0] = 5;
stats.river_wins[0] = 2;
stats.river_completes[0] = 4;
assert_eq!(stats.preflop_win_rate(0), 20.0);
assert_eq!(stats.flop_win_rate(0), 37.5);
assert_eq!(stats.turn_win_rate(0), 20.0);
assert_eq!(stats.river_win_rate(0), 50.0);
}
#[tokio::test]
async fn test_zero_sum_property_simple() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
let total_profit = borrowed.total_profit[0] + borrowed.total_profit[1];
assert!(
total_profit.abs() < 0.01,
"Total profit should be zero (zero-sum), but got: {}. Player 0: {}, Player 1: {}",
total_profit,
borrowed.total_profit[0],
borrowed.total_profit[1]
);
let player0_profit = borrowed.total_profit[0];
let player1_profit = borrowed.total_profit[1];
let total_games_0 =
borrowed.games_won[0] + borrowed.games_lost[0] + borrowed.games_breakeven[0];
let total_games_1 =
borrowed.games_won[1] + borrowed.games_lost[1] + borrowed.games_breakeven[1];
assert!(
total_games_0 > 0 || total_games_1 > 0,
"At least one player should have game results"
);
println!(
"Player 0 profit: {}, games: {}",
player0_profit, total_games_0
);
println!(
"Player 1 profit: {}, games: {}",
player1_profit, total_games_1
);
println!("Total profit: {}", total_profit);
}
#[tokio::test]
async fn test_zero_sum_property_three_players() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(3));
let storage = hist.get_storage();
let stacks = vec![100.0, 100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
let total_profit =
borrowed.total_profit[0] + borrowed.total_profit[1] + borrowed.total_profit[2];
assert!(
total_profit.abs() < 0.01,
"Total profit should be zero (zero-sum), but got: {}. Player profits: [{}, {}, {}]",
total_profit,
borrowed.total_profit[0],
borrowed.total_profit[1],
borrowed.total_profit[2]
);
println!(
"Player profits: [{}, {}, {}], Total: {}",
borrowed.total_profit[0],
borrowed.total_profit[1],
borrowed.total_profit[2],
total_profit
);
}
#[tokio::test]
async fn test_profit_calculation_matches_game_state() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks.clone())
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let final_game_state = &sim.game_state;
let borrowed = storage.read();
for i in 0..2 {
let tracked_profit = borrowed.total_profit[i];
let actual_reward = final_game_state.player_reward(i);
assert!(
(tracked_profit - actual_reward).abs() < 0.01,
"Player {} tracked profit ({}) should match actual reward ({})",
i,
tracked_profit,
actual_reward
);
}
}
#[test]
fn test_vpip_percent_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.hands_played[0] = 100;
stats.hands_vpip[0] = 25;
let vpip = stats.vpip_percent(0);
assert!(
(vpip - 25.0).abs() < 0.001,
"Expected VPIP of 25.0%, got {}",
vpip
);
}
#[test]
fn test_vpip_percent_zero_hands() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.vpip_percent(0),
0.0,
"VPIP should be 0 when no hands played"
);
}
#[test]
fn test_pfr_percent_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.hands_played[0] = 50;
stats.hands_pfr[0] = 10;
let pfr = stats.pfr_percent(0);
assert!(
(pfr - 20.0).abs() < 0.001,
"Expected PFR of 20.0%, got {}",
pfr
);
}
#[test]
fn test_pfr_percent_zero_hands() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.pfr_percent(0),
0.0,
"PFR should be 0 when no hands played"
);
}
#[test]
fn test_three_bet_percent_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.three_bet_opportunities[0] = 20;
stats.three_bet_count[0] = 5;
let three_bet = stats.three_bet_percent(0);
assert!(
(three_bet - 25.0).abs() < 0.001,
"Expected 3-bet of 25.0%, got {}",
three_bet
);
}
#[test]
fn test_three_bet_percent_zero_opportunities() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.three_bet_percent(0),
0.0,
"3-bet should be 0 when no opportunities"
);
}
#[test]
fn test_steal_percent_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.steal_opportunities[0] = 10;
stats.steal_count[0] = 4;
let steal = stats.steal_percent(0);
assert!(
(steal - 40.0).abs() < 0.001,
"Expected ATS of 40.0%, got {}",
steal
);
}
#[test]
fn test_steal_percent_zero_opportunities() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.steal_percent(0),
0.0,
"ATS should be 0 when no opportunities"
);
}
#[test]
fn test_is_steal_position() {
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 6),
"Button should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(5, 0, 6),
"Cutoff should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 6),
"Small blind should be steal position"
);
assert!(
!StatsTrackingHistorian::is_steal_position(2, 0, 6),
"Big blind should NOT be steal position"
);
assert!(
!StatsTrackingHistorian::is_steal_position(3, 0, 6),
"UTG should NOT be steal position"
);
assert!(
!StatsTrackingHistorian::is_steal_position(4, 0, 6),
"MP should NOT be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 2),
"In heads-up, button is steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 2),
"In heads-up, other player is steal position"
);
}
#[test]
fn test_is_folded_to() {
use crate::core::PlayerBitSet;
let mut player_active = PlayerBitSet::new(6);
for i in 0..6 {
player_active.enable(i);
}
assert!(
StatsTrackingHistorian::is_folded_to(3, 0, 6, &player_active),
"UTG is always folded to (first to act)"
);
assert!(
!StatsTrackingHistorian::is_folded_to(5, 0, 6, &player_active),
"CO is NOT folded to when UTG/MP still active"
);
player_active.disable(3); player_active.disable(4);
assert!(
StatsTrackingHistorian::is_folded_to(5, 0, 6, &player_active),
"CO is folded to when UTG/MP have folded"
);
assert!(
!StatsTrackingHistorian::is_folded_to(0, 0, 6, &player_active),
"BTN is NOT folded to when CO still active"
);
player_active.disable(5);
assert!(
StatsTrackingHistorian::is_folded_to(0, 0, 6, &player_active),
"BTN is folded to when UTG/MP/CO have folded"
);
let mut hu_active = PlayerBitSet::new(2);
hu_active.enable(0);
hu_active.enable(1);
assert!(
StatsTrackingHistorian::is_folded_to(0, 0, 2, &hu_active),
"In heads-up, SB (dealer) is first to act, always folded to"
);
assert!(
!StatsTrackingHistorian::is_folded_to(1, 0, 2, &hu_active),
"In heads-up, BB is NOT folded to if SB still active"
);
hu_active.disable(0); assert!(
StatsTrackingHistorian::is_folded_to(1, 0, 2, &hu_active),
"In heads-up, BB is folded to when SB folds"
);
}
#[test]
fn test_aggression_factor_zero_aggressive_actions() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.aggression_factor(0),
0.0,
"AF should be 0 when no aggressive actions"
);
}
#[test]
fn test_profit_per_game_zero_games() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.profit_per_game(0),
0.0,
"Profit per game should be 0 when no games"
);
}
#[test]
fn test_profit_per_game_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_profit[0] = 250.0;
stats.games_won[0] = 3;
stats.games_lost[0] = 2;
let ppg = stats.profit_per_game(0);
assert!(
(ppg - 50.0).abs() < 0.001,
"Expected 50.0 profit per game, got {}",
ppg
);
}
#[test]
fn test_win_rate_zero_games() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(stats.win_rate(0), 0.0, "Win rate should be 0 when no games");
}
#[test]
fn test_win_rate_exact_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.games_won[0] = 3;
stats.games_lost[0] = 5;
stats.games_breakeven[0] = 2;
let wr = stats.win_rate(0);
assert!(
(wr - 30.0).abs() < 0.001,
"Expected 30.0% win rate, got {}",
wr
);
}
#[test]
fn test_preflop_win_rate_zero_completes() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.preflop_win_rate(0),
0.0,
"Preflop win rate should be 0 when no completes"
);
}
#[test]
fn test_flop_win_rate_zero_completes() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.flop_win_rate(0),
0.0,
"Flop win rate should be 0 when no completes"
);
}
#[test]
fn test_turn_win_rate_zero_completes() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.turn_win_rate(0),
0.0,
"Turn win rate should be 0 when no completes"
);
}
#[test]
fn test_river_win_rate_zero_completes() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(
stats.river_win_rate(0),
0.0,
"River win rate should be 0 when no completes"
);
}
#[test]
fn test_merge_hands_played_and_vpip() {
let mut stats1 = StatsStorage::new_with_num_players(2);
stats1.hands_played[0] = 10;
stats1.hands_vpip[0] = 5;
let mut stats2 = StatsStorage::new_with_num_players(2);
stats2.hands_played[0] = 15;
stats2.hands_vpip[0] = 8;
stats1.merge(&stats2);
assert_eq!(
stats1.hands_played[0], 25,
"Hands played should merge correctly"
);
assert_eq!(
stats1.hands_vpip[0], 13,
"Hands VPIP should merge correctly"
);
}
#[test]
fn test_merge_all_round_stats() {
let mut stats1 = StatsStorage::new_with_num_players(1);
stats1.preflop_wins[0] = 1;
stats1.flop_wins[0] = 2;
stats1.turn_wins[0] = 3;
stats1.river_wins[0] = 4;
stats1.preflop_completes[0] = 10;
stats1.flop_completes[0] = 20;
stats1.turn_completes[0] = 30;
stats1.river_completes[0] = 40;
let mut stats2 = StatsStorage::new_with_num_players(1);
stats2.preflop_wins[0] = 5;
stats2.flop_wins[0] = 6;
stats2.turn_wins[0] = 7;
stats2.river_wins[0] = 8;
stats2.preflop_completes[0] = 15;
stats2.flop_completes[0] = 25;
stats2.turn_completes[0] = 35;
stats2.river_completes[0] = 45;
stats1.merge(&stats2);
assert_eq!(stats1.preflop_wins[0], 6);
assert_eq!(stats1.flop_wins[0], 8);
assert_eq!(stats1.turn_wins[0], 10);
assert_eq!(stats1.river_wins[0], 12);
assert_eq!(stats1.preflop_completes[0], 25);
assert_eq!(stats1.flop_completes[0], 45);
assert_eq!(stats1.turn_completes[0], 65);
assert_eq!(stats1.river_completes[0], 85);
}
#[tokio::test]
async fn test_vpip_binary_per_hand() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser-caller",
vec![AgentAction::Bet(20.0), AgentAction::Call], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"three-better",
vec![AgentAction::Bet(40.0)], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.hands_played[0], 1,
"Player 0 should have 1 hand played"
);
assert_eq!(
stats.hands_played[1], 1,
"Player 1 should have 1 hand played"
);
assert_eq!(
stats.hands_vpip[0], 1,
"Player 0 should have 1 VPIP hand (not 2 despite multiple actions)"
);
assert_eq!(stats.hands_vpip[1], 1, "Player 1 should have 1 VPIP hand");
assert!(
(stats.vpip_percent(0) - 100.0).abs() < 0.001,
"Player 0 VPIP should be 100%"
);
assert!(
(stats.vpip_percent(1) - 100.0).abs() < 0.001,
"Player 1 VPIP should be 100%"
);
}
#[test]
fn test_shared_storage_snapshot() {
let storage = SharedStatsStorage::new(2);
{
let mut writer = storage.inner().write().unwrap();
writer.actions_count[0] = 42;
writer.total_profit[0] = 100.0;
}
let snapshot = storage.snapshot();
assert_eq!(snapshot.actions_count[0], 42);
assert_eq!(snapshot.total_profit[0], 100.0);
}
#[test]
fn test_shared_storage_merge_stats() {
let storage = SharedStatsStorage::new(2);
let mut other = StatsStorage::new_with_num_players(2);
other.actions_count[0] = 10;
other.total_profit[0] = 50.0;
other.hands_played[0] = 5;
other.hands_vpip[0] = 3;
storage.merge_stats(&other);
let stats = storage.read();
assert_eq!(stats.actions_count[0], 10);
assert_eq!(stats.total_profit[0], 50.0);
assert_eq!(stats.hands_played[0], 5);
assert_eq!(stats.hands_vpip[0], 3);
}
#[test]
fn test_num_players_accessor() {
let stats = StatsStorage::new_with_num_players(6);
assert_eq!(stats.num_players(), 6);
}
#[test]
fn test_roi_percent_basic() {
let mut stats = StatsStorage::new_with_num_players(2);
stats.total_invested[0] = 100.0;
stats.total_profit[0] = 50.0;
assert!((stats.roi_percent(0) - 50.0).abs() < 0.01);
}
#[test]
fn test_roi_percent_loss() {
let mut stats = StatsStorage::new_with_num_players(2);
stats.total_invested[0] = 100.0;
stats.total_profit[0] = -30.0;
assert!((stats.roi_percent(0) - (-30.0)).abs() < 0.01);
}
#[test]
fn test_roi_percent_zero_investment() {
let mut stats = StatsStorage::new_with_num_players(2);
stats.total_invested[0] = 0.0;
stats.total_profit[0] = -10.0;
assert_eq!(stats.roi_percent(0), 0.0);
}
#[tokio::test]
async fn test_roi_never_below_negative_100_percent_for_pure_folder() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
for idx in 0..2 {
let roi = stats.roi_percent(idx);
assert!(
roi >= -100.0 - 0.01,
"player {idx}: ROI must not be below -100%, got {roi} \
(profit={}, invested={})",
stats.total_profit[idx],
stats.total_invested[idx]
);
}
}
#[tokio::test]
async fn test_short_stack_forced_blind_not_overcounted() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 3.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.total_invested[1] <= 3.0 + 0.01,
"Short-stacked BB could only post 3, but total_invested = {}",
stats.total_invested[1]
);
}
#[tokio::test]
async fn test_investment_tracking_bet() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"bettor",
vec![AgentAction::Bet(30.0)], AgentAction::Fold,
)) as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
(stats.total_invested[0] - 30.0).abs() < 0.01,
"Bettor should have invested SB + voluntary = 30, got {}",
stats.total_invested[0]
);
assert!(
(stats.total_invested[1] - 10.0).abs() < 0.01,
"Folder should still have paid the big blind, got {}",
stats.total_invested[1]
);
}
#[tokio::test]
async fn test_investment_tracking_call() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)],
AgentAction::Fold,
)) as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.total_invested[0] > 0.0,
"Raiser should have invested money"
);
assert!(
stats.total_invested[1] > 0.0,
"Caller should have invested money"
);
}
#[tokio::test]
async fn test_folding_agent_zero_vpip_heads_up() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.hands_played[0], 1,
"Player 0 should have 1 hand played"
);
assert_eq!(
stats.hands_played[1], 1,
"Player 1 should have 1 hand played"
);
assert_eq!(
stats.hands_vpip[0], 0,
"Folding player 0 (SB) should have 0 VPIP hands - they just folded"
);
assert_eq!(
stats.hands_vpip[1], 0,
"Player 1 (BB) should have 0 VPIP hands - they won uncontested"
);
assert_eq!(
stats.vpip_percent(0),
0.0,
"Folding agent should have 0% VPIP"
);
assert_eq!(
stats.vpip_percent(1),
0.0,
"Big blind who won uncontested should have 0% VPIP"
);
}
#[tokio::test]
async fn test_folding_agent_vs_caller_zero_vpip() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.hands_vpip[0], 0,
"Folding agent should have 0 VPIP hands"
);
assert_eq!(
stats.vpip_percent(0),
0.0,
"Folding agent should have 0% VPIP"
);
assert_eq!(
stats.hands_vpip[1], 0,
"Calling agent who won uncontested should have 0 VPIP hands"
);
}
#[tokio::test]
async fn test_calling_agent_vs_raiser_has_vpip() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 1, "Raiser should have 1 VPIP hand");
assert_eq!(stats.vpip_percent(0), 100.0, "Raiser should have 100% VPIP");
assert_eq!(
stats.hands_vpip[1], 1,
"Caller who called a raise should have 1 VPIP hand"
);
assert_eq!(stats.vpip_percent(1), 100.0, "Caller should have 100% VPIP");
}
#[tokio::test]
async fn test_big_blind_check_no_vpip() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>, Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"checker",
vec![], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.hands_vpip[0], 1,
"Small blind who limped should have VPIP"
);
println!(
"BB hands_vpip: {}, hands_played: {}",
stats.hands_vpip[1], stats.hands_played[1]
);
}
#[tokio::test]
async fn test_folding_agent_three_way_zero_vpip() {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<CallingAgent>::default() as Box<dyn Agent>, ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 0, "UTG folder should have 0 VPIP");
assert_eq!(stats.vpip_percent(0), 0.0, "UTG folder should have 0% VPIP");
assert_eq!(stats.hands_vpip[1], 0, "SB folder should have 0 VPIP");
assert_eq!(stats.vpip_percent(1), 0.0, "SB folder should have 0% VPIP");
assert_eq!(
stats.hands_vpip[2], 0,
"BB who won uncontested should have 0 VPIP"
);
assert_eq!(
stats.vpip_percent(2),
0.0,
"BB who won uncontested should have 0% VPIP"
);
}
#[tokio::test]
async fn test_folding_agent_vs_all_in_vpip() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>, Box::<FoldingAgent>::default() as Box<dyn Agent>, ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 1, "All-in agent should have VPIP");
println!(
"Folding agent vs all-in - hands_vpip: {}, hands_played: {}",
stats.hands_vpip[1], stats.hands_played[1]
);
assert_eq!(
stats.hands_vpip[1], 0,
"Folding agent should fold against all-in, so 0 VPIP"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_folding_agent_three_player_various_opponents() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut total_hands = 0;
let mut total_vpip = 0;
for seed in 0..100 {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![1000.0; 3]; let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
let stats = storage.read();
total_hands += stats.hands_played[0];
total_vpip += stats.hands_vpip[0];
}
assert_eq!(
total_vpip, 0,
"Folding agent should have 0 VPIP across {} hands, but had {}",
total_hands, total_vpip
);
}
#[tokio::test]
async fn test_folding_agent_last_to_act_uncontested() {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<FoldingAgent>::default() as Box<dyn Agent>, ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 0, "UTG folder should have 0 VPIP");
assert_eq!(stats.hands_vpip[1], 0, "SB folder should have 0 VPIP");
println!(
"BB (last folder) - hands_vpip: {}, hands_played: {}, actions_count: {}",
stats.hands_vpip[2], stats.hands_played[2], stats.actions_count[2]
);
assert_eq!(
stats.hands_vpip[2], 0,
"BB who won uncontested should have 0 VPIP (even if they 'bet' to claim pot)"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_folding_agent_all_permutations_zero_vpip() {
use itertools::Itertools;
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut folder_total_hands = 0;
let mut folder_total_vpip = 0;
let agent_types = ["Folder", "AllIn", "Calling", "Random"];
for perm in (0..4).permutations(3) {
for seed in 0..10 {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = perm
.iter()
.map(|&idx| -> Box<dyn Agent> {
match agent_types[idx] {
"Folder" => Box::<FoldingAgent>::default(),
"AllIn" => Box::<AllInAgent>::default(),
"Calling" => Box::<CallingAgent>::default(),
"Random" => Box::new(VecReplayAgent::new_with_default(
"random",
vec![AgentAction::Call],
AgentAction::Fold,
)),
_ => unreachable!(),
}
})
.collect();
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed as u64))
.unwrap();
sim.run().await;
let stats = storage.read();
for (pos, &agent_idx) in perm.iter().enumerate() {
if agent_types[agent_idx] == "Folder" {
folder_total_hands += stats.hands_played[pos];
folder_total_vpip += stats.hands_vpip[pos];
if stats.hands_vpip[pos] > 0 {
println!(
"VPIP detected! Perm: {:?}, Seed: {}, Position: {}, VPIP: {}",
perm.iter().map(|&i| agent_types[i]).collect::<Vec<_>>(),
seed,
pos,
stats.hands_vpip[pos]
);
}
}
}
}
}
let vpip_percent = if folder_total_hands > 0 {
(folder_total_vpip as f32 / folder_total_hands as f32) * 100.0
} else {
0.0
};
println!(
"Folding agent total: {} hands, {} VPIP ({:.2}%)",
folder_total_hands, folder_total_vpip, vpip_percent
);
assert_eq!(
folder_total_vpip, 0,
"Folding agent should have 0 VPIP across all {} permutations and seeds, but had {} ({:.2}%)",
folder_total_hands, folder_total_vpip, vpip_percent
);
}
#[tokio::test]
async fn test_folding_agent_sb_when_utg_folds_to_bb() {
use crate::arena::historian::VecHistorian;
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let vec_hist = VecHistorian::default();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<CallingAgent>::default() as Box<dyn Agent>, ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist), Box::new(vec_hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
println!(
"UTG (Folder) - vpip: {}, played: {}, actions: {}",
stats.hands_vpip[0], stats.hands_played[0], stats.actions_count[0]
);
println!(
"SB (Folder) - vpip: {}, played: {}, actions: {}",
stats.hands_vpip[1], stats.hands_played[1], stats.actions_count[1]
);
println!(
"BB (Caller) - vpip: {}, played: {}, actions: {}",
stats.hands_vpip[2], stats.hands_played[2], stats.actions_count[2]
);
assert_eq!(stats.hands_vpip[0], 0, "UTG folder should have 0 VPIP");
assert_eq!(stats.hands_vpip[1], 0, "SB folder should have 0 VPIP");
}
#[tokio::test(flavor = "current_thread")]
async fn test_debug_folding_agent_with_random_opponents() {
use crate::arena::agent::RandomAgent;
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut total_vpip = 0;
let mut total_hands = 0;
for seed in 0..100 {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::new(RandomAgent::default()),
Box::new(RandomAgent::default()),
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
let stats = storage.read();
total_hands += stats.hands_played[0];
total_vpip += stats.hands_vpip[0];
if stats.hands_vpip[0] > 0 {
println!(
"VPIP at seed {}: vpip={}, played={}, actions={}",
seed, stats.hands_vpip[0], stats.hands_played[0], stats.actions_count[0]
);
}
}
println!(
"Total: {} VPIP out of {} hands ({:.2}%)",
total_vpip,
total_hands,
(total_vpip as f32 / total_hands as f32) * 100.0
);
assert_eq!(
total_vpip, 0,
"Folding agent should have 0 VPIP with random opponents"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_debug_folding_agent_agent_comparison_scenario() {
use crate::arena::agent::{RandomAgent, RandomPotControlAgent};
use itertools::Itertools;
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut folder_vpip_by_position: [usize; 3] = [0, 0, 0];
let mut folder_hands_by_position: [usize; 3] = [0, 0, 0];
let agent_types = [
"AllIn",
"Calling",
"Folding",
"RandomAgg",
"RandomDef",
"RandomPot",
];
for perm in (0..6).permutations(3) {
if !perm.contains(&2) {
continue;
}
for seed in 0..10 {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = perm
.iter()
.map(|&idx| -> Box<dyn Agent> {
match agent_types[idx] {
"AllIn" => Box::<AllInAgent>::default(),
"Calling" => Box::<CallingAgent>::default(),
"Folding" => Box::<FoldingAgent>::default(),
"RandomAgg" => Box::new(RandomAgent::new(
"RandomAgg",
vec![0.1, 0.15, 0.25],
vec![0.4, 0.5, 0.4],
)),
"RandomDef" => Box::new(RandomAgent::default()),
"RandomPot" => {
Box::new(RandomPotControlAgent::new("RandomPot", vec![0.5, 0.3]))
}
_ => unreachable!(),
}
})
.collect();
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed as u64))
.unwrap();
sim.run().await;
let stats = storage.read();
for (pos, &agent_idx) in perm.iter().enumerate() {
if agent_types[agent_idx] == "Folding" {
folder_hands_by_position[pos] += stats.hands_played[pos];
folder_vpip_by_position[pos] += stats.hands_vpip[pos];
if stats.hands_vpip[pos] > 0 {
println!(
"VPIP! Perm: {:?}, Seed: {}, Pos: {}, VPIP: {}, Actions: {}, legacy_vpip: {}, legacy_total: {}",
perm.iter().map(|&i| agent_types[i]).collect::<Vec<_>>(),
seed,
pos,
stats.hands_vpip[pos],
stats.actions_count[pos],
stats.vpip_count[pos],
stats.vpip_total[pos]
);
}
}
}
}
}
let total_hands: usize = folder_hands_by_position.iter().sum();
let total_vpip: usize = folder_vpip_by_position.iter().sum();
println!("\nFolding agent summary:");
for pos in 0..3 {
let pct = if folder_hands_by_position[pos] > 0 {
(folder_vpip_by_position[pos] as f32 / folder_hands_by_position[pos] as f32) * 100.0
} else {
0.0
};
println!(
" Position {}: {} VPIP / {} hands ({:.2}%)",
pos, folder_vpip_by_position[pos], folder_hands_by_position[pos], pct
);
}
println!(
" Total: {} VPIP / {} hands ({:.2}%)",
total_vpip,
total_hands,
(total_vpip as f32 / total_hands as f32) * 100.0
);
assert_eq!(
total_vpip, 0,
"Folding agent should have 0 VPIP in agent_comparison-like scenario"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_debug_specific_vpip_scenario() {
use crate::arena::action::Action;
use crate::arena::agent::RandomAgent;
use crate::arena::historian::VecHistorian;
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let vec_hist = VecHistorian::default();
let vec_storage = vec_hist.get_storage();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(RandomAgent::new(
"RandomAgg",
vec![0.1, 0.15, 0.25],
vec![0.4, 0.5, 0.4],
)),
Box::<CallingAgent>::default(),
Box::<FoldingAgent>::default(),
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist), Box::new(vec_hist)])
.build_with_rng(StdRng::seed_from_u64(1))
.unwrap();
sim.run().await;
let stats = storage.read();
println!("=== Specific scenario debug ===");
println!(
"Folding agent (pos 2): vpip={}, played={}, actions={}, legacy_vpip={}, legacy_total={}",
stats.hands_vpip[2],
stats.hands_played[2],
stats.actions_count[2],
stats.vpip_count[2],
stats.vpip_total[2]
);
println!("\n=== ALL Actions ===");
let actions = vec_storage.lock().unwrap();
for record in actions.iter() {
match &record.action {
Action::PlayedAction(payload) => {
let agent_name = match payload.idx {
0 => "RandomAgg",
1 => "Calling",
2 => "FOLDING",
_ => "???",
};
println!(
"[{}] Round: {:?}, Action: {:?}, current_bet={}, player_bet={}, put_in={}",
agent_name,
payload.round,
payload.action,
payload.final_bet,
payload.final_player_bet,
payload.final_player_bet - payload.starting_player_bet
);
}
Action::RoundAdvance(round) => {
println!("--- Round advance to {:?} ---", round);
}
_ => {}
}
}
assert_eq!(
stats.hands_vpip[2], 0,
"Folding agent at BB should have 0 VPIP"
);
}
#[tokio::test]
async fn test_folding_agent_when_utg_raises_and_sb_folds() {
use crate::arena::action::Action;
use crate::arena::historian::VecHistorian;
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let vec_hist = VecHistorian::default();
let vec_storage = vec_hist.get_storage();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(VecReplayAgent::new_with_default(
"Raiser",
vec![AgentAction::Bet(40.0)], AgentAction::Call,
)),
Box::new(VecReplayAgent::new_with_default(
"Folder",
vec![AgentAction::Fold], AgentAction::Fold,
)),
Box::<FoldingAgent>::default(), ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist), Box::new(vec_hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
println!("=== UTG raises, SB folds scenario ===");
println!(
"Folding agent (pos 2): vpip={}, played={}, actions={}",
stats.hands_vpip[2], stats.hands_played[2], stats.actions_count[2]
);
println!("\n=== ALL Actions ===");
let actions = vec_storage.lock().unwrap();
for record in actions.iter() {
match &record.action {
Action::PlayedAction(payload) => {
let agent_name = match payload.idx {
0 => "UTG-Raiser",
1 => "SB-Folder",
2 => "BB-FOLDING",
_ => "???",
};
println!(
"[{}] Round: {:?}, Action: {:?}, current_bet={}, player_bet={}, put_in={}",
agent_name,
payload.round,
payload.action,
payload.final_bet,
payload.final_player_bet,
payload.final_player_bet - payload.starting_player_bet
);
}
Action::RoundAdvance(round) => {
println!("--- Round advance to {:?} ---", round);
}
_ => {}
}
}
assert_eq!(
stats.hands_vpip[2], 0,
"Folding agent should have 0 VPIP when facing a raise"
);
}
#[tokio::test]
async fn test_folding_agent_when_utg_folds_and_sb_folds() {
use crate::arena::action::Action;
use crate::arena::historian::VecHistorian;
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let vec_hist = VecHistorian::default();
let vec_storage = vec_hist.get_storage();
let stacks = vec![1000.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(VecReplayAgent::new_with_default(
"Folder1",
vec![AgentAction::Fold], AgentAction::Fold,
)),
Box::new(VecReplayAgent::new_with_default(
"Folder2",
vec![AgentAction::Fold], AgentAction::Fold,
)),
Box::<FoldingAgent>::default(), ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist), Box::new(vec_hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
println!("=== UTG folds, SB folds scenario ===");
println!(
"Folding agent (pos 2): vpip={}, played={}, actions={}",
stats.hands_vpip[2], stats.hands_played[2], stats.actions_count[2]
);
println!("\n=== ALL Actions ===");
let actions = vec_storage.lock().unwrap();
for record in actions.iter() {
match &record.action {
Action::PlayedAction(payload) => {
let agent_name = match payload.idx {
0 => "UTG-Folder1",
1 => "SB-Folder2",
2 => "BB-FOLDING",
_ => "???",
};
println!(
"[{}] Round: {:?}, Action: {:?}, current_bet={}, player_bet={}, put_in={}",
agent_name,
payload.round,
payload.action,
payload.final_bet,
payload.final_player_bet,
payload.final_player_bet - payload.starting_player_bet
);
}
Action::RoundAdvance(round) => {
println!("--- Round advance to {:?} ---", round);
}
_ => {}
}
}
assert_eq!(
stats.hands_vpip[2], 0,
"Folding agent winning uncontested should have 0 VPIP"
);
}
#[test]
fn test_position_stats_accessor() {
let mut stats = StatsStorage::new_with_num_players(3);
stats.position_games[0].insert(0, 5);
stats.position_games[0].insert(1, 3);
stats.position_games[1].insert(2, 7);
let pos_stats = stats.position_stats(0);
assert_eq!(pos_stats.get(&0), Some(&5));
assert_eq!(pos_stats.get(&1), Some(&3));
assert_eq!(pos_stats.get(&2), None);
let pos_stats_1 = stats.position_stats(1);
assert_eq!(pos_stats_1.get(&2), Some(&7));
}
#[test]
fn test_position_profit_accessor() {
let mut stats = StatsStorage::new_with_num_players(2);
stats.position_profit[0].insert(0, 100.0);
stats.position_profit[0].insert(1, -50.0);
stats.position_profit[1].insert(0, -100.0);
stats.position_profit[1].insert(1, 50.0);
let profit_0 = stats.position_profit(0);
assert_eq!(profit_0.get(&0), Some(&100.0));
assert_eq!(profit_0.get(&1), Some(&-50.0));
let profit_1 = stats.position_profit(1);
assert_eq!(profit_1.get(&0), Some(&-100.0));
assert_eq!(profit_1.get(&1), Some(&50.0));
}
#[tokio::test]
async fn test_position_tracking_heads_up_single_game() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.position_games[0].get(&0),
Some(&1),
"Player 0 should have 1 game at position 0"
);
assert_eq!(
stats.position_games[1].get(&1),
Some(&1),
"Player 1 should have 1 game at position 1"
);
let profit_0 = stats.position_profit[0].get(&0).copied().unwrap_or(0.0);
let profit_1 = stats.position_profit[1].get(&1).copied().unwrap_or(0.0);
assert!(
(profit_0 + profit_1).abs() < 0.01,
"Position profits should sum to zero, got {} + {} = {}",
profit_0,
profit_1,
profit_0 + profit_1
);
}
#[tokio::test]
async fn test_position_tracking_three_player_single_game() {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
for player in 0..3 {
assert_eq!(
stats.position_games[player].get(&player),
Some(&1),
"Player {} should have 1 game at position {}",
player,
player
);
}
let total_position_profit: f32 = (0..3)
.map(|p| stats.position_profit[p].get(&p).copied().unwrap_or(0.0))
.sum();
assert!(
total_position_profit.abs() < 0.01,
"Total position profit should be zero, got {}",
total_position_profit
);
}
#[test]
fn test_merge_position_stats_multiple_positions() {
let mut stats1 = StatsStorage::new_with_num_players(3);
stats1.position_games[0].insert(0, 5);
stats1.position_games[0].insert(1, 3);
stats1.position_games[0].insert(2, 2);
stats1.position_profit[0].insert(0, 100.0);
stats1.position_profit[0].insert(1, -50.0);
stats1.position_profit[0].insert(2, 25.0);
let mut stats2 = StatsStorage::new_with_num_players(3);
stats2.position_games[0].insert(0, 3);
stats2.position_games[0].insert(1, 4);
stats2.position_games[0].insert(2, 1);
stats2.position_profit[0].insert(0, 50.0);
stats2.position_profit[0].insert(1, 30.0);
stats2.position_profit[0].insert(2, -10.0);
stats1.merge(&stats2);
assert_eq!(stats1.position_games[0].get(&0), Some(&8)); assert_eq!(stats1.position_games[0].get(&1), Some(&7)); assert_eq!(stats1.position_games[0].get(&2), Some(&3));
assert_eq!(stats1.position_profit[0].get(&0), Some(&150.0)); assert_eq!(stats1.position_profit[0].get(&1), Some(&-20.0)); assert_eq!(stats1.position_profit[0].get(&2), Some(&15.0)); }
#[test]
fn test_merge_position_stats_disjoint_positions() {
let mut stats1 = StatsStorage::new_with_num_players(3);
stats1.position_games[0].insert(0, 5);
stats1.position_profit[0].insert(0, 100.0);
let mut stats2 = StatsStorage::new_with_num_players(3);
stats2.position_games[0].insert(1, 3);
stats2.position_profit[0].insert(1, -50.0);
stats1.merge(&stats2);
assert_eq!(stats1.position_games[0].get(&0), Some(&5));
assert_eq!(stats1.position_games[0].get(&1), Some(&3));
assert_eq!(stats1.position_profit[0].get(&0), Some(&100.0));
assert_eq!(stats1.position_profit[0].get(&1), Some(&-50.0));
}
#[test]
fn test_position_profit_positive_and_negative() {
let mut stats = StatsStorage::new_with_num_players(2);
stats.position_games[0].insert(0, 1);
stats.position_games[0].insert(1, 1);
stats.position_profit[0].insert(0, 100.0);
stats.position_profit[0].insert(1, -50.0);
stats.position_games[1].insert(0, 1);
stats.position_games[1].insert(1, 1);
stats.position_profit[1].insert(0, -100.0);
stats.position_profit[1].insert(1, 50.0);
let profit_at_0 = *stats.position_profit[0].get(&0).unwrap_or(&0.0);
let games_at_0 = *stats.position_games[0].get(&0).unwrap_or(&0) as f32;
let ppg_at_0 = if games_at_0 > 0.0 {
profit_at_0 / games_at_0
} else {
0.0
};
assert_eq!(ppg_at_0, 100.0);
let profit_at_1 = *stats.position_profit[0].get(&1).unwrap_or(&0.0);
let games_at_1 = *stats.position_games[0].get(&1).unwrap_or(&0) as f32;
let ppg_at_1 = if games_at_1 > 0.0 {
profit_at_1 / games_at_1
} else {
0.0
};
assert_eq!(ppg_at_1, -50.0);
}
#[test]
fn test_position_tracking_empty_initial() {
let stats = StatsStorage::new_with_num_players(3);
for player in 0..3 {
assert!(
stats.position_games[player].is_empty(),
"Player {} should have no position games initially",
player
);
assert!(
stats.position_profit[player].is_empty(),
"Player {} should have no position profit initially",
player
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_position_tracking_multiple_games_accumulates() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..10 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert_eq!(
stats.position_games[0].get(&0),
Some(&10),
"Player 0 should have 10 games at position 0"
);
assert_eq!(
stats.position_games[1].get(&1),
Some(&10),
"Player 1 should have 10 games at position 1"
);
let total_profit: f32 = stats.total_profit.iter().sum();
assert!(
total_profit.abs() < 0.01,
"Total profit across players should be zero, got {}",
total_profit
);
}
#[test]
fn test_position_profit_calculation_accuracy() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.position_games[0].insert(0, 4);
stats.position_profit[0].insert(0, 100.0 - 50.0 + 25.0 - 75.0);
let profit = stats.position_profit[0].get(&0).copied().unwrap_or(0.0);
assert!(profit.abs() < 0.001, "Expected 0.0 profit, got {}", profit);
}
#[tokio::test(flavor = "current_thread")]
async fn test_position_tracking_zero_sum_heads_up_multiple_games() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..50 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
let player0_pos_profit: f32 = stats.position_profit[0].values().sum();
let player1_pos_profit: f32 = stats.position_profit[1].values().sum();
assert!(
(player0_pos_profit + player1_pos_profit).abs() < 0.01,
"Position profits should sum to zero, got {} + {} = {}",
player0_pos_profit,
player1_pos_profit,
player0_pos_profit + player1_pos_profit
);
assert!(
(player0_pos_profit - stats.total_profit[0]).abs() < 0.01,
"Player 0 position profit {} should equal total profit {}",
player0_pos_profit,
stats.total_profit[0]
);
assert!(
(player1_pos_profit - stats.total_profit[1]).abs() < 0.01,
"Player 1 position profit {} should equal total profit {}",
player1_pos_profit,
stats.total_profit[1]
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_position_stats_three_player_zero_sum() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(3);
for seed in 0..30 {
let hist = storage.historian();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
let total_position_profit: f32 =
(0..3).flat_map(|p| stats.position_profit[p].values()).sum();
assert!(
total_position_profit.abs() < 0.01,
"Total position profit should be zero in 3-player game, got {}",
total_position_profit
);
}
#[test]
fn test_aggression_frequency_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.bet_count[0] = 3;
stats.raise_count[0] = 2;
stats.call_count[0] = 3;
stats.fold_count[0] = 2;
let afq = stats.aggression_frequency(0);
assert!(
(afq - 50.0).abs() < 0.01,
"Expected AFq of 50%, got {}",
afq
);
}
#[test]
fn test_aggression_frequency_zero_actions() {
let stats = StatsStorage::new_with_num_players(1);
let afq = stats.aggression_frequency(0);
assert_eq!(afq, 0.0);
}
#[test]
fn test_aggression_frequency_all_aggressive() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.bet_count[0] = 5;
stats.raise_count[0] = 5;
let afq = stats.aggression_frequency(0);
assert!(
(afq - 100.0).abs() < 0.01,
"Expected AFq of 100%, got {}",
afq
);
}
#[test]
fn test_flop_aggression_factor() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_bets[0] = 4;
stats.flop_raises[0] = 2;
stats.flop_calls[0] = 2;
let flop_af = stats.flop_aggression_factor(0);
assert!(
(flop_af - 3.0).abs() < 0.01,
"Expected Flop AF of 3.0, got {}",
flop_af
);
}
#[test]
fn test_turn_aggression_factor() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_bets[0] = 3;
stats.turn_raises[0] = 1;
stats.turn_calls[0] = 2;
let turn_af = stats.turn_aggression_factor(0);
assert!(
(turn_af - 2.0).abs() < 0.01,
"Expected Turn AF of 2.0, got {}",
turn_af
);
}
#[test]
fn test_river_aggression_factor() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_bets[0] = 2;
stats.river_raises[0] = 0;
stats.river_calls[0] = 4;
let river_af = stats.river_aggression_factor(0);
assert!(
(river_af - 0.5).abs() < 0.01,
"Expected River AF of 0.5, got {}",
river_af
);
}
#[test]
fn test_per_street_af_no_calls() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_bets[0] = 3;
stats.flop_raises[0] = 2;
let flop_af = stats.flop_aggression_factor(0);
assert_eq!(flop_af, f32::INFINITY);
}
#[test]
fn test_per_street_af_no_actions() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(stats.flop_aggression_factor(0), 0.0);
assert_eq!(stats.turn_aggression_factor(0), 0.0);
assert_eq!(stats.river_aggression_factor(0), 0.0);
}
#[test]
fn test_cbet_percent_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.cbet_opportunities[0] = 10;
stats.cbet_count[0] = 7;
let cbet = stats.cbet_percent(0);
assert!(
(cbet - 70.0).abs() < 0.01,
"Expected C-Bet% of 70%, got {}",
cbet
);
}
#[test]
fn test_cbet_percent_zero_opportunities() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(stats.cbet_percent(0), 0.0);
}
#[test]
fn test_wtsd_percent_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.wtsd_opportunities[0] = 20; stats.wtsd_count[0] = 8;
let wtsd = stats.wtsd_percent(0);
assert!(
(wtsd - 40.0).abs() < 0.01,
"Expected WTSD% of 40%, got {}",
wtsd
);
}
#[test]
fn test_wtsd_percent_zero_opportunities() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(stats.wtsd_percent(0), 0.0);
}
#[test]
fn test_wsd_percent_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.showdown_count[0] = 10;
stats.showdown_wins[0] = 6;
let wsd = stats.wsd_percent(0);
assert!(
(wsd - 60.0).abs() < 0.01,
"Expected W$SD% of 60%, got {}",
wsd
);
}
#[test]
fn test_wsd_percent_zero_showdowns() {
let stats = StatsStorage::new_with_num_players(1);
assert_eq!(stats.wsd_percent(0), 0.0);
}
#[tokio::test]
async fn test_fold_count_tracking() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
assert!(
borrowed.fold_count[0] > 0,
"Expected folding agent to have folds tracked, got {}",
borrowed.fold_count[0]
);
}
#[tokio::test]
async fn test_per_street_action_tracking() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"bettor",
vec![
AgentAction::Bet(20.0), AgentAction::Bet(10.0), AgentAction::Bet(10.0), AgentAction::Bet(10.0), ],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![
AgentAction::Call, AgentAction::Call, AgentAction::Call, AgentAction::Call, ],
AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
let total_flop_bets: usize = borrowed.flop_bets.iter().sum();
let total_turn_bets: usize = borrowed.turn_bets.iter().sum();
let total_river_bets: usize = borrowed.river_bets.iter().sum();
let total_flop_calls: usize = borrowed.flop_calls.iter().sum();
let total_turn_calls: usize = borrowed.turn_calls.iter().sum();
let total_river_calls: usize = borrowed.river_calls.iter().sum();
assert!(
total_flop_bets > 0,
"Expected flop bets, got {}",
total_flop_bets
);
assert!(
total_flop_calls > 0,
"Expected flop calls, got {}",
total_flop_calls
);
assert!(
total_turn_bets > 0 || total_turn_calls > 0,
"Expected some turn actions, got bets={}, calls={}",
total_turn_bets,
total_turn_calls
);
assert!(
total_river_bets > 0 || total_river_calls > 0,
"Expected some river actions, got bets={}, calls={}",
total_river_bets,
total_river_calls
);
}
#[tokio::test]
async fn test_cbet_tracking_raiser_bets_flop() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(2));
let storage = hist.get_storage();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"aggressor",
vec![
AgentAction::Bet(20.0), AgentAction::Bet(15.0), ],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![
AgentAction::Call, AgentAction::Call, ],
AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
assert!(
borrowed.cbet_opportunities[0] >= 1,
"Expected aggressor to have C-Bet opportunity, got {}",
borrowed.cbet_opportunities[0]
);
assert!(
borrowed.cbet_count[0] >= 1,
"Expected aggressor to have C-Bet count, got {}",
borrowed.cbet_count[0]
);
let cbet_pct = borrowed.cbet_percent(0);
assert!(cbet_pct > 0.0, "Expected positive C-Bet%, got {}", cbet_pct);
}
#[tokio::test]
async fn test_ats_tracking_button_steals() {
let hist = Box::new(StatsTrackingHistorian::new_with_num_players(3));
let storage = hist.get_storage();
let stacks = vec![100.0; 3];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"button_stealer",
vec![
AgentAction::Bet(20.0), ],
AgentAction::Fold,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"sb_folder",
vec![
AgentAction::Fold, ],
AgentAction::Fold,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"bb_folder",
vec![
AgentAction::Fold, ],
AgentAction::Fold,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![hist])
.build()
.unwrap();
sim.run().await;
let borrowed = storage.read();
assert_eq!(
borrowed.steal_opportunities[0], 1,
"Expected BTN to have 1 steal opportunity, got {}",
borrowed.steal_opportunities[0]
);
assert_eq!(
borrowed.steal_count[0], 1,
"Expected BTN to have 1 steal, got {}",
borrowed.steal_count[0]
);
let steal_pct = borrowed.steal_percent(0);
assert!(
(steal_pct - 100.0).abs() < 0.001,
"Expected 100% steal%, got {}",
steal_pct
);
assert_eq!(
borrowed.steal_opportunities[1], 0,
"SB should have no steal opportunities"
);
assert_eq!(
borrowed.steal_opportunities[2], 0,
"BB should have no steal opportunities"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_wtsd_tracking() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..20 {
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert!(
stats.wtsd_opportunities[0] > 0,
"Expected WTSD opportunities for player 0, got {}",
stats.wtsd_opportunities[0]
);
assert!(
stats.wtsd_opportunities[1] > 0,
"Expected WTSD opportunities for player 1, got {}",
stats.wtsd_opportunities[1]
);
assert!(
stats.wtsd_count[0] > 0,
"Expected WTSD count for player 0, got {}",
stats.wtsd_count[0]
);
assert!(
stats.wtsd_count[1] > 0,
"Expected WTSD count for player 1, got {}",
stats.wtsd_count[1]
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_wsd_tracking() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..20 {
let hist = storage.historian();
let stacks = vec![100.0; 2];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert!(
stats.showdown_count[0] > 0,
"Expected showdown count for player 0, got {}",
stats.showdown_count[0]
);
assert!(
stats.showdown_count[1] > 0,
"Expected showdown count for player 1, got {}",
stats.showdown_count[1]
);
let total_showdown_wins = stats.showdown_wins[0] + stats.showdown_wins[1];
assert!(
total_showdown_wins > 0,
"Expected some showdown wins, got {}",
total_showdown_wins
);
let wsd_0 = stats.wsd_percent(0);
let wsd_1 = stats.wsd_percent(1);
assert!(
(0.0..=100.0).contains(&wsd_0),
"W$SD should be between 0-100%, got {}",
wsd_0
);
assert!(
(0.0..=100.0).contains(&wsd_1),
"W$SD should be between 0-100%, got {}",
wsd_1
);
}
#[test]
fn test_merge_advanced_stats() {
let mut stats1 = StatsStorage::new_with_num_players(2);
stats1.cbet_opportunities[0] = 5;
stats1.cbet_count[0] = 3;
stats1.wtsd_opportunities[0] = 10;
stats1.wtsd_count[0] = 4;
stats1.showdown_count[0] = 4;
stats1.showdown_wins[0] = 2;
stats1.fold_count[0] = 5;
stats1.flop_bets[0] = 3;
stats1.turn_raises[0] = 2;
stats1.river_calls[0] = 4;
let mut stats2 = StatsStorage::new_with_num_players(2);
stats2.cbet_opportunities[0] = 3;
stats2.cbet_count[0] = 2;
stats2.wtsd_opportunities[0] = 5;
stats2.wtsd_count[0] = 3;
stats2.showdown_count[0] = 3;
stats2.showdown_wins[0] = 1;
stats2.fold_count[0] = 3;
stats2.flop_bets[0] = 2;
stats2.turn_raises[0] = 1;
stats2.river_calls[0] = 2;
stats1.merge(&stats2);
assert_eq!(stats1.cbet_opportunities[0], 8);
assert_eq!(stats1.cbet_count[0], 5);
assert_eq!(stats1.wtsd_opportunities[0], 15);
assert_eq!(stats1.wtsd_count[0], 7);
assert_eq!(stats1.showdown_count[0], 7);
assert_eq!(stats1.showdown_wins[0], 3);
assert_eq!(stats1.fold_count[0], 8);
assert_eq!(stats1.flop_bets[0], 5);
assert_eq!(stats1.turn_raises[0], 3);
assert_eq!(stats1.river_calls[0], 6);
let cbet_pct = stats1.cbet_percent(0);
assert!((cbet_pct - 62.5).abs() < 0.01);
let wtsd_pct = stats1.wtsd_percent(0);
assert!((wtsd_pct - 46.67).abs() < 0.1);
let wsd_pct = stats1.wsd_percent(0);
assert!((wsd_pct - 42.86).abs() < 0.1);
}
#[test]
fn test_aggression_factor_arithmetic_correctness() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.raise_count[0] = 3;
stats.bet_count[0] = 2;
stats.call_count[0] = 1;
let af = stats.aggression_factor(0);
assert!(
(af - 5.0).abs() < 0.001,
"Expected AF of 5.0 (3+2)/1, got {}",
af
);
}
#[test]
fn test_aggression_factor_asymmetric_values() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.raise_count[0] = 7;
stats.bet_count[0] = 3;
stats.call_count[0] = 5;
let af = stats.aggression_factor(0);
assert!((af - 2.0).abs() < 0.001, "Expected AF of 2.0, got {}", af);
}
#[test]
fn test_roi_percent_calculation() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_profit[0] = 100.0;
stats.total_invested[0] = 1000.0;
let roi = stats.roi_percent(0);
assert!(
(roi - 10.0).abs() < 0.001,
"Expected ROI of 10.0%, got {}",
roi
);
}
#[test]
fn test_roi_percent_division() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_profit[0] = 50.0;
stats.total_invested[0] = 1000.0;
let roi = stats.roi_percent(0);
assert!(
(roi - 5.0).abs() < 0.001,
"Expected ROI of 5.0%, got {}",
roi
);
}
#[test]
fn test_flop_aggression_factor_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_bets[0] = 4;
stats.flop_raises[0] = 2;
stats.flop_calls[0] = 3;
let flop_af = stats.flop_aggression_factor(0);
assert!(
(flop_af - 2.0).abs() < 0.001,
"Expected Flop AF of 2.0, got {}",
flop_af
);
}
#[test]
fn test_turn_aggression_factor_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_bets[0] = 5;
stats.turn_raises[0] = 3;
stats.turn_calls[0] = 4;
let turn_af = stats.turn_aggression_factor(0);
assert!(
(turn_af - 2.0).abs() < 0.001,
"Expected Turn AF of 2.0, got {}",
turn_af
);
}
#[test]
fn test_river_aggression_factor_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_bets[0] = 3;
stats.river_raises[0] = 2;
stats.river_calls[0] = 0;
let river_af = stats.river_aggression_factor(0);
assert_eq!(river_af, f32::INFINITY);
stats.river_calls[0] = 5;
let river_af = stats.river_aggression_factor(0);
assert!(
(river_af - 1.0).abs() < 0.001,
"Expected River AF of 1.0, got {}",
river_af
);
}
#[test]
fn test_merge_all_action_counts() {
let mut stats1 = StatsStorage::new_with_num_players(1);
stats1.actions_count[0] = 10;
stats1.vpip_count[0] = 5;
stats1.vpip_total[0] = 100.0;
stats1.raise_count[0] = 3;
stats1.hands_played[0] = 10;
stats1.hands_vpip[0] = 6;
stats1.hands_pfr[0] = 4;
stats1.preflop_raise_count[0] = 4;
stats1.preflop_actions[0] = 10;
stats1.three_bet_count[0] = 2;
stats1.three_bet_opportunities[0] = 5;
stats1.call_count[0] = 8;
stats1.bet_count[0] = 3;
let mut stats2 = StatsStorage::new_with_num_players(1);
stats2.actions_count[0] = 5;
stats2.vpip_count[0] = 3;
stats2.vpip_total[0] = 50.0;
stats2.raise_count[0] = 2;
stats2.hands_played[0] = 5;
stats2.hands_vpip[0] = 3;
stats2.hands_pfr[0] = 2;
stats2.preflop_raise_count[0] = 2;
stats2.preflop_actions[0] = 5;
stats2.three_bet_count[0] = 1;
stats2.three_bet_opportunities[0] = 3;
stats2.call_count[0] = 4;
stats2.bet_count[0] = 2;
stats1.merge(&stats2);
assert_eq!(stats1.actions_count[0], 15); assert_eq!(stats1.vpip_count[0], 8); assert!((stats1.vpip_total[0] - 150.0).abs() < 0.001); assert_eq!(stats1.raise_count[0], 5); assert_eq!(stats1.hands_played[0], 15); assert_eq!(stats1.hands_vpip[0], 9); assert_eq!(stats1.hands_pfr[0], 6); assert_eq!(stats1.preflop_raise_count[0], 6); assert_eq!(stats1.preflop_actions[0], 15); assert_eq!(stats1.three_bet_count[0], 3); assert_eq!(stats1.three_bet_opportunities[0], 8); assert_eq!(stats1.call_count[0], 12); assert_eq!(stats1.bet_count[0], 5); }
#[test]
fn test_merge_financial_tracking() {
let mut stats1 = StatsStorage::new_with_num_players(1);
stats1.total_profit[0] = 200.0;
stats1.games_won[0] = 10;
stats1.games_lost[0] = 5;
stats1.games_breakeven[0] = 3;
let mut stats2 = StatsStorage::new_with_num_players(1);
stats2.total_profit[0] = -50.0;
stats2.games_won[0] = 3;
stats2.games_lost[0] = 4;
stats2.games_breakeven[0] = 2;
stats1.merge(&stats2);
assert!((stats1.total_profit[0] - 150.0).abs() < 0.001); assert_eq!(stats1.games_won[0], 13); assert_eq!(stats1.games_lost[0], 9); assert_eq!(stats1.games_breakeven[0], 5); }
#[test]
fn test_merge_per_street_stats() {
let mut stats1 = StatsStorage::new_with_num_players(1);
stats1.flop_bets[0] = 5;
stats1.flop_raises[0] = 3;
stats1.flop_calls[0] = 7;
stats1.turn_bets[0] = 4;
stats1.turn_raises[0] = 2;
stats1.turn_calls[0] = 6;
stats1.river_bets[0] = 3;
stats1.river_raises[0] = 1;
stats1.river_calls[0] = 5;
let mut stats2 = StatsStorage::new_with_num_players(1);
stats2.flop_bets[0] = 2;
stats2.flop_raises[0] = 1;
stats2.flop_calls[0] = 3;
stats2.turn_bets[0] = 2;
stats2.turn_raises[0] = 1;
stats2.turn_calls[0] = 2;
stats2.river_bets[0] = 1;
stats2.river_raises[0] = 1;
stats2.river_calls[0] = 2;
stats1.merge(&stats2);
assert_eq!(stats1.flop_bets[0], 7);
assert_eq!(stats1.flop_raises[0], 4);
assert_eq!(stats1.flop_calls[0], 10);
assert_eq!(stats1.turn_bets[0], 6);
assert_eq!(stats1.turn_raises[0], 3);
assert_eq!(stats1.turn_calls[0], 8);
assert_eq!(stats1.river_bets[0], 4);
assert_eq!(stats1.river_raises[0], 2);
assert_eq!(stats1.river_calls[0], 7);
}
#[test]
fn test_merge_steal_stats() {
let mut stats1 = StatsStorage::new_with_num_players(1);
stats1.steal_opportunities[0] = 10;
stats1.steal_count[0] = 6;
let mut stats2 = StatsStorage::new_with_num_players(1);
stats2.steal_opportunities[0] = 5;
stats2.steal_count[0] = 3;
stats1.merge(&stats2);
assert_eq!(stats1.steal_opportunities[0], 15);
assert_eq!(stats1.steal_count[0], 9);
}
#[test]
fn test_is_steal_position_two_players() {
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 2),
"In heads-up, player 0 should be in steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 2),
"In heads-up, player 1 should be in steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(0, 1, 2),
"In heads-up with dealer 1, player 0 should be in steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 1, 2),
"In heads-up with dealer 1, player 1 should be in steal position"
);
}
#[test]
fn test_is_steal_position_three_players() {
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 3),
"BTN (pos 0) should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 3),
"SB (pos 1) should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(2, 0, 3),
"CO/BB (pos 2) should be steal position in 3-player"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_round_advance_preflop_completes() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(42))
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.preflop_completes[0] > 0,
"Expected preflop_completes[0] > 0"
);
assert!(
stats.preflop_completes[1] > 0,
"Expected preflop_completes[1] > 0"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_round_advance_all_rounds() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..10 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert!(
stats.preflop_completes[0] > 0,
"preflop_completes should be tracked"
);
assert!(
stats.flop_completes[0] > 0,
"flop_completes should be tracked"
);
assert!(
stats.turn_completes[0] > 0,
"turn_completes should be tracked"
);
assert!(
stats.river_completes[0] > 0,
"river_completes should be tracked"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_game_complete_tracks_round_wins() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..10 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert!(
stats.preflop_wins[0] > 0 || stats.games_won[0] > 0,
"Expected the all-in agent to have wins"
);
}
#[tokio::test]
async fn test_game_complete_profit_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
let total_won = stats.games_won[0] + stats.games_won[1];
let total_lost = stats.games_lost[0] + stats.games_lost[1];
assert!(total_won > 0, "Expected at least one win");
assert!(total_lost > 0, "Expected at least one loss");
}
#[tokio::test(flavor = "current_thread")]
async fn test_game_start_resets_state() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..2 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert_eq!(
stats.hands_played[0], 2,
"Expected 2 hands played for player 0"
);
assert_eq!(
stats.hands_played[1], 2,
"Expected 2 hands played for player 1"
);
}
#[tokio::test]
async fn test_played_action_bet_vpip_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(30.0)], AgentAction::Call,
)) as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 1, "Raiser should have VPIP");
assert_eq!(stats.hands_vpip[1], 0, "Folder should not have VPIP");
}
#[tokio::test]
async fn test_played_action_call_vpip_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![AgentAction::Call], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_vpip[0], 1, "Raiser should have VPIP");
assert_eq!(stats.hands_vpip[1], 1, "Caller should have VPIP");
}
#[tokio::test]
async fn test_played_action_fold_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.fold_count[0] > 0,
"Expected fold_count[0] > 0, got {}",
stats.fold_count[0]
);
}
#[tokio::test]
async fn test_played_action_allin_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.bet_count[0] > 0,
"Expected bet_count[0] > 0 for all-in agent, got {}",
stats.bet_count[0]
);
assert_eq!(stats.hands_vpip[0], 1, "All-in agent should have VPIP");
}
#[tokio::test]
async fn test_historian_record_action_game_start() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.hands_played[0], 1,
"Game start should initialize tracking"
);
}
#[tokio::test]
async fn test_historian_record_action_failed_action() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<AllInAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(stats.hands_played[0], 1);
}
#[tokio::test]
async fn test_historian_record_action_award() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<AllInAgent>::default() as Box<dyn Agent>,
Box::<FoldingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
let total_profit = stats.total_profit[0] + stats.total_profit[1];
assert!(total_profit.abs() < 0.01, "Profits should sum to zero");
}
#[tokio::test(flavor = "current_thread")]
async fn test_wtsd_saw_flop_tracking() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..5 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert_eq!(
stats.wtsd_opportunities[0], 5,
"Expected 5 WTSD opportunities for player 0"
);
assert_eq!(
stats.wtsd_opportunities[1], 5,
"Expected 5 WTSD opportunities for player 1"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_showdown_tracking_with_multiple_active_players() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
for seed in 0..10 {
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<CallingAgent>::default() as Box<dyn Agent>,
Box::<CallingAgent>::default() as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(seed))
.unwrap();
sim.run().await;
}
let stats = storage.read();
assert!(
stats.showdown_count[0] > 0 && stats.showdown_count[1] > 0,
"Expected showdown counts for both players"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_showdown_counted_when_players_all_in() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(VecReplayAgent::new_with_default(
"shover",
vec![AgentAction::AllIn],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::new(VecReplayAgent::new_with_default(
"caller",
vec![AgentAction::AllIn],
AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build_with_rng(StdRng::seed_from_u64(0))
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.showdown_count[0] > 0 && stats.showdown_count[1] > 0,
"all-in showdown should count for both players: got {:?}",
stats.showdown_count
);
}
#[tokio::test]
async fn test_preflop_raise_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0), AgentAction::Bet(60.0)],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![AgentAction::Call, AgentAction::Call],
AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.preflop_raise_count[0] > 0,
"Expected preflop_raise_count[0] > 0"
);
assert_eq!(stats.hands_pfr[0], 1, "Raiser should have hands_pfr = 1");
assert_eq!(stats.hands_pfr[1], 0, "Caller should have hands_pfr = 0");
}
#[tokio::test]
async fn test_three_bet_tracking() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![200.0, 200.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0), AgentAction::Call],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"three-better",
vec![AgentAction::Bet(50.0)], AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert!(
stats.three_bet_opportunities[1] > 0,
"Expected 3-bet opportunities for player 1"
);
assert!(
stats.three_bet_count[1] > 0,
"Expected 3-bet count for player 1"
);
}
#[tokio::test]
async fn test_three_bet_excludes_four_bet() {
let storage = SharedStatsStorage::new(3);
let hist = storage.historian();
let stacks = vec![1000.0, 1000.0, 1000.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(VecReplayAgent::new_with_default(
"open-raiser",
vec![
AgentAction::Bet(20.0),
AgentAction::Bet(120.0),
AgentAction::Call,
],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::new(VecReplayAgent::new_with_default(
"three-better",
vec![AgentAction::Bet(50.0), AgentAction::Call],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::new(VecReplayAgent::new_with_default(
"folder",
vec![AgentAction::Fold],
AgentAction::Fold,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let stats = storage.read();
assert_eq!(
stats.three_bet_count[1], 1,
"P1 should have exactly 1 three-bet, got {}",
stats.three_bet_count[1]
);
assert_eq!(
stats.three_bet_count[0], 0,
"P0's 4-bet should not count as a 3-bet, got {}",
stats.three_bet_count[0]
);
assert_eq!(
stats.three_bet_opportunities[1], 1,
"P1 should have exactly 1 three-bet opportunity, got {}",
stats.three_bet_opportunities[1]
);
assert_eq!(
stats.three_bet_opportunities[0], 0,
"P0 should have 0 three-bet opportunities (4-bet is not a 3-bet opp), got {}",
stats.three_bet_opportunities[0]
);
}
#[test]
fn test_river_bets_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_bets[0] = 5;
stats.river_bets[0] += 3;
assert_eq!(stats.river_bets[0], 8, "river_bets should be 5 + 3 = 8");
}
#[test]
fn test_turn_bets_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_bets[0] = 4;
stats.turn_bets[0] += 2;
assert_eq!(stats.turn_bets[0], 6, "turn_bets should be 4 + 2 = 6");
}
#[test]
fn test_flop_bets_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_bets[0] = 3;
stats.flop_bets[0] += 2;
assert_eq!(stats.flop_bets[0], 5, "flop_bets should be 3 + 2 = 5");
}
#[test]
fn test_river_raises_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_raises[0] = 2;
stats.river_raises[0] += 3;
assert_eq!(stats.river_raises[0], 5, "river_raises should be 2 + 3 = 5");
}
#[test]
fn test_turn_raises_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_raises[0] = 2;
stats.turn_raises[0] += 2;
assert_eq!(stats.turn_raises[0], 4, "turn_raises should be 2 + 2 = 4");
}
#[test]
fn test_flop_raises_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_raises[0] = 1;
stats.flop_raises[0] += 4;
assert_eq!(stats.flop_raises[0], 5, "flop_raises should be 1 + 4 = 5");
}
#[test]
fn test_river_calls_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_calls[0] = 3;
stats.river_calls[0] += 2;
assert_eq!(stats.river_calls[0], 5, "river_calls should be 3 + 2 = 5");
}
#[test]
fn test_turn_calls_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_calls[0] = 4;
stats.turn_calls[0] += 1;
assert_eq!(stats.turn_calls[0], 5, "turn_calls should be 4 + 1 = 5");
}
#[test]
fn test_flop_calls_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_calls[0] = 2;
stats.flop_calls[0] += 3;
assert_eq!(stats.flop_calls[0], 5, "flop_calls should be 2 + 3 = 5");
}
#[test]
fn test_vpip_count_legacy_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.vpip_count[0] = 10;
stats.vpip_count[0] += 5;
assert_eq!(stats.vpip_count[0], 15, "vpip_count should be 10 + 5 = 15");
}
#[test]
fn test_vpip_total_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.vpip_total[0] = 100.0;
stats.vpip_total[0] += 50.0;
assert!(
(stats.vpip_total[0] - 150.0).abs() < 0.01,
"vpip_total should be 100 + 50 = 150"
);
}
#[test]
fn test_invested_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_invested[0] = 200.0;
stats.total_invested[0] += 100.0;
assert!(
(stats.total_invested[0] - 300.0).abs() < 0.01,
"total_invested should be 200 + 100 = 300"
);
}
#[test]
fn test_preflop_raise_count_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.preflop_raise_count[0] = 2;
stats.preflop_raise_count[0] += 3;
assert_eq!(
stats.preflop_raise_count[0], 5,
"preflop_raise_count should be 2 + 3 = 5"
);
}
#[test]
fn test_per_street_aggression_factor_addition() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.flop_bets[0] = 3;
stats.flop_raises[0] = 2;
stats.flop_calls[0] = 1;
let af = stats.flop_aggression_factor(0);
assert!(
(af - 5.0).abs() < 0.001,
"Flop AF should be (3 + 2) / 1 = 5.0, got {}",
af
);
}
#[test]
fn test_turn_aggression_factor_addition() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.turn_bets[0] = 4;
stats.turn_raises[0] = 3;
stats.turn_calls[0] = 2;
let af = stats.turn_aggression_factor(0);
assert!(
(af - 3.5).abs() < 0.001,
"Turn AF should be (4 + 3) / 2 = 3.5, got {}",
af
);
}
#[test]
fn test_river_aggression_factor_addition() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.river_bets[0] = 2;
stats.river_raises[0] = 4;
stats.river_calls[0] = 3;
let af = stats.river_aggression_factor(0);
assert!(
(af - 2.0).abs() < 0.001,
"River AF should be (2 + 4) / 3 = 2.0, got {}",
af
);
}
#[test]
fn test_overall_aggression_factor_addition() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.raise_count[0] = 5;
stats.bet_count[0] = 3;
stats.call_count[0] = 4;
let af = stats.aggression_factor(0);
assert!(
(af - 2.0).abs() < 0.001,
"Overall AF should be (5 + 3) / 4 = 2.0, got {}",
af
);
}
#[test]
fn test_round_wins_tracking() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.preflop_wins[0] = 2;
stats.flop_wins[0] = 3;
stats.turn_wins[0] = 1;
stats.river_wins[0] = 4;
stats.preflop_wins[0] += 1;
stats.flop_wins[0] += 1;
stats.turn_wins[0] += 1;
stats.river_wins[0] += 1;
assert_eq!(stats.preflop_wins[0], 3, "preflop_wins should be 2 + 1 = 3");
assert_eq!(stats.flop_wins[0], 4, "flop_wins should be 3 + 1 = 4");
assert_eq!(stats.turn_wins[0], 2, "turn_wins should be 1 + 1 = 2");
assert_eq!(stats.river_wins[0], 5, "river_wins should be 4 + 1 = 5");
}
#[test]
fn test_games_breakeven_tracking() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.games_breakeven[0] = 3;
stats.games_breakeven[0] += 2;
assert_eq!(
stats.games_breakeven[0], 5,
"games_breakeven should be 3 + 2 = 5"
);
}
#[test]
fn test_is_steal_position_edge_cases() {
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 2),
"In heads-up, player 0 should be in steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 2),
"In heads-up, player 1 should be in steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 3),
"Button should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 3),
"SB should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(2, 0, 3),
"CO should be steal position"
);
assert!(
StatsTrackingHistorian::is_steal_position(0, 0, 6),
"Button should be steal position (6 players)"
);
assert!(
StatsTrackingHistorian::is_steal_position(5, 0, 6),
"CO should be steal position (6 players)"
);
assert!(
StatsTrackingHistorian::is_steal_position(1, 0, 6),
"SB should be steal position (6 players)"
);
assert!(
!StatsTrackingHistorian::is_steal_position(2, 0, 6),
"BB should NOT be steal position (6 players)"
);
assert!(
!StatsTrackingHistorian::is_steal_position(3, 0, 6),
"UTG should NOT be steal position (6 players)"
);
}
#[test]
fn test_hand_accumulator_reset() {
let mut accumulator = HandAccumulator::new(2);
accumulator.player_stats[0].actions_count = 5;
accumulator.player_stats[0].raise_count = 3;
accumulator.player_stats[1].bet_count = 2;
accumulator.player_stats[1].vpip_occurred = true;
accumulator.reset();
assert_eq!(
accumulator.player_stats[0].actions_count, 0,
"actions_count should be 0 after reset"
);
assert_eq!(
accumulator.player_stats[0].raise_count, 0,
"raise_count should be 0 after reset"
);
assert_eq!(
accumulator.player_stats[1].bet_count, 0,
"bet_count should be 0 after reset"
);
assert!(
!accumulator.player_stats[1].vpip_occurred,
"vpip_occurred should be false after reset"
);
}
#[tokio::test]
async fn test_flush_accumulated_stats_arithmetic() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
{
let mut s = storage.inner().write().unwrap();
s.actions_count[0] = 10;
s.raise_count[0] = 5;
}
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"raiser",
vec![AgentAction::Bet(20.0)],
AgentAction::Call,
)) as Box<dyn Agent>,
Box::<VecReplayAgent>::new(VecReplayAgent::new_with_default(
"caller",
vec![AgentAction::Call],
AgentAction::Call,
)) as Box<dyn Agent>,
];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let s = storage.read();
assert!(
s.actions_count[0] > 10,
"actions_count should have increased from 10"
);
assert!(
s.raise_count[0] > 5,
"raise_count should have increased from 5"
);
}
#[test]
fn test_total_profit_tracking_arithmetic() {
let mut stats = StatsStorage::new_with_num_players(1);
stats.total_profit[0] = 50.0;
stats.total_profit[0] += 25.0;
assert!(
(stats.total_profit[0] - 75.0).abs() < 0.01,
"total_profit should be 50 + 25 = 75"
);
stats.total_profit[0] += -100.0;
assert!(
(stats.total_profit[0] - (-25.0)).abs() < 0.01,
"total_profit should be 75 + (-100) = -25"
);
}
#[tokio::test]
async fn test_record_game_complete_profit_sign() {
let storage = SharedStatsStorage::new(2);
let hist = storage.historian();
let stacks = vec![100.0, 100.0];
let agents: Vec<Box<dyn Agent>> = vec![
Box::<FoldingAgent>::default() as Box<dyn Agent>, Box::<AllInAgent>::default() as Box<dyn Agent>, ];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.historians(vec![Box::new(hist)])
.build()
.unwrap();
sim.run().await;
let s = storage.read();
assert!(
s.total_profit[0] < 0.0,
"Folding agent should have negative profit (lost blind)"
);
assert!(
s.total_profit[1] > 0.0,
"All-in agent should have positive profit"
);
}
}