1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
mod bot;
mod game;
mod stats;
mod ui;
use std::io;
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use game::state::GamePhase;
use stats::persistence::StatsStore;
use ui::app::App;
#[derive(Parser, Debug)]
#[command(name = "terminal-poker")]
#[command(about = "A heads-up No-Limit Texas Hold'em training tool")]
#[command(version)]
struct Args {
/// Starting stack size in big blinds
#[arg(long, default_value = "100")]
stack: u32,
/// Bot aggression level (0.0 = passive, 1.0 = aggressive)
#[arg(long, default_value = "0.5")]
aggression: f64,
}
fn main() -> io::Result<()> {
let args = Args::parse();
// Set up panic hook to restore terminal state on panic
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
crossterm::cursor::Show
);
original_hook(panic_info);
}));
// Set up terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Load or create stats store
let mut stats_store = StatsStore::load_or_create();
// Create app state
let mut app = App::new(args.stack, args.aggression);
app.initialize(&mut stats_store);
// Main game loop
let result = run_game_loop(&mut terminal, &mut app, &mut stats_store);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Save stats on exit
stats_store.save();
result
}
fn run_game_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
stats_store: &mut StatsStore,
) -> io::Result<()> {
loop {
app.tick_count = app.tick_count.wrapping_add(1);
// Draw UI
terminal.draw(|f| ui::render::render(f, app))?;
// Process pending game events (timed)
app.process_next_event(stats_store);
// Handle input (50ms poll for responsive event processing)
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
// Ctrl+C quits from any phase
if key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('c')
{
// Record stats unless already recorded (Summary is entered
// after 'q' which already calls these)
if !matches!(app.game_state.phase, GamePhase::Summary) {
stats_store.record_session_end();
stats_store.record_profit(
(app.game_state.session_profit_bb() * 2.0).round() as i64,
);
}
break;
}
match app.game_state.phase {
GamePhase::Showdown => {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => {
stats_store.record_session_end();
stats_store.record_profit(
(app.game_state.session_profit_bb() * 2.0).round() as i64,
);
app.game_state.phase = GamePhase::Summary;
}
_ => {
app.continue_after_showdown(stats_store);
}
}
}
GamePhase::Summary | GamePhase::SessionEnd => match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => break,
KeyCode::Char('n') | KeyCode::Char('N') => {
if matches!(app.game_state.phase, GamePhase::SessionEnd) {
app.new_session(stats_store);
}
}
_ => {
if matches!(app.game_state.phase, GamePhase::Summary) {
break;
}
}
},
_ => {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => {
stats_store.record_session_end();
stats_store.record_profit(
(app.game_state.session_profit_bb() * 2.0).round() as i64,
);
app.game_state.phase = GamePhase::Summary;
}
KeyCode::Char('?') => {
app.toggle_help();
}
KeyCode::Char('s') | KeyCode::Char('S') => {
app.toggle_stats();
}
_ => {
// Block gameplay input while events are pending or overlays are open
if !app.has_pending_events() && !app.show_help && !app.show_stats {
if let Some(action) = ui::input::handle_key(
key,
&app.game_state,
&mut app.raise_input,
&mut app.raise_mode,
) {
app.apply_player_action(action, stats_store);
}
}
}
}
}
}
}
}
// Check for session end after a fold resolves (showdown path handled by continue_after_showdown)
if app.game_state.phase == GamePhase::HandComplete {
if app.game_state.player_stack == 0 || app.game_state.bot_stack == 0 {
app.game_state.phase = GamePhase::SessionEnd;
}
}
}
Ok(())
}