turnbase_simulator/ui.rs
1//! A fixed-layout terminal dashboard for [`Simulator`], built directly on
2//! `retroglyph-core`'s `App`/`Terminal` contract.
3//!
4//! There is no intermediate widget or layout-engine layer here: the screen is
5//! split into four rects by plain arithmetic (see [`Layout`]), and each panel
6//! is drawn with plain `Terminal::print` calls. A game opts in by
7//! implementing [`PrintableGame`] on its [`turnbase::Game`] and drawing its
8//! board into the rect [`SimulationRunner`] hands it.
9//!
10//! No panel clears its rect before drawing: `Terminal::present` already wipes
11//! the whole buffer after every frame (retroglyph's immediate-mode
12//! contract), so the surface each `update` call draws onto is already blank.
13
14use std::fmt::Debug;
15use std::time::Duration;
16
17use retroglyph_core::event::{Event, KeyCode};
18use retroglyph_core::grid::Rect;
19use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
20use retroglyph_widgets::Theme;
21use turnbase::{Game, PlayerId};
22use turnbase_match::Simulator;
23
24use crate::dashboard::{Layout, actions_panel, draw_board_stats_log, draw_menu, print_rows};
25
26/// A [`Game`] that knows how to render itself, for [`SimulationRunner`].
27///
28/// Every method takes `&self` (the rules) and an explicit `view`: what a
29/// player or spectator is allowed to see ([`Game::view`]), never the raw
30/// `Self::State`. Games with no hidden information can have `View` be a
31/// clone of `State` (as the perfect-information examples in this workspace
32/// already do); games with hidden hands or decks get that redaction for
33/// free, since [`SimulationRunner`] always renders from one fixed seat's
34/// perspective (see [`Simulator::primary_human`]) rather than the full
35/// state.
36pub trait PrintableGame: Game {
37 /// Draws the board (map, cards, whatever the game is) into `area`.
38 ///
39 /// Implementations should stay within `area`; the rest of the screen is
40 /// reserved for the dashboard.
41 fn draw_viewport<B: Backend>(&self, view: &Self::View, term: &mut Terminal<B>, area: Rect);
42
43 /// Key/value pairs summarizing `view` (scores, resources, hand sizes) for
44 /// the stats panel, in display order.
45 fn get_stats(&self, view: &Self::View) -> Vec<(String, String)>;
46
47 /// Renders `action` as one line of menu text for the action-select panel.
48 fn format_action(&self, action: &Self::Action) -> String;
49}
50
51/// Drives a [`Simulator`] behind a fixed dashboard.
52///
53/// The game's own viewport sits on the left, a stats panel and (when a human
54/// is up) an action-select menu stack on the right, and a scrolling log strip
55/// runs along the bottom.
56///
57/// Implements [`App`] for every [`Backend`], so the same runner drives a real
58/// terminal (`retroglyph-crossterm`) or an in-memory [`retroglyph_core::backend::Headless`]
59/// test session unchanged; [`run`] is the convenience entry point for the
60/// former. Escape ends the loop early regardless of whose turn it is; once
61/// the match reaches a terminal state the dashboard keeps rendering the
62/// final position (rather than exiting the instant it happens, which would
63/// hide it) and waits for Enter or Escape to close.
64pub struct SimulationRunner<G: PrintableGame> {
65 simulator: Simulator<G>,
66 selected: usize,
67 ai_tick: Duration,
68 ai_elapsed: Duration,
69 viewer: Option<PlayerId>,
70}
71
72impl<G: PrintableGame> SimulationRunner<G> {
73 /// Wraps `simulator`, polling an AI-controlled active seat for a move
74 /// once every `ai_tick` of wall-clock time (via [`Frame::delta`]) rather
75 /// than on every single frame, so AI turns are visible instead of
76 /// flashing past.
77 ///
78 /// Fixes the dashboard's viewing seat for the whole match at
79 /// [`Simulator::primary_human`] (or a neutral spectator if there is no
80 /// human seat), computed once here rather than every frame.
81 #[must_use]
82 pub fn new(simulator: Simulator<G>, ai_tick: Duration) -> Self {
83 let viewer = simulator.primary_human();
84 Self {
85 simulator,
86 selected: 0,
87 ai_tick,
88 ai_elapsed: Duration::ZERO,
89 viewer,
90 }
91 }
92
93 /// Returns whether the wrapped match has reached a terminal state.
94 ///
95 /// True while the dashboard is showing its "match over" prompt and
96 /// waiting for Enter/Escape, per [`SimulationRunner`]'s own docs.
97 #[must_use]
98 pub fn is_terminal(&self) -> bool {
99 self.simulator.is_terminal()
100 }
101
102 /// Unwraps the runner, returning the [`Simulator`] at its current state
103 /// (e.g. after the loop exits, to inspect the final position or log).
104 #[must_use]
105 pub fn into_simulator(self) -> Simulator<G> {
106 self.simulator
107 }
108}
109
110impl<G, B> App<B> for SimulationRunner<G>
111where
112 G: PrintableGame,
113 G::Action: Debug,
114 B: Backend,
115{
116 fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
117 let events: Vec<Event> = term.drain_events().collect();
118
119 if key_pressed(&events, KeyCode::Escape) {
120 return Flow::Exit;
121 }
122
123 let over = self.simulator.is_terminal();
124 if over {
125 if key_pressed(&events, KeyCode::Enter) {
126 return Flow::Exit;
127 }
128 } else {
129 match self.simulator.awaiting_human() {
130 Some(player) => self.handle_human_input(player, &events),
131 None => self.tick_ai(frame),
132 }
133 }
134
135 term.reset_style();
136 let layout = Layout::new(term.area());
137 let view = self
138 .simulator
139 .game()
140 .view(self.simulator.state(), self.viewer);
141
142 let theme = Theme::DARK;
143 // The auto-only runner never scrolls the log, so it always pins to the
144 // newest line (offset 0).
145 let _ = draw_board_stats_log(
146 self.simulator.game(),
147 &view,
148 self.simulator.log_history(),
149 term,
150 &layout,
151 theme,
152 0,
153 );
154
155 if over {
156 let inner = actions_panel(term, layout.actions, theme, None);
157 print_rows(
158 term,
159 inner,
160 0,
161 std::iter::once("match over -- Enter/Esc to exit".to_owned()),
162 );
163 } else if let Some(player) = self.simulator.awaiting_human() {
164 let actions = self
165 .simulator
166 .game()
167 .legal_actions(self.simulator.state(), player);
168 let game = self.simulator.game();
169 let labels: Vec<String> = actions.iter().map(|a| game.format_action(a)).collect();
170 let selected = self.selected.min(labels.len().saturating_sub(1));
171 let inner = actions_panel(
172 term,
173 layout.actions,
174 theme,
175 Some((selected + 1, labels.len())),
176 );
177 draw_menu(term, inner, 0, &labels, selected);
178 } else {
179 let inner = actions_panel(term, layout.actions, theme, None);
180 print_rows(
181 term,
182 inner,
183 0,
184 std::iter::once("(AI thinking...)".to_owned()),
185 );
186 }
187
188 let _ = term.present();
189
190 Flow::Continue
191 }
192}
193
194/// Returns `true` if `events` contains a press or auto-repeat of `code`.
195fn key_pressed(events: &[Event], code: KeyCode) -> bool {
196 events
197 .iter()
198 .any(|event| matches!(event, Event::Key(key) if key.is_down() && key.code == code))
199}
200
201impl<G> SimulationRunner<G>
202where
203 G: PrintableGame,
204 G::Action: Debug,
205{
206 /// Reads arrow keys to move the selection and Enter to commit it,
207 /// re-reading `legal_actions` fresh each time rather than caching the
208 /// list across frames, since a game's legal set can change out from
209 /// under a stale index (e.g. another simultaneous seat just resolved).
210 fn handle_human_input(&mut self, player: turnbase::PlayerId, events: &[Event]) {
211 let count = self
212 .simulator
213 .game()
214 .legal_actions(self.simulator.state(), player)
215 .len();
216 if count > 0 {
217 self.selected = self.selected.min(count - 1);
218 }
219
220 let mut confirm = false;
221 for event in events {
222 let Event::Key(key) = event else { continue };
223 if !key.is_down() {
224 continue;
225 }
226 match key.code {
227 KeyCode::Up if count > 0 => {
228 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
229 }
230 KeyCode::Down if count > 0 => {
231 self.selected = (self.selected + 1) % count;
232 }
233 KeyCode::Enter => confirm = true,
234 _ => {}
235 }
236 }
237
238 if confirm && count > 0 {
239 let mut actions = self
240 .simulator
241 .game()
242 .legal_actions(self.simulator.state(), player);
243 let action = actions.swap_remove(self.selected);
244 let _ = self.simulator.select_human_action(player, action);
245 self.selected = 0;
246 }
247 }
248
249 /// Advances the AI clock by one frame, stepping the simulator once
250 /// `ai_tick` has accumulated.
251 fn tick_ai(&mut self, frame: &Frame) {
252 self.ai_elapsed = self.ai_elapsed.saturating_add(frame.delta);
253 if self.ai_elapsed >= self.ai_tick {
254 self.ai_elapsed = Duration::ZERO;
255 let _ = self.simulator.step();
256 }
257 }
258}
259
260/// Runs `simulator` on a real terminal via `retroglyph-crossterm`, polling
261/// an AI-controlled seat every `ai_tick`, until the match ends or the
262/// terminal closes.
263///
264/// # Errors
265/// Returns an `std::io::Error` if the terminal backend fails to initialize.
266#[cfg(feature = "crossterm")]
267pub fn run<G>(simulator: Simulator<G>, ai_tick: Duration) -> std::io::Result<()>
268where
269 G: PrintableGame,
270 G::Action: Debug,
271{
272 retroglyph_crossterm::Crossterm::run(SimulationRunner::new(simulator, ai_tick))
273}