1use 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
29const SEARCH_ITERATIONS: u32 = 100;
33
34const 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
44const DEFAULT_SPEED: usize = 2;
46
47const WHEEL_LINES: usize = 3;
50
51pub 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 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 #[must_use]
73 pub const fn name(&self) -> &'static str {
74 self.name
75 }
76}
77
78#[must_use]
80pub fn random_bot<G: Game>() -> BotOption<G> {
81 BotOption::new("Random", |seed| Box::new(Random::new(seed)))
82}
83
84#[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#[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#[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#[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
144fn count_humans(seats: &[SeatKind]) -> usize {
147 seats
148 .iter()
149 .filter(|kind| matches!(kind, SeatKind::Human))
150 .count()
151}
152
153fn 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
165fn 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
173fn 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
192fn 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#[derive(Clone, Copy, Debug)]
206struct LogScroll {
207 offset: usize,
209 rows: usize,
212 len_seen: usize,
215 dragging: bool,
218}
219
220impl LogScroll {
221 const fn new() -> Self {
224 Self {
225 offset: 0,
226 rows: 1,
227 len_seen: 0,
228 dragging: false,
229 }
230 }
231}
232
233#[derive(Clone, Copy)]
236enum Overlay {
237 None,
239 Setup(usize),
241 Help,
243 Handoff(PlayerId),
245}
246
247pub 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 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 overlay: Overlay,
281 exit: bool,
282}
283
284impl<G> SessionApp<G>
285where
286 G: PrintableGame + Clone,
287 G::Action: Debug,
288{
289 #[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 #[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 #[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 #[must_use]
346 pub const fn with_step_mode(mut self) -> Self {
347 self.auto = false;
348 self
349 }
350
351 #[must_use]
354 pub fn is_terminal(&self) -> bool {
355 matches!(self.overlay, Overlay::None) && self.sim.is_terminal()
356 }
357
358 #[must_use]
360 pub fn into_simulator(self) -> Simulator<G> {
361 self.sim
362 }
363
364 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 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 fn reset(&mut self) {
383 self.seed = self.seed.wrapping_add(1);
384 self.rebuild();
385 }
386
387 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 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 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 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 let max_back = total.saturating_sub(visible);
514 self.log.offset = max_back.saturating_sub(start);
515 }
516
517 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 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 const fn scroll_log_forward(&mut self, lines: usize) {
537 self.log.offset = self.log.offset.saturating_sub(lines);
538 }
539
540 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 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 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 fn reveal_gated(&self, player: PlayerId) -> bool {
640 count_humans(&self.seats) >= 2 && self.viewer != Some(player)
641 }
642
643 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 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 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 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 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 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 let layout = Layout::new(term.area());
906
907 match self.overlay {
908 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 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 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 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#[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 #[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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}