Skip to main content

turnbase_simulator/
session.rs

1//! [`SessionApp`]: the interactive dashboard over a [`Simulator`].
2//!
3//! A superset of [`SimulationRunner`](crate::SimulationRunner): the same
4//! viewport/stats/log dashboard, plus a setup modal (pick Human or an AI type
5//! per seat), Auto/Step run modes with an adjustable speed, a reset, and a
6//! human seat that plays through the action menu. One backend-generic
7//! [`App`], so the same session drives a real terminal
8//! (`retroglyph-crossterm`) and the in-browser demos (`retroglyph-terminal-wasm`)
9//! unchanged.
10
11use std::collections::HashMap;
12use std::fmt::Debug;
13use std::time::Duration;
14
15use retroglyph_core::event::{Event, KeyCode, MouseButton, MouseEventKind};
16use retroglyph_core::grid::{Pos, Rect};
17use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
18use retroglyph_widgets::{List, ListState, Modal, StatefulWidget, Theme, offset_for_pos};
19use turnbase::{Determinize, Game, PlayerId};
20use turnbase_bots::{Bot, Ismcts, Mcts, Random};
21use turnbase_match::{PlayerAgent, Simulator};
22
23use crate::PrintableGame;
24use crate::dashboard::{
25    Layout, actions_panel, draw_board_stats_log, draw_menu, log_geometry, menu_start, panel_inner,
26    print_rows,
27};
28
29/// Search budget for the [`Mcts`]/[`Ismcts`] bot options offered in the setup
30/// modal. Small, so a move computes within a frame and the demo stays
31/// responsive rather than hitching on a deep search.
32const SEARCH_ITERATIONS: u32 = 100;
33
34/// Auto-mode tick intervals, slowest first. The setup/status speed control
35/// indexes this; a higher index is faster (a shorter interval).
36const SPEEDS: [Duration; 5] = [
37    Duration::from_secs(1),
38    Duration::from_millis(600),
39    Duration::from_millis(380),
40    Duration::from_millis(220),
41    Duration::from_millis(110),
42];
43
44/// The default speed index into [`SPEEDS`].
45const DEFAULT_SPEED: usize = 2;
46
47/// Log lines one wheel notch (or one touch-drag step, in the browser demos)
48/// scrolls.
49const WHEEL_LINES: usize = 3;
50
51/// One selectable AI type for a seat.
52///
53/// Names an AI and how to build it for a seat seed. Carried by [`SessionApp`]
54/// so the setup modal can offer a per-game set of bots (see
55/// [`random_bot`]/[`mcts_bot`]/[`ismcts_bot`]).
56pub struct BotOption<G: Game> {
57    name: &'static str,
58    make: Box<dyn Fn(u64) -> Box<dyn Bot<G>>>,
59}
60
61impl<G: Game> BotOption<G> {
62    /// Names an AI type and how to build a fresh instance seeded from a seat
63    /// seed.
64    pub fn new(name: &'static str, make: impl Fn(u64) -> Box<dyn Bot<G>> + 'static) -> Self {
65        Self {
66            name,
67            make: Box::new(make),
68        }
69    }
70
71    /// The label shown in the setup modal.
72    #[must_use]
73    pub const fn name(&self) -> &'static str {
74        self.name
75    }
76}
77
78/// The uniform-random bot option, available for every game.
79#[must_use]
80pub fn random_bot<G: Game>() -> BotOption<G> {
81    BotOption::new("Random", |seed| Box::new(Random::new(seed)))
82}
83
84/// The MCTS bot option, for any game whose state and actions are [`Clone`].
85#[must_use]
86pub fn mcts_bot<G>() -> BotOption<G>
87where
88    G: Game,
89    G::State: Clone,
90    G::Action: Clone,
91{
92    BotOption::new("MCTS", |seed| Box::new(Mcts::new(SEARCH_ITERATIONS, seed)))
93}
94
95/// The information-set MCTS bot option, for a game that implements
96/// [`Determinize`] (so it can search under hidden information).
97#[must_use]
98pub fn ismcts_bot<G>() -> BotOption<G>
99where
100    G: Determinize,
101    G::State: Clone,
102    G::Action: Clone,
103{
104    BotOption::new("ISMCTS", |seed| {
105        Box::new(Ismcts::new(SEARCH_ITERATIONS, seed))
106    })
107}
108
109/// The bot set every `Clone`-stated game can offer: [`random_bot`] and
110/// [`mcts_bot`]. A game that also implements [`Determinize`] can push
111/// [`ismcts_bot`] on top.
112#[must_use]
113pub fn standard_bots<G>() -> Vec<BotOption<G>>
114where
115    G: Game,
116    G::State: Clone,
117    G::Action: Clone,
118{
119    vec![random_bot(), mcts_bot()]
120}
121
122/// Who controls a seat, as chosen in the setup modal: a human, or the AI type
123/// at this index into [`SessionApp`]'s bot options.
124#[derive(Clone, Copy)]
125enum SeatKind {
126    Human,
127    Ai(usize),
128}
129
130const fn kind_index(kind: SeatKind) -> usize {
131    match kind {
132        SeatKind::Human => 0,
133        SeatKind::Ai(index) => index + 1,
134    }
135}
136
137const fn index_kind(index: usize) -> SeatKind {
138    match index {
139        0 => SeatKind::Human,
140        other => SeatKind::Ai(other - 1),
141    }
142}
143
144/// Counts the human-controlled seats. Two or more means local pass-and-play,
145/// which gates each seat's reveal behind a device handoff.
146fn count_humans(seats: &[SeatKind]) -> usize {
147    seats
148        .iter()
149        .filter(|kind| matches!(kind, SeatKind::Human))
150        .count()
151}
152
153/// Chooses the dashboard's viewing seat for a freshly built match: with two or
154/// more human seats the viewer follows the acting seat (starting as a neutral
155/// spectator, gated by a handoff), otherwise it is fixed at the single human
156/// (or a spectator when every seat is AI).
157fn viewer_for<G: Game>(seats: &[SeatKind], sim: &Simulator<G>) -> Option<PlayerId> {
158    if count_humans(seats) >= 2 {
159        None
160    } else {
161        sim.primary_human()
162    }
163}
164
165/// Mixes a per-seat bot seed off the match seed, so two bot seats do not share
166/// a random stream.
167fn seat_seed(seed: u64, seat: u32) -> u64 {
168    seed ^ u64::from(seat)
169        .wrapping_add(1)
170        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
171}
172
173/// Builds a [`Simulator`] from a seat configuration, cloning the rules so the
174/// caller keeps its own copy for the next rebuild.
175fn build_sim<G>(game: &G, seats: &[SeatKind], bots: &[BotOption<G>], seed: u64) -> Simulator<G>
176where
177    G: Game + Clone,
178{
179    let mut agents = HashMap::new();
180    for (seat, kind) in seats.iter().enumerate() {
181        let index = u32::try_from(seat).expect("seat index fits in u32");
182        let id = PlayerId::new(index);
183        let agent = match *kind {
184            SeatKind::Human => PlayerAgent::Human,
185            SeatKind::Ai(bot) => PlayerAgent::Ai((bots[bot].make)(seat_seed(seed, index))),
186        };
187        agents.insert(id, agent);
188    }
189    Simulator::new(game.clone(), seed, agents)
190}
191
192/// Returns the key codes of the key-*down* events in `events`.
193fn down_keys(events: &[Event]) -> impl Iterator<Item = KeyCode> + '_ {
194    events.iter().filter_map(|event| match event {
195        Event::Key(key) if key.is_down() => Some(key.code),
196        _ => None,
197    })
198}
199
200/// Where the log strip is scrolled to, and what the last frame drew.
201///
202/// One struct rather than four loose fields on [`SessionApp`]: they are only
203/// ever read and written together (a scroll, an anchor, a drag), and the
204/// panel's own draw is the only thing that can fill `rows` in.
205#[derive(Clone, Copy, Debug)]
206struct LogScroll {
207    /// Lines back from the newest; 0 pins to the tail.
208    offset: usize,
209    /// Visible log rows as of the last draw, to size a page jump and clamp
210    /// [`offset`](Self::offset).
211    rows: usize,
212    /// History length last seen, to keep a scrolled-back view anchored to the
213    /// same lines as auto-play appends new ones.
214    len_seen: usize,
215    /// Whether the left button is held on the scrollbar, so pointer moves keep
216    /// dragging the thumb even once the pointer slides off the strip.
217    dragging: bool,
218}
219
220impl LogScroll {
221    /// Pinned to the newest line, with a one-row panel assumed until the first
222    /// draw reports the real height.
223    const fn new() -> Self {
224        Self {
225            offset: 0,
226            rows: 1,
227            len_seen: 0,
228            dragging: false,
229        }
230    }
231}
232
233/// What is currently in front of the dashboard. Mutually exclusive by
234/// construction, so only one modal-ish state can be open at a time.
235#[derive(Clone, Copy)]
236enum Overlay {
237    /// Nothing: the live match dashboard is in front.
238    None,
239    /// The seat-setup modal, carrying the cursor's seat row.
240    Setup(usize),
241    /// The controls help card.
242    Help,
243    /// A pending device handoff to the given seat, awaiting Enter to reveal.
244    Handoff(PlayerId),
245}
246
247/// An interactive session over a [`Simulator`]: the dashboard plus a setup
248/// modal, Auto/Step control, speed, reset, and a human-playable seat.
249///
250/// Fixes the dashboard's viewing seat at the lowest human seat (or a neutral
251/// spectator if there is none) each time the match is (re)built, so a human
252/// never sees another seat's hidden information. That single fixed viewer means
253/// a hidden-info game configured with two or more human seats is local
254/// pass-and-play that shows one seat's view throughout; it is not safe for
255/// competitive hot-seat play of a game with private state (the same limitation
256/// the CLI's text `play` documents).
257pub struct SessionApp<G>
258where
259    G: PrintableGame + Clone,
260    G::Action: Debug,
261{
262    game: G,
263    bots: Vec<BotOption<G>>,
264    seats: Vec<SeatKind>,
265    // The seat config as it was when the setup modal opened, restored if the
266    // modal is cancelled with Escape.
267    setup_backup: Vec<SeatKind>,
268    seed: u64,
269    auto: bool,
270    paused: bool,
271    speed: usize,
272    ai_elapsed: Duration,
273    sim: Simulator<G>,
274    viewer: Option<PlayerId>,
275    selected: usize,
276    log: LogScroll,
277    // The active overlay (setup modal, help card, or a pending device
278    // handoff), or None when the live dashboard is in front. One field, so the
279    // three stay mutually exclusive by construction.
280    overlay: Overlay,
281    exit: bool,
282}
283
284impl<G> SessionApp<G>
285where
286    G: PrintableGame + Clone,
287    G::Action: Debug,
288{
289    /// Builds a session for `game` with the given `bots` available as AI types
290    /// (at least [`random_bot`] is always included), every seat an AI, Auto
291    /// mode, seeded from `seed`.
292    #[must_use]
293    pub fn new(game: G, bots: Vec<BotOption<G>>, seed: u64) -> Self {
294        let mut bots = bots;
295        if bots.is_empty() {
296            bots.push(random_bot());
297        }
298        let seats = vec![SeatKind::Ai(0); game.num_players()];
299        let sim = build_sim(&game, &seats, &bots, seed);
300        let viewer = viewer_for(&seats, &sim);
301        Self {
302            game,
303            bots,
304            setup_backup: seats.clone(),
305            seats,
306            seed,
307            auto: true,
308            paused: false,
309            speed: DEFAULT_SPEED,
310            ai_elapsed: Duration::ZERO,
311            sim,
312            viewer,
313            selected: 0,
314            log: LogScroll::new(),
315            overlay: Overlay::None,
316            exit: false,
317        }
318    }
319
320    /// Marks `seat` human (it plays through the action menu). Out-of-range
321    /// seats are ignored.
322    #[must_use]
323    pub fn with_human_seat(mut self, seat: usize) -> Self {
324        if seat < self.seats.len() {
325            self.seats[seat] = SeatKind::Human;
326            self.rebuild();
327        }
328        self
329    }
330
331    /// Opens (or closes) the setup modal on start, so a native player
332    /// configures seats before the match runs.
333    #[must_use]
334    pub fn with_setup_open(mut self, open: bool) -> Self {
335        self.overlay = if open {
336            self.setup_backup = self.seats.clone();
337            Overlay::Setup(0)
338        } else {
339            Overlay::None
340        };
341        self
342    }
343
344    /// Starts in Step mode (advance one decision per Space) rather than Auto.
345    #[must_use]
346    pub const fn with_step_mode(mut self) -> Self {
347        self.auto = false;
348        self
349    }
350
351    /// Whether the match has ended and the setup modal is closed (so a demo
352    /// harness can decide when to restart).
353    #[must_use]
354    pub fn is_terminal(&self) -> bool {
355        matches!(self.overlay, Overlay::None) && self.sim.is_terminal()
356    }
357
358    /// Unwraps to the underlying [`Simulator`] at its current state.
359    #[must_use]
360    pub fn into_simulator(self) -> Simulator<G> {
361        self.sim
362    }
363
364    /// Rebuilds the match from the current seats and seed (keeping the seed),
365    /// e.g. after the setup modal is confirmed.
366    fn rebuild(&mut self) {
367        self.sim = build_sim(&self.game, &self.seats, &self.bots, self.seed);
368        self.viewer = viewer_for(&self.seats, &self.sim);
369        // A rebuild invalidates a pending handoff, but must preserve an open
370        // setup modal (with_human_seat rebuilds while the modal is still up).
371        if matches!(self.overlay, Overlay::Handoff(_)) {
372            self.overlay = Overlay::None;
373        }
374        self.selected = 0;
375        self.ai_elapsed = Duration::ZERO;
376        self.paused = false;
377        self.log.offset = 0;
378        self.log.len_seen = 0;
379    }
380
381    /// Restarts with a fresh seed, keeping the seat configuration.
382    fn reset(&mut self) {
383        self.seed = self.seed.wrapping_add(1);
384        self.rebuild();
385    }
386
387    /// The AI type label for a seat kind (Human, or the bot's name).
388    fn kind_label(&self, kind: SeatKind) -> &'static str {
389        match kind {
390            SeatKind::Human => "Human",
391            SeatKind::Ai(index) => self.bots[index].name(),
392        }
393    }
394
395    fn handle_controls(&mut self, events: &[Event]) {
396        for key in down_keys(events) {
397            match key {
398                KeyCode::Escape => self.exit = true,
399                KeyCode::Char('c' | 'C') => {
400                    self.setup_backup = self.seats.clone();
401                    self.overlay = Overlay::Setup(0);
402                }
403                KeyCode::Char('r' | 'R') => self.reset(),
404                KeyCode::Char('m' | 'M') | KeyCode::Tab => {
405                    self.auto = !self.auto;
406                    self.paused = false;
407                    self.ai_elapsed = Duration::ZERO;
408                }
409                KeyCode::Char('p' | 'P') => {
410                    if self.auto {
411                        self.paused = !self.paused;
412                    }
413                }
414                KeyCode::Char('+' | '=') => self.speed = (self.speed + 1).min(SPEEDS.len() - 1),
415                KeyCode::Char('-' | '_') => self.speed = self.speed.saturating_sub(1),
416                KeyCode::Char('?' | 'h' | 'H') => self.overlay = Overlay::Help,
417                KeyCode::Char(' ') => self.step_once(),
418                KeyCode::PageUp => self.scroll_log_back(self.log.rows.max(1)),
419                KeyCode::PageDown => self.scroll_log_forward(self.log.rows.max(1)),
420                KeyCode::Home => self.scroll_log_back(usize::MAX),
421                KeyCode::End => self.log.offset = 0,
422                _ => {}
423            }
424        }
425    }
426
427    /// Routes pointer input over the dashboard: the wheel (or a trackpad/touch
428    /// scroll, which arrives as wheel events) scrolls the log, the scrollbar on
429    /// its right edge is click-to-jump and drag-to-scroll, and a click in the
430    /// actions panel picks a move.
431    ///
432    /// `layout` is the current frame's, so the rects hit-tested here are the
433    /// ones the last frame drew. A drag that started on the strip keeps
434    /// tracking the pointer's row even after it slides off (the usual
435    /// scrollbar behavior), and only a button release ends it.
436    fn handle_mouse(&mut self, layout: &Layout, events: &[Event]) {
437        let total = self.sim.log_history().len();
438        let geometry = log_geometry(layout.log, total);
439        for event in events {
440            let Event::Mouse(mouse) = event else { continue };
441            let over_log = layout.log.contains_pos(mouse.position);
442            match mouse.kind {
443                MouseEventKind::ScrollUp if over_log => self.scroll_log_back(WHEEL_LINES),
444                MouseEventKind::ScrollDown if over_log => self.scroll_log_forward(WHEEL_LINES),
445                MouseEventKind::Down(MouseButton::Left) => {
446                    if let Some(bar) = geometry.bar
447                        && bar.contains_pos(mouse.position)
448                    {
449                        self.log.dragging = true;
450                        self.drag_log_to(bar, total, geometry.visible, mouse.position);
451                    } else {
452                        self.click_action(layout, mouse.position);
453                    }
454                }
455                MouseEventKind::Moved if self.log.dragging => {
456                    if let Some(bar) = geometry.bar {
457                        self.drag_log_to(bar, total, geometry.visible, mouse.position);
458                    }
459                }
460                MouseEventKind::Up(MouseButton::Left) => self.log.dragging = false,
461                _ => {}
462            }
463        }
464    }
465
466    /// Picks the action a click at `pos` in the actions panel landed on.
467    ///
468    /// Click to select, click the selected row again to play it, mirroring
469    /// Up/Down then Enter. Two clicks rather than one so a mis-tap on a touch
470    /// screen (where the demos have no keyboard at all) does not silently
471    /// commit somebody's turn.
472    fn click_action(&mut self, layout: &Layout, pos: Pos) {
473        let Some(player) = self.sim.awaiting_human() else {
474            return;
475        };
476        let inner = panel_inner(layout.actions);
477        if !inner.contains_pos(pos) {
478            return;
479        }
480        let mut actions = self.sim.game().legal_actions(self.sim.state(), player);
481        let capacity = usize::from(inner.height());
482        let selected = self.selected.min(actions.len().saturating_sub(1));
483        let row = usize::from(pos.y.saturating_sub(inner.top()));
484        let index = menu_start(capacity, actions.len(), selected) + row;
485        if index >= actions.len() {
486            return;
487        }
488        if index == selected {
489            let action = actions.swap_remove(index);
490            let _ = self.sim.select_human_action(player, action);
491            self.selected = 0;
492        } else {
493            self.selected = index;
494        }
495    }
496
497    /// Scrolls the log so the scrollbar thumb follows a click or drag at `pos`
498    /// on the `bar` track.
499    ///
500    /// `pos` is clamped into the track first, so a drag that wanders off the
501    /// strip pins to its top or bottom rather than being dropped.
502    fn drag_log_to(&mut self, bar: Rect, total: usize, visible: usize, pos: Pos) {
503        let clamped = Pos::new(
504            bar.left(),
505            pos.y
506                .clamp(bar.top(), bar.bottom().saturating_sub(1).max(bar.top())),
507        );
508        let Some(start) = offset_for_pos(bar, total, visible, clamped) else {
509            return;
510        };
511        // The bar speaks in lines from the top; the dashboard tracks lines back
512        // from the newest, so invert against the same maximum draw_log uses.
513        let max_back = total.saturating_sub(visible);
514        self.log.offset = max_back.saturating_sub(start);
515    }
516
517    /// The furthest the log can scroll back: enough to bring its oldest line to
518    /// the top of the panel, and no further.
519    fn max_log_scroll(&self) -> usize {
520        self.sim
521            .log_history()
522            .len()
523            .saturating_sub(self.log.rows.max(1))
524    }
525
526    /// Scrolls the log `lines` further into the past, clamped at the oldest.
527    fn scroll_log_back(&mut self, lines: usize) {
528        self.log.offset = self
529            .log
530            .offset
531            .saturating_add(lines)
532            .min(self.max_log_scroll());
533    }
534
535    /// Scrolls the log `lines` back toward the newest line.
536    const fn scroll_log_forward(&mut self, lines: usize) {
537        self.log.offset = self.log.offset.saturating_sub(lines);
538    }
539
540    /// Keeps a scrolled-back log view pinned to the same lines as new entries
541    /// arrive during auto-play, then clamps the offset to what currently fits.
542    /// Run once per frame before drawing.
543    fn anchor_log(&mut self) {
544        let len = self.sim.log_history().len();
545        if self.log.offset > 0 {
546            let grown = len.saturating_sub(self.log.len_seen);
547            self.log.offset = self.log.offset.saturating_add(grown);
548        }
549        self.log.len_seen = len;
550        self.log.offset = self.log.offset.min(self.max_log_scroll());
551    }
552
553    /// Advances one decision if the active seat is a bot or chance node (never
554    /// for a human seat, which acts through the menu). The only way to progress
555    /// in Step mode, and a manual nudge in Auto.
556    fn step_once(&mut self) {
557        if !self.sim.is_terminal() && self.sim.awaiting_human().is_none() {
558            let _ = self.sim.step();
559            self.ai_elapsed = Duration::ZERO;
560        }
561    }
562
563    fn tick_ai(&mut self, frame: &Frame) {
564        self.ai_elapsed = self.ai_elapsed.saturating_add(frame.delta);
565        if self.ai_elapsed >= SPEEDS[self.speed] {
566            self.ai_elapsed = Duration::ZERO;
567            let _ = self.sim.step();
568        }
569    }
570
571    fn handle_action_menu(&mut self, player: PlayerId, events: &[Event]) {
572        let count = self
573            .sim
574            .game()
575            .legal_actions(self.sim.state(), player)
576            .len();
577        if count > 0 {
578            self.selected = self.selected.min(count - 1);
579        }
580
581        let mut confirm = false;
582        for key in down_keys(events) {
583            match key {
584                KeyCode::Up if count > 0 => {
585                    self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
586                }
587                KeyCode::Down if count > 0 => self.selected = (self.selected + 1) % count,
588                KeyCode::Enter => confirm = true,
589                _ => {}
590            }
591        }
592
593        if confirm && count > 0 {
594            let mut actions = self.sim.game().legal_actions(self.sim.state(), player);
595            let action = actions.swap_remove(self.selected);
596            let _ = self.sim.select_human_action(player, action);
597            self.selected = 0;
598        }
599    }
600
601    fn handle_setup(&mut self, cursor: usize, events: &[Event]) {
602        let seats = self.seats.len();
603        let kinds = self.bots.len() + 1;
604        let mut cursor = cursor.min(seats.saturating_sub(1));
605        for key in down_keys(events) {
606            match key {
607                KeyCode::Up if seats > 0 => {
608                    cursor = cursor.checked_sub(1).unwrap_or(seats - 1);
609                }
610                KeyCode::Down if seats > 0 => cursor = (cursor + 1) % seats,
611                KeyCode::Left => {
612                    let next = (kind_index(self.seats[cursor]) + kinds - 1) % kinds;
613                    self.seats[cursor] = index_kind(next);
614                }
615                KeyCode::Right => {
616                    let next = (kind_index(self.seats[cursor]) + 1) % kinds;
617                    self.seats[cursor] = index_kind(next);
618                }
619                KeyCode::Enter => {
620                    self.overlay = Overlay::None;
621                    self.rebuild();
622                    return;
623                }
624                KeyCode::Escape => {
625                    // Cancel: discard the edits made in the modal.
626                    self.seats.clone_from(&self.setup_backup);
627                    self.overlay = Overlay::None;
628                    return;
629                }
630                _ => {}
631            }
632        }
633        self.overlay = Overlay::Setup(cursor);
634    }
635
636    /// Whether committing `player`'s turn to the action menu would reveal one
637    /// human's private view to another. True only in 2+ human pass-and-play
638    /// when the board is not already showing `player`'s seat.
639    fn reveal_gated(&self, player: PlayerId) -> bool {
640        count_humans(&self.seats) >= 2 && self.viewer != Some(player)
641    }
642
643    /// While a handoff is pending, wait for Enter to reveal the new seat (or
644    /// Esc to quit); every other key is swallowed so nothing leaks early.
645    fn handle_handoff(&mut self, seat: PlayerId, events: &[Event]) {
646        for key in down_keys(events) {
647            match key {
648                KeyCode::Enter => {
649                    self.viewer = Some(seat);
650                    self.overlay = Overlay::None;
651                    return;
652                }
653                KeyCode::Escape => {
654                    self.exit = true;
655                    return;
656                }
657                _ => {}
658            }
659        }
660    }
661
662    /// While the help overlay is up, any of `?`/`h`/Esc closes it; the match
663    /// stays frozen until then.
664    fn handle_help(&mut self, events: &[Event]) {
665        for key in down_keys(events) {
666            if matches!(key, KeyCode::Char('?' | 'h' | 'H') | KeyCode::Escape) {
667                self.overlay = Overlay::None;
668                return;
669            }
670        }
671    }
672
673    /// The status-bar summary of whose turn it is: the human (you), the named
674    /// AI controlling the active seat, a chance node, or the finished match.
675    fn turn_label(&self) -> String {
676        if self.sim.is_terminal() {
677            return "over".to_owned();
678        }
679        if let Some(player) = self.sim.awaiting_human() {
680            return format!("P{} (you)", player.index());
681        }
682        match self
683            .sim
684            .game()
685            .active_players(self.sim.state())
686            .iter()
687            .next()
688        {
689            Some(player) if player.is_chance() => "chance".to_owned(),
690            Some(player) => {
691                let name = usize::try_from(player.index())
692                    .ok()
693                    .and_then(|seat| self.seats.get(seat))
694                    .map_or("AI", |kind| self.kind_label(*kind));
695                format!("P{} ({name})", player.index())
696            }
697            None => "over".to_owned(),
698        }
699    }
700
701    /// Draws one frame and returns the number of visible log rows (so the
702    /// caller can clamp scrolling and size a page jump).
703    fn draw<B: Backend>(&self, term: &mut Terminal<B>) -> usize {
704        term.reset_style();
705        let theme = Theme::DARK;
706        let layout = Layout::new(term.area());
707        let log_rows = if let Overlay::Handoff(seat) = self.overlay {
708            // Cover the board so the previous seat's private view is hidden
709            // while the device changes hands.
710            Self::draw_handoff(term, seat);
711            self.log.rows
712        } else {
713            let view = self.sim.game().view(self.sim.state(), self.viewer);
714            let rows = draw_board_stats_log(
715                self.sim.game(),
716                &view,
717                self.sim.log_history(),
718                term,
719                &layout,
720                theme,
721                self.log.offset,
722            );
723            self.draw_actions(term, &layout);
724            rows
725        };
726        self.draw_status(term, &layout);
727        match self.overlay {
728            Overlay::Setup(cursor) => self.draw_setup(term, cursor),
729            Overlay::Help => Self::draw_help(term),
730            Overlay::None | Overlay::Handoff(_) => {}
731        }
732        log_rows
733    }
734
735    fn draw_actions<B: Backend>(&self, term: &mut Terminal<B>, layout: &Layout) {
736        let theme = Theme::DARK;
737        if self.sim.is_terminal() {
738            let inner = actions_panel(term, layout.actions, theme, None);
739            print_rows(
740                term,
741                inner,
742                0,
743                std::iter::once("match over -- r restart, c config".to_owned()),
744            );
745        } else if let Some(player) = self.sim.awaiting_human() {
746            let actions = self.sim.game().legal_actions(self.sim.state(), player);
747            let game = self.sim.game();
748            let labels: Vec<String> = actions.iter().map(|a| game.format_action(a)).collect();
749            let selected = self.selected.min(labels.len().saturating_sub(1));
750            // Position/total in the panel title, so a scrolled menu still tells
751            // you how many actions there are.
752            let inner = actions_panel(
753                term,
754                layout.actions,
755                theme,
756                Some((selected + 1, labels.len())),
757            );
758            draw_menu(term, inner, 0, &labels, selected);
759        } else {
760            let inner = actions_panel(term, layout.actions, theme, None);
761            let label = if !self.auto {
762                "(step: press Space)"
763            } else if self.paused {
764                "(paused: p to resume)"
765            } else {
766                "(AI thinking...)"
767            };
768            print_rows(term, inner, 0, std::iter::once(label.to_owned()));
769        }
770    }
771
772    fn draw_status<B: Backend>(&self, term: &mut Terminal<B>, layout: &Layout) {
773        let mode = if self.auto {
774            if self.paused { "Auto(paused)" } else { "Auto" }
775        } else {
776            "Step"
777        };
778        let speed = if self.auto {
779            format!("  speed {}/{}", self.speed + 1, SPEEDS.len())
780        } else {
781            String::new()
782        };
783        let turn = self.turn_label();
784        let text =
785            format!(" {mode}{speed}  |  turn: {turn}  |  c config  r reset  ? help  Esc quit");
786
787        let width = usize::from(layout.status.width());
788        let mut bar: String = text.chars().take(width).collect();
789        while bar.chars().count() < width {
790            bar.push(' ');
791        }
792        let theme = Theme::DARK;
793        term.reset_style().fg(theme.fg).bg(theme.title_bg);
794        term.print(layout.status.left(), layout.status.top(), &bar);
795        term.reset_style();
796    }
797
798    fn draw_setup<B: Backend>(&self, term: &mut Terminal<B>, cursor: usize) {
799        let theme = Theme::DARK;
800        let seats = self.seats.len();
801        #[expect(clippy::cast_possible_truncation, reason = "seat counts are tiny")]
802        let rows = seats as u16;
803        let width = 44;
804        let height = rows.saturating_add(5);
805        let inner = Modal::new(width, height)
806            .theme(theme)
807            .title("Session Setup")
808            .render(term.area(), term);
809
810        let items: Vec<String> = self
811            .seats
812            .iter()
813            .enumerate()
814            .map(|(seat, kind)| format!("Seat {seat}    {}", self.kind_label(*kind)))
815            .collect();
816        let refs: Vec<&str> = items.iter().map(String::as_str).collect();
817        let list_rect = Rect::new(
818            inner.left(),
819            inner.top(),
820            inner.width(),
821            rows.min(inner.height()),
822        );
823        let mut state = ListState::new();
824        state.select(Some(cursor));
825        List::new(&refs)
826            .theme(theme)
827            .render(list_rect, term, &mut state);
828
829        let hint_rect = Rect::new(
830            inner.left(),
831            inner.bottom().saturating_sub(1),
832            inner.width(),
833            1,
834        );
835        term.reset_style().fg(theme.dim);
836        print_rows(
837            term,
838            hint_rect,
839            0,
840            std::iter::once("arrows pick   Enter start   Esc cancel".to_owned()),
841        );
842        term.reset_style();
843    }
844
845    fn draw_handoff<B: Backend>(term: &mut Terminal<B>, seat: PlayerId) {
846        let theme = Theme::DARK;
847        let inner = Modal::new(42, 6)
848            .theme(theme)
849            .title("Pass the device")
850            .render(term.area(), term);
851        term.reset_style().fg(theme.fg);
852        print_rows(
853            term,
854            inner,
855            0,
856            [
857                format!("Hand the screen to P{}.", seat.index()),
858                String::new(),
859                "Press Enter when they are ready.".to_owned(),
860            ],
861        );
862        term.reset_style();
863    }
864
865    fn draw_help<B: Backend>(term: &mut Terminal<B>) {
866        let theme = Theme::DARK;
867        let lines = [
868            "Space     step one decision",
869            "m/Tab     Auto <-> Step",
870            "p         pause / resume (Auto)",
871            "+ / -     speed",
872            "PgUp/PgDn scroll the log (or the wheel)",
873            "Home/End  log oldest / newest",
874            "c         configure seats",
875            "r         restart (new seed)",
876            "?         toggle this help",
877            "Esc       quit",
878            "",
879            "Your turn: Up/Down select, Enter play",
880            "           or click a row, again to play",
881        ];
882        #[expect(clippy::cast_possible_truncation, reason = "line count is tiny")]
883        let height = lines.len() as u16 + 4;
884        let inner = Modal::new(44, height)
885            .theme(theme)
886            .title("Controls")
887            .render(term.area(), term);
888        term.reset_style().fg(theme.fg);
889        print_rows(term, inner, 0, lines.iter().map(|line| (*line).to_owned()));
890        term.reset_style();
891    }
892}
893
894impl<G, B> App<B> for SessionApp<G>
895where
896    G: PrintableGame + Clone,
897    G::Action: Debug,
898    B: Backend,
899{
900    fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
901        let events: Vec<Event> = term.drain_events().collect();
902        // The same rects the previous frame drew (the layout is a pure
903        // function of the terminal size), so pointer input hit-tests against
904        // what the viewer is actually looking at.
905        let layout = Layout::new(term.area());
906
907        match self.overlay {
908            // An overlay covers the log, so any drag in progress is over: the
909            // release that would end it goes to the overlay, not the strip.
910            Overlay::Setup(cursor) => {
911                self.log.dragging = false;
912                self.handle_setup(cursor, &events);
913            }
914            Overlay::Help => {
915                self.log.dragging = false;
916                self.handle_help(&events);
917            }
918            Overlay::Handoff(seat) => {
919                self.log.dragging = false;
920                self.handle_handoff(seat, &events);
921            }
922            Overlay::None => {
923                self.handle_controls(&events);
924                self.handle_mouse(&layout, &events);
925                // handle_controls may have opened an overlay or set exit; only
926                // touch the match if the live dashboard is still in front.
927                if !self.exit && matches!(self.overlay, Overlay::None) && !self.sim.is_terminal() {
928                    match self.sim.awaiting_human() {
929                        Some(player) if self.reveal_gated(player) => {
930                            // Blank the screen and wait for the device to change
931                            // hands before revealing this seat's view.
932                            self.overlay = Overlay::Handoff(player);
933                        }
934                        Some(player) => self.handle_action_menu(player, &events),
935                        None => {
936                            if self.auto && !self.paused {
937                                self.tick_ai(frame);
938                            }
939                        }
940                    }
941                }
942            }
943        }
944
945        // Keep a scrolled-back log anchored as new lines arrive, then draw and
946        // record how many log rows were visible for the next frame's clamping.
947        self.anchor_log();
948        self.log.rows = self.draw(term).max(1);
949        let _ = term.present();
950
951        if self.exit {
952            Flow::Exit
953        } else {
954            Flow::Continue
955        }
956    }
957}
958
959/// Runs `app` on a real terminal via `retroglyph-crossterm` until it quits.
960///
961/// # Errors
962/// Returns an `std::io::Error` if the terminal backend fails to initialize.
963#[cfg(feature = "crossterm")]
964pub fn run_session<G>(app: SessionApp<G>) -> std::io::Result<()>
965where
966    G: PrintableGame + Clone,
967    G::Action: Debug,
968{
969    retroglyph_crossterm::Crossterm::run(app)
970}
971
972#[cfg(test)]
973mod tests {
974    use std::time::Duration;
975
976    use retroglyph_core::backend::Headless;
977    use retroglyph_core::event::{
978        Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
979    };
980    use retroglyph_core::grid::{Pos, Rect};
981    use retroglyph_core::{Backend, Flow, Frame, Terminal, step};
982    use turnbase::{ActivePlayers, Game, PlayerId};
983
984    use super::{SeatKind, SessionApp, build_sim, count_humans, standard_bots, viewer_for};
985    use crate::PrintableGame;
986    use crate::dashboard::{Layout, log_geometry, log_start, panel_inner};
987
988    /// A two-seat game that takes one action per seat then ends. Enough state
989    /// to exercise seat scheduling, human turns, and rebuilds; the view is the
990    /// public move count (no hidden info needed for these tests).
991    #[derive(Clone)]
992    struct TwoSeat;
993
994    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
995    struct Play;
996
997    impl Game for TwoSeat {
998        type State = u32;
999        type Action = Play;
1000        type View = u32;
1001
1002        fn new_initial_state(&self, _seed: u64) -> u32 {
1003            0
1004        }
1005        fn num_players(&self) -> usize {
1006            2
1007        }
1008        fn active_players(&self, state: &u32) -> ActivePlayers {
1009            if *state >= 2 {
1010                ActivePlayers::none()
1011            } else {
1012                ActivePlayers::one(PlayerId::new(*state % 2))
1013            }
1014        }
1015        fn legal_actions(&self, state: &u32, _player: PlayerId) -> Vec<Play> {
1016            if *state >= 2 { Vec::new() } else { vec![Play] }
1017        }
1018        fn apply(&self, state: &mut u32, _player: PlayerId, _action: Play) {
1019            *state += 1;
1020        }
1021        fn is_terminal(&self, state: &u32) -> bool {
1022            *state >= 2
1023        }
1024        fn reward(&self, _state: &u32, _player: PlayerId) -> f64 {
1025            0.0
1026        }
1027        fn view(&self, state: &u32, _viewer: Option<PlayerId>) -> u32 {
1028            *state
1029        }
1030    }
1031
1032    impl PrintableGame for TwoSeat {
1033        fn draw_viewport<B: Backend>(&self, _view: &u32, _term: &mut Terminal<B>, _area: Rect) {}
1034        fn get_stats(&self, _view: &u32) -> Vec<(String, String)> {
1035            Vec::new()
1036        }
1037        fn format_action(&self, _action: &Play) -> String {
1038            "play".to_owned()
1039        }
1040    }
1041
1042    /// Drives `app` for up to `frames` frames on a headless terminal, pressing
1043    /// Enter each frame when `enter` is set, and reports whether the match
1044    /// finished.
1045    fn drive(mut app: SessionApp<TwoSeat>, enter: bool, frames: u64) -> bool {
1046        let mut term = Terminal::new(Headless::new(60, 20));
1047        for frame in 0..frames {
1048            if enter {
1049                term.backend_mut().push_event(Event::Key(KeyEvent::new(
1050                    KeyCode::Enter,
1051                    KeyModifiers::NONE,
1052                )));
1053            }
1054            let ctx = Frame {
1055                delta: Duration::from_millis(250),
1056                frame,
1057            };
1058            if step(&mut term, &mut app, &ctx) == Flow::Exit {
1059                break;
1060            }
1061            if app.is_terminal() {
1062                return true;
1063            }
1064        }
1065        app.is_terminal()
1066    }
1067
1068    /// A one-seat game that takes `MANY_MOVES` turns, so its log outgrows any
1069    /// log panel and the scroll paths have something to scroll.
1070    #[derive(Clone)]
1071    struct LongGame;
1072
1073    const MANY_MOVES: u32 = 200;
1074
1075    impl Game for LongGame {
1076        type State = u32;
1077        type Action = Play;
1078        type View = u32;
1079
1080        fn new_initial_state(&self, _seed: u64) -> u32 {
1081            0
1082        }
1083        fn num_players(&self) -> usize {
1084            1
1085        }
1086        fn active_players(&self, state: &u32) -> ActivePlayers {
1087            if *state >= MANY_MOVES {
1088                ActivePlayers::none()
1089            } else {
1090                ActivePlayers::one(PlayerId::new(0))
1091            }
1092        }
1093        fn legal_actions(&self, state: &u32, _player: PlayerId) -> Vec<Play> {
1094            if *state >= MANY_MOVES {
1095                Vec::new()
1096            } else {
1097                vec![Play]
1098            }
1099        }
1100        fn apply(&self, state: &mut u32, _player: PlayerId, _action: Play) {
1101            *state += 1;
1102        }
1103        fn is_terminal(&self, state: &u32) -> bool {
1104            *state >= MANY_MOVES
1105        }
1106        fn reward(&self, _state: &u32, _player: PlayerId) -> f64 {
1107            0.0
1108        }
1109        fn view(&self, state: &u32, _viewer: Option<PlayerId>) -> u32 {
1110            *state
1111        }
1112    }
1113
1114    impl PrintableGame for LongGame {
1115        fn draw_viewport<B: Backend>(&self, _view: &u32, _term: &mut Terminal<B>, _area: Rect) {}
1116        fn get_stats(&self, _view: &u32) -> Vec<(String, String)> {
1117            Vec::new()
1118        }
1119        fn format_action(&self, _action: &Play) -> String {
1120            "play".to_owned()
1121        }
1122    }
1123
1124    const TEST_COLS: u16 = 60;
1125    const TEST_ROWS: u16 = 20;
1126
1127    /// A session on a filled-up log, plus the terminal it was drawn on, ready
1128    /// for the pointer tests to poke at the log strip.
1129    fn scrolled_session() -> (SessionApp<LongGame>, Terminal<Headless>) {
1130        let mut app = SessionApp::new(LongGame, standard_bots(), 7);
1131        let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
1132        // Auto mode steps once per SPEEDS[speed]; a generous delta per frame
1133        // fills the log in a handful of frames.
1134        for frame in 0..40 {
1135            let ctx = Frame {
1136                delta: Duration::from_secs(1),
1137                frame,
1138            };
1139            let _ = step(&mut term, &mut app, &ctx);
1140        }
1141        assert!(
1142            app.sim.log_history().len() > app.log.rows,
1143            "the log must outgrow the panel for the scroll tests to mean anything"
1144        );
1145        (app, term)
1146    }
1147
1148    /// Feeds `app` one mouse event and runs a frame.
1149    fn pointer(
1150        app: &mut SessionApp<LongGame>,
1151        term: &mut Terminal<Headless>,
1152        kind: MouseEventKind,
1153        position: Pos,
1154    ) {
1155        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1156            kind,
1157            position,
1158            pixel_position: None,
1159            modifiers: KeyModifiers::NONE,
1160        }));
1161        let ctx = Frame {
1162            delta: Duration::ZERO,
1163            frame: 0,
1164        };
1165        let _ = step(term, app, &ctx);
1166    }
1167
1168    #[test]
1169    fn the_wheel_scrolls_the_log_only_over_the_log() {
1170        let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
1171        let inside = Pos::new(layout.log.left() + 2, layout.log.top() + 1);
1172        let elsewhere = Pos::new(layout.viewport.left() + 2, layout.viewport.top() + 1);
1173
1174        let (mut app, mut term) = scrolled_session();
1175        pointer(&mut app, &mut term, MouseEventKind::ScrollUp, inside);
1176        assert_eq!(
1177            app.log.offset,
1178            super::WHEEL_LINES,
1179            "a wheel notch over the log should scroll it back"
1180        );
1181        pointer(&mut app, &mut term, MouseEventKind::ScrollDown, inside);
1182        assert_eq!(app.log.offset, 0, "scrolling forward returns to the tail");
1183
1184        pointer(&mut app, &mut term, MouseEventKind::ScrollUp, elsewhere);
1185        assert_eq!(
1186            app.log.offset, 0,
1187            "the wheel elsewhere on the dashboard must not scroll the log"
1188        );
1189    }
1190
1191    #[test]
1192    fn dragging_the_scrollbar_moves_through_the_log() {
1193        let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
1194        let (mut app, mut term) = scrolled_session();
1195        let total = app.sim.log_history().len();
1196        let geometry = log_geometry(layout.log, total);
1197        let bar = geometry.bar.expect("a filled log should show a scrollbar");
1198        let max_back = total.saturating_sub(geometry.visible);
1199
1200        // Grabbing the top of the track jumps to the oldest line...
1201        pointer(
1202            &mut app,
1203            &mut term,
1204            MouseEventKind::Down(MouseButton::Left),
1205            Pos::new(bar.left(), bar.top()),
1206        );
1207        assert_eq!(app.log.offset, max_back, "the top of the track is oldest");
1208        assert_eq!(
1209            log_start(total, geometry.visible, app.log.offset),
1210            0,
1211            "which is line 0 in the panel's own coordinates"
1212        );
1213
1214        // ...and dragging past the bottom of the track pins to the newest,
1215        // even though the pointer has left the strip.
1216        pointer(
1217            &mut app,
1218            &mut term,
1219            MouseEventKind::Moved,
1220            Pos::new(bar.left(), TEST_ROWS - 1),
1221        );
1222        assert_eq!(app.log.offset, 0, "the bottom of the track is newest");
1223
1224        // Releasing ends the drag: later moves are just hovering.
1225        pointer(
1226            &mut app,
1227            &mut term,
1228            MouseEventKind::Up(MouseButton::Left),
1229            Pos::new(bar.left(), bar.bottom() - 1),
1230        );
1231        pointer(
1232            &mut app,
1233            &mut term,
1234            MouseEventKind::Moved,
1235            Pos::new(bar.left(), bar.top()),
1236        );
1237        assert_eq!(
1238            app.log.offset, 0,
1239            "a hover after the release scrolls nothing"
1240        );
1241    }
1242
1243    #[test]
1244    fn clicking_an_action_row_selects_then_plays_it() {
1245        let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
1246        let actions = panel_inner(layout.actions);
1247        // A human seat, so the actions panel holds a menu rather than a status
1248        // line; TwoSeat offers exactly one action per turn.
1249        let mut app = SessionApp::new(TwoSeat, standard_bots(), 11).with_human_seat(0);
1250        let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
1251        let ctx = Frame {
1252            delta: Duration::ZERO,
1253            frame: 0,
1254        };
1255        let _ = step(&mut term, &mut app, &ctx);
1256        assert_eq!(app.sim.state(), &0, "nothing has been played yet");
1257
1258        let row = Pos::new(actions.left() + 2, actions.top());
1259        let click = |app: &mut SessionApp<TwoSeat>, term: &mut Terminal<Headless>| {
1260            term.backend_mut().push_event(Event::Mouse(MouseEvent {
1261                kind: MouseEventKind::Down(MouseButton::Left),
1262                position: row,
1263                pixel_position: None,
1264                modifiers: KeyModifiers::NONE,
1265            }));
1266            let _ = step(term, app, &ctx);
1267        };
1268
1269        // The first click lands on the already-selected row, which is the
1270        // confirm: one click is enough when there is a single legal action.
1271        click(&mut app, &mut term);
1272        assert_eq!(
1273            app.sim.state(),
1274            &1,
1275            "clicking the selected action should play it"
1276        );
1277    }
1278
1279    #[test]
1280    fn a_click_outside_the_actions_panel_plays_nothing() {
1281        let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
1282        let mut app = SessionApp::new(TwoSeat, standard_bots(), 12).with_human_seat(0);
1283        let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
1284        let ctx = Frame {
1285            delta: Duration::ZERO,
1286            frame: 0,
1287        };
1288        term.backend_mut().push_event(Event::Mouse(MouseEvent {
1289            kind: MouseEventKind::Down(MouseButton::Left),
1290            position: Pos::new(layout.viewport.left() + 1, layout.viewport.top() + 1),
1291            pixel_position: None,
1292            modifiers: KeyModifiers::NONE,
1293        }));
1294        let _ = step(&mut term, &mut app, &ctx);
1295        assert_eq!(app.sim.state(), &0, "the board is not a menu");
1296    }
1297
1298    #[test]
1299    fn viewer_follows_seat_config() {
1300        let bots = standard_bots::<TwoSeat>();
1301        // All AI: a neutral spectator (no seat's private view).
1302        let all_ai = [SeatKind::Ai(0), SeatKind::Ai(0)];
1303        let sim = build_sim(&TwoSeat, &all_ai, &bots, 0);
1304        assert_eq!(viewer_for(&all_ai, &sim), None);
1305        // One human: fixed at that seat.
1306        let one = [SeatKind::Human, SeatKind::Ai(0)];
1307        let sim = build_sim(&TwoSeat, &one, &bots, 0);
1308        assert_eq!(viewer_for(&one, &sim), Some(PlayerId::new(0)));
1309        // Two humans: spectator until a handoff reveals the acting seat.
1310        let two = [SeatKind::Human, SeatKind::Human];
1311        let sim = build_sim(&TwoSeat, &two, &bots, 0);
1312        assert_eq!(viewer_for(&two, &sim), None);
1313        assert_eq!(count_humans(&two), 2);
1314    }
1315
1316    #[test]
1317    fn all_ai_auto_plays_to_the_end() {
1318        // The demo case: no seats human, Auto mode, no input needed.
1319        let app = SessionApp::new(TwoSeat, standard_bots(), 1);
1320        assert!(
1321            drive(app, false, 60),
1322            "an all-AI match should finish on its own"
1323        );
1324    }
1325
1326    #[test]
1327    fn one_human_needs_no_handoff() {
1328        // A single human seat plays via the menu (Enter confirms); the AI seat
1329        // auto-steps. No handoff gate, so Enter alone drives it to the end.
1330        let app = SessionApp::new(TwoSeat, standard_bots(), 2).with_human_seat(0);
1331        assert!(drive(app, true, 60), "one human + Enter should finish");
1332    }
1333
1334    #[test]
1335    fn two_humans_block_on_a_handoff() {
1336        // With two human seats, no seat is revealed until an Enter passes the
1337        // device: with no input the match must not advance at all.
1338        let app = SessionApp::new(TwoSeat, standard_bots(), 3)
1339            .with_human_seat(0)
1340            .with_human_seat(1);
1341        assert!(
1342            !drive(app, false, 60),
1343            "a 2-human match must stall on the handoff without input"
1344        );
1345        // Feeding Enter clears each handoff and confirms each seat's move.
1346        let app = SessionApp::new(TwoSeat, standard_bots(), 3)
1347            .with_human_seat(0)
1348            .with_human_seat(1);
1349        assert!(
1350            drive(app, true, 60),
1351            "Enter should pass the device and play both seats to the end"
1352        );
1353    }
1354}