Skip to content

Commit 5b1ea3a

Browse files
feat: enhance TUI with structured game log, BB-relative profits, and new filters (#247)
Redesign the game log from a flat list into a columnar table showing winner, loser, pot size, and street with agent-colored names. Display profits in big blinds throughout the stats table. Add loser, win-size, and loss-size bucket filters to the filter panel. Support Shift+Tab for reverse panel navigation.
1 parent 5505b66 commit 5b1ea3a

16 files changed

Lines changed: 638 additions & 249 deletions

src/bin/rsp/arena/compare.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ fn build_comparison(args: &CompareArgs) -> Result<ArenaComparison, CompareError>
100100
}
101101

102102
/// Convert a PermutationResult into a GameResult for the TUI.
103-
fn perm_to_game_result(perm: PermutationResult) -> GameResult {
103+
fn perm_to_game_result(perm: PermutationResult, big_blind: f32) -> GameResult {
104104
let num_players = perm.agent_names.len();
105105
let ending_round = ending_round_from_stats(&perm.stats, num_players);
106106
let profits: Vec<f32> = (0..num_players)
@@ -116,6 +116,7 @@ fn perm_to_game_result(perm: PermutationResult) -> GameResult {
116116
profits,
117117
ending_round,
118118
seat_stats,
119+
big_blind,
119120
}
120121
}
121122

@@ -125,6 +126,7 @@ fn run_comparison_background(
125126
tx: std::sync::mpsc::SyncSender<SimMessage<GameResult>>,
126127
hand_store: HandStore,
127128
ohh_path: Option<PathBuf>,
129+
big_blind: f32,
128130
) {
129131
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
130132
let mut prev_file_size: u64 = 0;
@@ -138,7 +140,7 @@ fn run_comparison_background(
138140
prev_file_size = current_size;
139141
}
140142

141-
let game_result = perm_to_game_result(perm);
143+
let game_result = perm_to_game_result(perm, big_blind);
142144
// If send fails, the TUI has quit — exit cleanly
143145
let _ = tx.send(SimMessage::GameResult(game_result));
144146
})
@@ -158,7 +160,10 @@ fn run_comparison_background(
158160
}
159161

