Skip to main content

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}