use std::collections::HashMap;
use rs_poker::arena::historian::StatsStorage;
use crate::tui::state::{AgentDisplayData, GameResult, ProfitHistory, StreetDistribution};
use crate::tui::widgets::stats_table::SortColumn;
pub(crate) const MAX_PROFIT_HISTORY: usize = 10_000;
#[derive(Default)]
pub struct Projection {
agent_stats: HashMap<String, StatsStorage>,
agent_profit_bb: HashMap<String, f32>,
agent_profit_history: HashMap<String, ProfitHistory>,
street_dist: StreetDistribution,
game_count: usize,
cached_agent_display: Option<Vec<AgentDisplayData>>,
cached_sort_col: Option<SortColumn>,
}
impl Projection {
pub fn new() -> Self {
Self::default()
}
pub fn game_count(&self) -> usize {
self.game_count
}
pub fn street_dist(&self) -> &StreetDistribution {
&self.street_dist
}
pub fn profit_histories(&self) -> &HashMap<String, ProfitHistory> {
&self.agent_profit_history
}
pub fn invalidate_display_cache(&mut self) {
self.cached_agent_display = None;
self.cached_sort_col = None;
}
pub fn agent_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.agent_stats.keys().cloned().collect();
names.sort();
names
}
pub fn fold(&mut self, result: &GameResult) {
self.game_count += 1;
self.cached_agent_display = None;
self.cached_sort_col = None;
self.street_dist.record(result.ending_round);
for (seat_idx, name) in result.agent_names.iter().enumerate() {
let storage = self
.agent_stats
.entry(name.clone())
.or_insert_with(|| StatsStorage::new_with_num_players(1));
result.seat_stats[seat_idx].merge_into(storage);
}
let mut agent_profits: HashMap<&str, f32> = HashMap::new();
for (seat_idx, name) in result.agent_names.iter().enumerate() {
*agent_profits.entry(name.as_str()).or_default() += result.profits[seat_idx];
}
for (name, profit) in agent_profits {
if result.big_blind > 0.0 {
*self.agent_profit_bb.entry(name.to_string()).or_default() +=
profit / result.big_blind;
}
let history = self
.agent_profit_history
.entry(name.to_string())
.or_insert_with(|| ProfitHistory {
first_game_index: self.game_count,
values: Vec::new(),
});
let prev = history.values.last().copied().unwrap_or(0.0);
history.values.push(prev + profit);
if history.values.len() > MAX_PROFIT_HISTORY {
let drop_count = history.values.len() - MAX_PROFIT_HISTORY;
history.values.drain(..drop_count);
history.first_game_index += drop_count;
}
}
}
pub fn agent_display_data(&mut self, sort_col: SortColumn) -> Vec<AgentDisplayData> {
if self.cached_sort_col == Some(sort_col)
&& let Some(cached) = &self.cached_agent_display
{
return cached.clone();
}
let mut agents: Vec<AgentDisplayData> = self
.agent_stats
.iter()
.map(|(name, stats)| {
let idx = 0;
let profit_bb = self.agent_profit_bb.get(name).copied().unwrap_or(0.0);
AgentDisplayData {
name: name.clone(),
total_profit: stats.total_profit[idx],
profit_bb,
games_played: stats.hands_played[idx],
wins: stats.games_won[idx],
vpip_percent: stats.vpip_percent(idx),
pfr_percent: stats.pfr_percent(idx),
three_bet_percent: stats.three_bet_percent(idx),
aggression_factor: stats.aggression_factor(idx),
cbet_percent: stats.cbet_percent(idx),
wtsd_percent: stats.wtsd_percent(idx),
wsd_percent: stats.wsd_percent(idx),
roi_percent: stats.roi_percent(idx),
}
})
.collect();
agents.sort_by(|a, b| match sort_col {
SortColumn::Name => a.name.cmp(&b.name),
SortColumn::Profit => b
.profit_bb
.partial_cmp(&a.profit_bb)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Games => b.games_played.cmp(&a.games_played),
SortColumn::WinPct => {
let pct = |x: &AgentDisplayData| {
if x.games_played > 0 {
x.wins as f32 / x.games_played as f32
} else {
0.0
}
};
pct(b)
.partial_cmp(&pct(a))
.unwrap_or(std::cmp::Ordering::Equal)
}
SortColumn::Roi => b
.roi_percent
.partial_cmp(&a.roi_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Vpip => b
.vpip_percent
.partial_cmp(&a.vpip_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Pfr => b
.pfr_percent
.partial_cmp(&a.pfr_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::ThreeBet => b
.three_bet_percent
.partial_cmp(&a.three_bet_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Af => b
.aggression_factor
.partial_cmp(&a.aggression_factor)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Cbet => b
.cbet_percent
.partial_cmp(&a.cbet_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Wtsd => b
.wtsd_percent
.partial_cmp(&a.wtsd_percent)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Wsd => b
.wsd_percent
.partial_cmp(&a.wsd_percent)
.unwrap_or(std::cmp::Ordering::Equal),
});
self.cached_agent_display = Some(agents.clone());
self.cached_sort_col = Some(sort_col);
agents
}
#[cfg(test)]
pub fn set_game_count(&mut self, n: usize) {
self.game_count = n;
}
}
use crate::tui::hand_store::HandStore;
pub fn build_projection<I>(game_numbers: I, hand_store: &HandStore) -> Projection
where
I: IntoIterator<Item = usize>,
{
let mut proj = Projection::new();
for n in game_numbers {
if let Ok(Some(hand)) = hand_store.fetch(n) {
proj.fold(&crate::tui::hand_stats::game_result_from_hand(&hand));
}
}
proj
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::{GameResult, RoundLabel, SeatStats};
use crate::tui::widgets::stats_table::SortColumn;
use rs_poker::arena::historian::StatsStorage;
fn make_game_result(names: &[&str], profits: &[f32], round: RoundLabel) -> GameResult {
let n = names.len();
let mut stats = StatsStorage::new_with_num_players(n);
for (i, &p) in profits.iter().enumerate() {
stats.total_profit[i] = p;
stats.hands_played[i] = 1;
stats.total_invested[i] = 10.0;
if p > 0.0 {
stats.games_won[i] = 1;
} else if p < 0.0 {
stats.games_lost[i] = 1;
} else {
stats.games_breakeven[i] = 1;
}
}
let seat_stats = (0..n).map(|i| SeatStats::from_storage(&stats, i)).collect();
GameResult {
agent_names: names.iter().map(|s| s.to_string()).collect(),
profits: profits.to_vec(),
ending_round: round,
seat_stats,
big_blind: 10.0,
}
}
#[test]
fn test_fold_accumulates_profit_and_count() {
let mut p = Projection::new();
p.fold(&make_game_result(
&["Alice", "Bob"],
&[10.0, -10.0],
RoundLabel::Flop,
));
p.fold(&make_game_result(
&["Alice", "Bob"],
&[-5.0, 5.0],
RoundLabel::River,
));
assert_eq!(p.game_count(), 2);
assert_eq!(p.street_dist().flop, 1);
assert_eq!(p.street_dist().river, 1);
let agents = p.agent_display_data(SortColumn::Profit);
let alice = agents.iter().find(|a| a.name == "Alice").unwrap();
assert!((alice.total_profit - 5.0).abs() < 0.01);
assert_eq!(alice.games_played, 2);
assert_eq!(alice.wins, 1);
}
#[test]
fn test_filtered_projection_indexes_profit_history_from_one() {
let mut p = Projection::new();
p.fold(&make_game_result(&["Alice"], &[3.0], RoundLabel::Preflop));
let hist = p.profit_histories().get("Alice").unwrap();
assert_eq!(hist.first_game_index, 1);
assert_eq!(hist.x_at(0), 1);
}
#[test]
fn test_display_cache_invalidation() {
let mut p = Projection::new();
p.fold(&make_game_result(&["A"], &[1.0], RoundLabel::Preflop));
let first = p.agent_display_data(SortColumn::Profit);
p.invalidate_display_cache();
let second = p.agent_display_data(SortColumn::Name);
assert_eq!(first.len(), second.len());
assert_eq!(second.len(), 1);
}
#[test]
fn test_display_data_respects_sort_column() {
let mut p = Projection::new();
p.fold(&make_game_result(
&["Zeb", "Amy"],
&[20.0, -20.0],
RoundLabel::River,
));
let by_profit = p.agent_display_data(SortColumn::Profit);
assert_eq!(by_profit[0].name, "Zeb", "profit sort: Zeb should be first");
assert_eq!(
by_profit[1].name, "Amy",
"profit sort: Amy should be second"
);
let by_name = p.agent_display_data(SortColumn::Name);
assert_eq!(by_name[0].name, "Amy", "name sort: Amy should be first");
assert_eq!(by_name[1].name, "Zeb", "name sort: Zeb should be second");
}
#[test]
fn test_build_projection_from_disk_matches_folding_those_hands() {
use crate::tui::hand_store::HandStore;
use rs_poker::open_hand_history::OpenHandHistoryWrapper;
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
for gn in ["1", "2"] {
let h = crate::tui::hand_stats::test_util::simple_hand(gn);
let wrapped = OpenHandHistoryWrapper { ohh: h };
serde_json::to_writer(tmp.as_file_mut(), &wrapped).unwrap();
writeln!(tmp.as_file_mut()).unwrap();
writeln!(tmp.as_file_mut()).unwrap();
}
let store = HandStore::from_existing(tmp.path()).unwrap();
let proj = build_projection([1usize], &store);
assert_eq!(proj.game_count(), 1);
}
}