160162
/// Run comparison with the TUI dashboard.
161-
fn run_comparison_with_tui(comparison: ArenaComparison) -> Result<(), CompareError> {
163+
fn run_comparison_with_tui(
164+
comparison: ArenaComparison,
165+
big_blind: f32,
166+
) -> Result<(), CompareError> {
162167
let total_games = comparison.total_games();
163168

164169
// Extract OHH path before moving comparison into background thread
@@ -182,7 +187,7 @@ fn run_comparison_with_tui(comparison: ArenaComparison) -> Result<(), CompareErr
182187
std::thread::Builder::new()
183188
.stack_size(STACK_SIZE)
184189
.spawn(move || {
185-
run_comparison_background(comparison, tx, bg_hand_store, ohh_path);
190+
run_comparison_background(comparison, tx, bg_hand_store, ohh_path, big_blind);
186191
})
187192
.expect("failed to spawn comparison thread");
188193

@@ -211,7 +216,7 @@ pub fn run(mut args: CompareArgs, tui_flags: &TuiFlags) -> Result<(), CompareErr
211216

212217
if tui_flags.should_use_tui() {
213218
comparison.print_configuration_summary();
214-
run_comparison_with_tui(comparison)
219+
run_comparison_with_tui(comparison, args.big_blind)
215220
} else {
216221
// Print configuration summary
217222
comparison.print_configuration_summary();

src/bin/rsp/arena/generate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ fn run_generation_inner(
461461
profits,
462462
ending_round,
463463
seat_stats,
464+
big_blind: args.big_blind,
464465
};
465466

466467
// If send fails, the TUI has quit - exit cleanly

src/bin/rsp/ohh/stats.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ pub fn build_state_from_hands(hands: &[HandHistory]) -> TuiState {
9191
profits,
9292
ending_round,
9393
seat_stats,
94+
big_blind: hand.big_blind_amount,
9495
});
9596
}
9697

src/bin/rsp/tui/app.rs

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ impl App {
302302
self.state.active_panel = self.state.active_panel.next();
303303
self.focus_effect = Some(effects::border_chase());
304304
}
305+
KeyCode::BackTab => {
306+
self.state.active_panel = self.state.active_panel.prev();
307+
self.focus_effect = Some(effects::border_chase());
308+
}
305309
KeyCode::Char('s') => {
306310
self.state.sort_col = self.state.sort_col.next();
307311
self.state.invalidate_display_cache();
@@ -358,6 +362,10 @@ impl App {
358362
self.state.filter.toggle_winner(name);
359363
self.reset_log_selection();
360364
}
365+
FilterItem::Loser(name) => {
366+
self.state.filter.toggle_loser(name);
367+
self.reset_log_selection();
368+
}
361369
FilterItem::Participant(name) => {
362370
self.state.filter.toggle_participant(name);
363371
self.reset_log_selection();
@@ -366,6 +374,14 @@ impl App {
366374
self.state.filter.toggle_street(*round);
367375
self.reset_log_selection();
368376
}
377+
FilterItem::WinSize(bucket) => {
378+
self.state.filter.toggle_win_size(*bucket);
379+
self.reset_log_selection();
380+
}
381+
FilterItem::LossSize(bucket) => {
382+
self.state.filter.toggle_loss_size(*bucket);
383+
self.reset_log_selection();
384+
}
369385
FilterItem::PlayerCount(count) => {
370386
self.state.filter.toggle_player_count(*count);
371387
self.reset_log_selection();
@@ -473,12 +489,13 @@ impl App {
473489
pub fn handle_sim_message(&mut self, msg: SimMessage<GameResult>) {
474490
match msg {
475491
SimMessage::GameResult(result) => {
476-
let entry = GameLogEntry {
477-
game_number: self.state.games_completed + 1,
478-
agent_names: result.agent_names.clone(),
479-
profits: result.profits.clone(),
480-
ending_round: result.ending_round,
481-
};
492+
let entry = GameLogEntry::new(
493+
self.state.games_completed + 1,
494+
result.agent_names.clone(),
495+
result.profits.clone(),
496+
result.ending_round,
497+
result.big_blind,
498+
);
482499
self.state.update(result);
483500
self.filtered_log.on_new_game(&entry, &self.state.filter);
484501
}
@@ -658,6 +675,18 @@ mod tests {
658675
assert_eq!(app.state.active_panel, Panel::Table);
659676
}
660677

678+
#[test]
679+
fn test_shift_tab_switches_panel_reverse() {
680+
let mut app = App::new(Some(10));
681+
assert_eq!(app.state.active_panel, Panel::Table);
682+
app.handle_key(key(KeyCode::BackTab));
683+
assert_eq!(app.state.active_panel, Panel::Filter);
684+
app.handle_key(key(KeyCode::BackTab));
685+
assert_eq!(app.state.active_panel, Panel::GameLog);
686+
app.handle_key(key(KeyCode::BackTab));
687+
assert_eq!(app.state.active_panel, Panel::Table);
688+
}
689+
661690
#[test]
662691
fn test_s_cycles_sort_column() {
663692
let mut app = App::new(Some(10));
@@ -686,6 +715,7 @@ mod tests {
686715
profits: vec![10.0],
687716
ending_round: RoundLabel::Preflop,
688717
seat_stats: vec![SeatStats::default()],
718+
big_blind: 10.0,
689719
};
690720
app.handle_sim_message(SimMessage::GameResult(result));
691721
assert_eq!(app.state.games_completed, 1);
@@ -712,6 +742,7 @@ mod tests {
712742
profits: profits.to_vec(),
713743
ending_round: round,
714744
seat_stats,
745+
big_blind: 10.0,
715746
}));
716747
}
717748

@@ -764,7 +795,7 @@ mod tests {
764795
}
765796

766797
#[test]
767-
fn test_filter_panel_enter_toggles_item() {
798+
fn test_filter_panel_enter_toggles_winner() {
768799
let mut app = App::new(Some(10));
769800
add_game(
770801
&mut app,
@@ -800,6 +831,79 @@ mod tests {
800831
assert!(app.state.filter.winners.contains("Alice"));
801832
}
802833

834+
#[test]
835+
fn test_filter_panel_enter_toggles_loser() {
836+
let mut app = App::new(Some(10));
837+
add_game(
838+
&mut app,
839+
&["Alice", "Bob"],
840+
&[10.0, -10.0],
841+
RoundLabel::River,
842+
);
843+
844+
app.state.active_panel = Panel::Filter;
845+
// Header "Winner" + 2 winners + Header "Loser" = index 3 is header
846+
// index 4 is Loser("Alice")
847+
app.state.filter.selected = 4;
848+
app.handle_key(key(KeyCode::Enter));
849+
assert!(app.state.filter.losers.contains("Alice"));
850+
851+
app.handle_key(key(KeyCode::Enter));
852+
assert!(!app.state.filter.losers.contains("Alice"));
853+
}
854+
855+
#[test]
856+
fn test_filter_panel_enter_toggles_win_size() {
857+
let mut app = App::new(Some(10));
858+
add_game(
859+
&mut app,
860+
&["Alice", "Bob"],
861+
&[10.0, -10.0],
862+
RoundLabel::River,
863+
);
864+
865+
app.state.active_panel = Panel::Filter;
866+
// Winner(2) + Loser(2) + Participant(2) + headers(3) = 9
867+
// + Header "Street" = 10, + 5 streets = 15
868+
// + Header "Win Size" = 16, first WinSize = 17
869+
// With 2 agents: indices are:
870+
// 0: Header Winner, 1-2: winners, 3: Header Loser, 4-5: losers,
871+
// 6: Header Participant, 7-8: participants,
872+
// 9: Header Street, 10-14: streets,
873+
// 15: Header Win Size, 16: WinSize(Small)
874+
app.state.filter.selected = 16;
875+
app.handle_key(key(KeyCode::Enter));
876+
assert!(
877+
app.state
878+
.filter
879+
.win_sizes
880+
.contains(&crate::tui::state::ProfitBucket::Small)
881+
);
882+
}
883+
884+
#[test]
885+
fn test_filter_panel_enter_toggles_loss_size() {
886+
let mut app = App::new(Some(10));
887+
add_game(
888+
&mut app,
889+
&["Alice", "Bob"],
890+
&[10.0, -10.0],
891+
RoundLabel::River,
892+
);
893+
894+
app.state.active_panel = Panel::Filter;
895+
// After Win Size section: 16-19 are WinSize buckets
896+
// 20: Header Loss Size, 21: LossSize(Small)
897+
app.state.filter.selected = 21;
898+
app.handle_key(key(KeyCode::Enter));
899+
assert!(
900+
app.state
901+
.filter
902+
.loss_sizes
903+
.contains(&crate::tui::state::ProfitBucket::Small)
904+
);
905+
}
906+
803907
#[test]
804908
fn test_filter_panel_header_is_noop() {
805909
let mut app = App::new(Some(10));

src/bin/rsp/tui/filtered_log.rs

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,13 @@ impl FilteredGameLog {
126126
};
127127
match hand_store.fetch_entry(game_number) {
128128
Ok(Some(entry)) => self.window_cache.push(entry),
129-
_ => self.window_cache.push(GameLogEntry {
129+
_ => self.window_cache.push(GameLogEntry::new(
130130
game_number,
131-
agent_names: vec![],
132-
profits: vec![],
133-
ending_round: crate::tui::state::RoundLabel::Preflop,
134-
}),
131+
vec![],
132+
vec![],
133+
crate::tui::state::RoundLabel::Preflop,
134+
0.0,
135+
)),
135136
}
136137
}
137138
}
@@ -213,12 +214,7 @@ mod tests {
213214
fn test_on_new_game_no_filter() {
214215
let mut log = FilteredGameLog::new();
215216
let filter = FilterState::default();
216-
let entry = GameLogEntry {
217-
game_number: 1,
218-
agent_names: vec!["A".into()],
219-
profits: vec![1.0],
220-
ending_round: RoundLabel::Preflop,
221-
};
217+
let entry = GameLogEntry::new(1, vec!["A".into()], vec![1.0], RoundLabel::Preflop, 10.0);
222218
log.on_new_game(&entry, &filter);
223219
assert_eq!(log.total(), 1);
224220
}
@@ -231,12 +227,7 @@ mod tests {
231227
let mut filter = FilterState::default();
232228
filter.toggle_street(RoundLabel::River);
233229

234-
let entry = GameLogEntry {
235-
game_number: 1,
236-
agent_names: vec!["A".into()],
237-
profits: vec![1.0],
238-
ending_round: RoundLabel::River,
239-
};
230+
let entry = GameLogEntry::new(1, vec!["A".into()], vec![1.0], RoundLabel::River, 10.0);
240231
log.on_new_game(&entry, &filter);
241232
assert_eq!(log.total(), 1);
242233
assert_eq!(log.game_number_at(0), Some(1));
@@ -250,12 +241,7 @@ mod tests {
250241
let mut filter = FilterState::default();
251242
filter.toggle_street(RoundLabel::River);
252243

253-
let entry = GameLogEntry {
254-
game_number: 1,
255-
agent_names: vec!["A".into()],
256-
profits: vec![1.0],
257-
ending_round: RoundLabel::Flop,
258-
};
244+
let entry = GameLogEntry::new(1, vec!["A".into()], vec![1.0], RoundLabel::Flop, 10.0);
259245
log.on_new_game(&entry, &filter);
260246
assert_eq!(log.total(), 0);
261247
}
@@ -300,12 +286,13 @@ mod tests {
300286
#[test]
301287
fn test_invalidate_clears_cache() {
302288
let mut log = FilteredGameLog::new();
303-
log.window_cache.push(GameLogEntry {
304-
game_number: 1,
305-
agent_names: vec!["A".into()],
306-
profits: vec![1.0],
307-
ending_round: RoundLabel::Preflop,
308-
});
289+
log.window_cache.push(GameLogEntry::new(
290+
1,
291+
vec!["A".into()],
292+
vec![1.0],
293+
RoundLabel::Preflop,
294+
10.0,
295+
));
309296
assert_eq!(log.window_cache.len(), 1);
310297
log.invalidate();
311298
assert!(log.window_cache.is_empty());

src/bin/rsp/tui/screens/overview.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use ratatui::{
55

66
use crate::tui::{
77
state::{AgentDisplayData, GameLogEntry, Panel, TuiState},
8+
theme,
89
widgets::{
910
filter_panel::render_filter_panel, game_log::render_game_log,
1011
profit_chart::render_profit_chart, progress_bar::render_progress,
@@ -101,13 +102,20 @@ pub fn render_overview(
101102
Layout::horizontal([Constraint::Percentage(70), Constraint::Percentage(30)])
102103
.split(main_chunks[1]);
103104

105+
let agent_colors: std::collections::HashMap<&str, ratatui::style::Color> = agents
106+
.iter()
107+
.enumerate()
108+
.map(|(i, a)| (a.name.as_str(), theme::agent_color(i)))
109+
.collect();
110+
104111
render_game_log(
105112
frame,
106113
bottom_chunks[0],
107114
game_log_entries,
108115
0,
109116
log_selected,
110117
log_focused,
118+
&agent_colors,
111119
);
112120

113121
let player_counts: Vec<usize> = state.distinct_player_counts.iter().copied().collect();

0 commit comments

Comments
 (0)