use std::collections::HashMap;
use std::fmt::Debug;
use std::time::Duration;
use retroglyph_core::event::{Event, KeyCode, MouseButton, MouseEventKind};
use retroglyph_core::grid::{Pos, Rect};
use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
use retroglyph_widgets::{List, ListState, Modal, StatefulWidget, Theme, offset_for_pos};
use turnbase::{Determinize, Game, PlayerId};
use turnbase_bots::{Bot, Ismcts, Mcts, Random};
use turnbase_match::{PlayerAgent, Simulator};
use crate::PrintableGame;
use crate::dashboard::{
Layout, actions_panel, draw_board_stats_log, draw_menu, log_geometry, menu_start, panel_inner,
print_rows,
};
const SEARCH_ITERATIONS: u32 = 100;
const SPEEDS: [Duration; 5] = [
Duration::from_secs(1),
Duration::from_millis(600),
Duration::from_millis(380),
Duration::from_millis(220),
Duration::from_millis(110),
];
const DEFAULT_SPEED: usize = 2;
const WHEEL_LINES: usize = 3;
pub struct BotOption<G: Game> {
name: &'static str,
make: Box<dyn Fn(u64) -> Box<dyn Bot<G>>>,
}
impl<G: Game> BotOption<G> {
pub fn new(name: &'static str, make: impl Fn(u64) -> Box<dyn Bot<G>> + 'static) -> Self {
Self {
name,
make: Box::new(make),
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
}
#[must_use]
pub fn random_bot<G: Game>() -> BotOption<G> {
BotOption::new("Random", |seed| Box::new(Random::new(seed)))
}
#[must_use]
pub fn mcts_bot<G>() -> BotOption<G>
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
BotOption::new("MCTS", |seed| Box::new(Mcts::new(SEARCH_ITERATIONS, seed)))
}
#[must_use]
pub fn ismcts_bot<G>() -> BotOption<G>
where
G: Determinize,
G::State: Clone,
G::Action: Clone,
{
BotOption::new("ISMCTS", |seed| {
Box::new(Ismcts::new(SEARCH_ITERATIONS, seed))
})
}
#[must_use]
pub fn standard_bots<G>() -> Vec<BotOption<G>>
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
vec![random_bot(), mcts_bot()]
}
#[derive(Clone, Copy)]
enum SeatKind {
Human,
Ai(usize),
}
const fn kind_index(kind: SeatKind) -> usize {
match kind {
SeatKind::Human => 0,
SeatKind::Ai(index) => index + 1,
}
}
const fn index_kind(index: usize) -> SeatKind {
match index {
0 => SeatKind::Human,
other => SeatKind::Ai(other - 1),
}
}
fn count_humans(seats: &[SeatKind]) -> usize {
seats
.iter()
.filter(|kind| matches!(kind, SeatKind::Human))
.count()
}
fn viewer_for<G: Game>(seats: &[SeatKind], sim: &Simulator<G>) -> Option<PlayerId> {
if count_humans(seats) >= 2 {
None
} else {
sim.primary_human()
}
}
fn seat_seed(seed: u64, seat: u32) -> u64 {
seed ^ u64::from(seat)
.wrapping_add(1)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
fn build_sim<G>(game: &G, seats: &[SeatKind], bots: &[BotOption<G>], seed: u64) -> Simulator<G>
where
G: Game + Clone,
{
let mut agents = HashMap::new();
for (seat, kind) in seats.iter().enumerate() {
let index = u32::try_from(seat).expect("seat index fits in u32");
let id = PlayerId::new(index);
let agent = match *kind {
SeatKind::Human => PlayerAgent::Human,
SeatKind::Ai(bot) => PlayerAgent::Ai((bots[bot].make)(seat_seed(seed, index))),
};
agents.insert(id, agent);
}
Simulator::new(game.clone(), seed, agents)
}
fn down_keys(events: &[Event]) -> impl Iterator<Item = KeyCode> + '_ {
events.iter().filter_map(|event| match event {
Event::Key(key) if key.is_down() => Some(key.code),
_ => None,
})
}
#[derive(Clone, Copy, Debug)]
struct LogScroll {
offset: usize,
rows: usize,
len_seen: usize,
dragging: bool,
}
impl LogScroll {
const fn new() -> Self {
Self {
offset: 0,
rows: 1,
len_seen: 0,
dragging: false,
}
}
}
#[derive(Clone, Copy)]
enum Overlay {
None,
Setup(usize),
Help,
Handoff(PlayerId),
}
pub struct SessionApp<G>
where
G: PrintableGame + Clone,
G::Action: Debug,
{
game: G,
bots: Vec<BotOption<G>>,
seats: Vec<SeatKind>,
setup_backup: Vec<SeatKind>,
seed: u64,
auto: bool,
paused: bool,
speed: usize,
ai_elapsed: Duration,
sim: Simulator<G>,
viewer: Option<PlayerId>,
selected: usize,
log: LogScroll,
overlay: Overlay,
exit: bool,
}
impl<G> SessionApp<G>
where
G: PrintableGame + Clone,
G::Action: Debug,
{
#[must_use]
pub fn new(game: G, bots: Vec<BotOption<G>>, seed: u64) -> Self {
let mut bots = bots;
if bots.is_empty() {
bots.push(random_bot());
}
let seats = vec![SeatKind::Ai(0); game.num_players()];
let sim = build_sim(&game, &seats, &bots, seed);
let viewer = viewer_for(&seats, &sim);
Self {
game,
bots,
setup_backup: seats.clone(),
seats,
seed,
auto: true,
paused: false,
speed: DEFAULT_SPEED,
ai_elapsed: Duration::ZERO,
sim,
viewer,
selected: 0,
log: LogScroll::new(),
overlay: Overlay::None,
exit: false,
}
}
#[must_use]
pub fn with_human_seat(mut self, seat: usize) -> Self {
if seat < self.seats.len() {
self.seats[seat] = SeatKind::Human;
self.rebuild();
}
self
}
#[must_use]
pub fn with_setup_open(mut self, open: bool) -> Self {
self.overlay = if open {
self.setup_backup = self.seats.clone();
Overlay::Setup(0)
} else {
Overlay::None
};
self
}
#[must_use]
pub const fn with_step_mode(mut self) -> Self {
self.auto = false;
self
}
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self.overlay, Overlay::None) && self.sim.is_terminal()
}
#[must_use]
pub fn into_simulator(self) -> Simulator<G> {
self.sim
}
fn rebuild(&mut self) {
self.sim = build_sim(&self.game, &self.seats, &self.bots, self.seed);
self.viewer = viewer_for(&self.seats, &self.sim);
if matches!(self.overlay, Overlay::Handoff(_)) {
self.overlay = Overlay::None;
}
self.selected = 0;
self.ai_elapsed = Duration::ZERO;
self.paused = false;
self.log.offset = 0;
self.log.len_seen = 0;
}
fn reset(&mut self) {
self.seed = self.seed.wrapping_add(1);
self.rebuild();
}
fn kind_label(&self, kind: SeatKind) -> &'static str {
match kind {
SeatKind::Human => "Human",
SeatKind::Ai(index) => self.bots[index].name(),
}
}
fn handle_controls(&mut self, events: &[Event]) {
for key in down_keys(events) {
match key {
KeyCode::Escape => self.exit = true,
KeyCode::Char('c' | 'C') => {
self.setup_backup = self.seats.clone();
self.overlay = Overlay::Setup(0);
}
KeyCode::Char('r' | 'R') => self.reset(),
KeyCode::Char('m' | 'M') | KeyCode::Tab => {
self.auto = !self.auto;
self.paused = false;
self.ai_elapsed = Duration::ZERO;
}
KeyCode::Char('p' | 'P') => {
if self.auto {
self.paused = !self.paused;
}
}
KeyCode::Char('+' | '=') => self.speed = (self.speed + 1).min(SPEEDS.len() - 1),
KeyCode::Char('-' | '_') => self.speed = self.speed.saturating_sub(1),
KeyCode::Char('?' | 'h' | 'H') => self.overlay = Overlay::Help,
KeyCode::Char(' ') => self.step_once(),
KeyCode::PageUp => self.scroll_log_back(self.log.rows.max(1)),
KeyCode::PageDown => self.scroll_log_forward(self.log.rows.max(1)),
KeyCode::Home => self.scroll_log_back(usize::MAX),
KeyCode::End => self.log.offset = 0,
_ => {}
}
}
}
fn handle_mouse(&mut self, layout: &Layout, events: &[Event]) {
let total = self.sim.log_history().len();
let geometry = log_geometry(layout.log, total);
for event in events {
let Event::Mouse(mouse) = event else { continue };
let over_log = layout.log.contains_pos(mouse.position);
match mouse.kind {
MouseEventKind::ScrollUp if over_log => self.scroll_log_back(WHEEL_LINES),
MouseEventKind::ScrollDown if over_log => self.scroll_log_forward(WHEEL_LINES),
MouseEventKind::Down(MouseButton::Left) => {
if let Some(bar) = geometry.bar
&& bar.contains_pos(mouse.position)
{
self.log.dragging = true;
self.drag_log_to(bar, total, geometry.visible, mouse.position);
} else {
self.click_action(layout, mouse.position);
}
}
MouseEventKind::Moved if self.log.dragging => {
if let Some(bar) = geometry.bar {
self.drag_log_to(bar, total, geometry.visible, mouse.position);
}
}
MouseEventKind::Up(MouseButton::Left) => self.log.dragging = false,
_ => {}
}
}
}
fn click_action(&mut self, layout: &Layout, pos: Pos) {
let Some(player) = self.sim.awaiting_human() else {
return;
};
let inner = panel_inner(layout.actions);
if !inner.contains_pos(pos) {
return;
}
let mut actions = self.sim.game().legal_actions(self.sim.state(), player);
let capacity = usize::from(inner.height());
let selected = self.selected.min(actions.len().saturating_sub(1));
let row = usize::from(pos.y.saturating_sub(inner.top()));
let index = menu_start(capacity, actions.len(), selected) + row;
if index >= actions.len() {
return;
}
if index == selected {
let action = actions.swap_remove(index);
let _ = self.sim.select_human_action(player, action);
self.selected = 0;
} else {
self.selected = index;
}
}
fn drag_log_to(&mut self, bar: Rect, total: usize, visible: usize, pos: Pos) {
let clamped = Pos::new(
bar.left(),
pos.y
.clamp(bar.top(), bar.bottom().saturating_sub(1).max(bar.top())),
);
let Some(start) = offset_for_pos(bar, total, visible, clamped) else {
return;
};
let max_back = total.saturating_sub(visible);
self.log.offset = max_back.saturating_sub(start);
}
fn max_log_scroll(&self) -> usize {
self.sim
.log_history()
.len()
.saturating_sub(self.log.rows.max(1))
}
fn scroll_log_back(&mut self, lines: usize) {
self.log.offset = self
.log
.offset
.saturating_add(lines)
.min(self.max_log_scroll());
}
const fn scroll_log_forward(&mut self, lines: usize) {
self.log.offset = self.log.offset.saturating_sub(lines);
}
fn anchor_log(&mut self) {
let len = self.sim.log_history().len();
if self.log.offset > 0 {
let grown = len.saturating_sub(self.log.len_seen);
self.log.offset = self.log.offset.saturating_add(grown);
}
self.log.len_seen = len;
self.log.offset = self.log.offset.min(self.max_log_scroll());
}
fn step_once(&mut self) {
if !self.sim.is_terminal() && self.sim.awaiting_human().is_none() {
let _ = self.sim.step();
self.ai_elapsed = Duration::ZERO;
}
}
fn tick_ai(&mut self, frame: &Frame) {
self.ai_elapsed = self.ai_elapsed.saturating_add(frame.delta);
if self.ai_elapsed >= SPEEDS[self.speed] {
self.ai_elapsed = Duration::ZERO;
let _ = self.sim.step();
}
}
fn handle_action_menu(&mut self, player: PlayerId, events: &[Event]) {
let count = self
.sim
.game()
.legal_actions(self.sim.state(), player)
.len();
if count > 0 {
self.selected = self.selected.min(count - 1);
}
let mut confirm = false;
for key in down_keys(events) {
match key {
KeyCode::Up if count > 0 => {
self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
}
KeyCode::Down if count > 0 => self.selected = (self.selected + 1) % count,
KeyCode::Enter => confirm = true,
_ => {}
}
}
if confirm && count > 0 {
let mut actions = self.sim.game().legal_actions(self.sim.state(), player);
let action = actions.swap_remove(self.selected);
let _ = self.sim.select_human_action(player, action);
self.selected = 0;
}
}
fn handle_setup(&mut self, cursor: usize, events: &[Event]) {
let seats = self.seats.len();
let kinds = self.bots.len() + 1;
let mut cursor = cursor.min(seats.saturating_sub(1));
for key in down_keys(events) {
match key {
KeyCode::Up if seats > 0 => {
cursor = cursor.checked_sub(1).unwrap_or(seats - 1);
}
KeyCode::Down if seats > 0 => cursor = (cursor + 1) % seats,
KeyCode::Left => {
let next = (kind_index(self.seats[cursor]) + kinds - 1) % kinds;
self.seats[cursor] = index_kind(next);
}
KeyCode::Right => {
let next = (kind_index(self.seats[cursor]) + 1) % kinds;
self.seats[cursor] = index_kind(next);
}
KeyCode::Enter => {
self.overlay = Overlay::None;
self.rebuild();
return;
}
KeyCode::Escape => {
self.seats.clone_from(&self.setup_backup);
self.overlay = Overlay::None;
return;
}
_ => {}
}
}
self.overlay = Overlay::Setup(cursor);
}
fn reveal_gated(&self, player: PlayerId) -> bool {
count_humans(&self.seats) >= 2 && self.viewer != Some(player)
}
fn handle_handoff(&mut self, seat: PlayerId, events: &[Event]) {
for key in down_keys(events) {
match key {
KeyCode::Enter => {
self.viewer = Some(seat);
self.overlay = Overlay::None;
return;
}
KeyCode::Escape => {
self.exit = true;
return;
}
_ => {}
}
}
}
fn handle_help(&mut self, events: &[Event]) {
for key in down_keys(events) {
if matches!(key, KeyCode::Char('?' | 'h' | 'H') | KeyCode::Escape) {
self.overlay = Overlay::None;
return;
}
}
}
fn turn_label(&self) -> String {
if self.sim.is_terminal() {
return "over".to_owned();
}
if let Some(player) = self.sim.awaiting_human() {
return format!("P{} (you)", player.index());
}
match self
.sim
.game()
.active_players(self.sim.state())
.iter()
.next()
{
Some(player) if player.is_chance() => "chance".to_owned(),
Some(player) => {
let name = usize::try_from(player.index())
.ok()
.and_then(|seat| self.seats.get(seat))
.map_or("AI", |kind| self.kind_label(*kind));
format!("P{} ({name})", player.index())
}
None => "over".to_owned(),
}
}
fn draw<B: Backend>(&self, term: &mut Terminal<B>) -> usize {
term.reset_style();
let theme = Theme::DARK;
let layout = Layout::new(term.area());
let log_rows = if let Overlay::Handoff(seat) = self.overlay {
Self::draw_handoff(term, seat);
self.log.rows
} else {
let view = self.sim.game().view(self.sim.state(), self.viewer);
let rows = draw_board_stats_log(
self.sim.game(),
&view,
self.sim.log_history(),
term,
&layout,
theme,
self.log.offset,
);
self.draw_actions(term, &layout);
rows
};
self.draw_status(term, &layout);
match self.overlay {
Overlay::Setup(cursor) => self.draw_setup(term, cursor),
Overlay::Help => Self::draw_help(term),
Overlay::None | Overlay::Handoff(_) => {}
}
log_rows
}
fn draw_actions<B: Backend>(&self, term: &mut Terminal<B>, layout: &Layout) {
let theme = Theme::DARK;
if self.sim.is_terminal() {
let inner = actions_panel(term, layout.actions, theme, None);
print_rows(
term,
inner,
0,
std::iter::once("match over -- r restart, c config".to_owned()),
);
} else if let Some(player) = self.sim.awaiting_human() {
let actions = self.sim.game().legal_actions(self.sim.state(), player);
let game = self.sim.game();
let labels: Vec<String> = actions.iter().map(|a| game.format_action(a)).collect();
let selected = self.selected.min(labels.len().saturating_sub(1));
let inner = actions_panel(
term,
layout.actions,
theme,
Some((selected + 1, labels.len())),
);
draw_menu(term, inner, 0, &labels, selected);
} else {
let inner = actions_panel(term, layout.actions, theme, None);
let label = if !self.auto {
"(step: press Space)"
} else if self.paused {
"(paused: p to resume)"
} else {
"(AI thinking...)"
};
print_rows(term, inner, 0, std::iter::once(label.to_owned()));
}
}
fn draw_status<B: Backend>(&self, term: &mut Terminal<B>, layout: &Layout) {
let mode = if self.auto {
if self.paused { "Auto(paused)" } else { "Auto" }
} else {
"Step"
};
let speed = if self.auto {
format!(" speed {}/{}", self.speed + 1, SPEEDS.len())
} else {
String::new()
};
let turn = self.turn_label();
let text =
format!(" {mode}{speed} | turn: {turn} | c config r reset ? help Esc quit");
let width = usize::from(layout.status.width());
let mut bar: String = text.chars().take(width).collect();
while bar.chars().count() < width {
bar.push(' ');
}
let theme = Theme::DARK;
term.reset_style().fg(theme.fg).bg(theme.title_bg);
term.print(layout.status.left(), layout.status.top(), &bar);
term.reset_style();
}
fn draw_setup<B: Backend>(&self, term: &mut Terminal<B>, cursor: usize) {
let theme = Theme::DARK;
let seats = self.seats.len();
#[expect(clippy::cast_possible_truncation, reason = "seat counts are tiny")]
let rows = seats as u16;
let width = 44;
let height = rows.saturating_add(5);
let inner = Modal::new(width, height)
.theme(theme)
.title("Session Setup")
.render(term.area(), term);
let items: Vec<String> = self
.seats
.iter()
.enumerate()
.map(|(seat, kind)| format!("Seat {seat} {}", self.kind_label(*kind)))
.collect();
let refs: Vec<&str> = items.iter().map(String::as_str).collect();
let list_rect = Rect::new(
inner.left(),
inner.top(),
inner.width(),
rows.min(inner.height()),
);
let mut state = ListState::new();
state.select(Some(cursor));
List::new(&refs)
.theme(theme)
.render(list_rect, term, &mut state);
let hint_rect = Rect::new(
inner.left(),
inner.bottom().saturating_sub(1),
inner.width(),
1,
);
term.reset_style().fg(theme.dim);
print_rows(
term,
hint_rect,
0,
std::iter::once("arrows pick Enter start Esc cancel".to_owned()),
);
term.reset_style();
}
fn draw_handoff<B: Backend>(term: &mut Terminal<B>, seat: PlayerId) {
let theme = Theme::DARK;
let inner = Modal::new(42, 6)
.theme(theme)
.title("Pass the device")
.render(term.area(), term);
term.reset_style().fg(theme.fg);
print_rows(
term,
inner,
0,
[
format!("Hand the screen to P{}.", seat.index()),
String::new(),
"Press Enter when they are ready.".to_owned(),
],
);
term.reset_style();
}
fn draw_help<B: Backend>(term: &mut Terminal<B>) {
let theme = Theme::DARK;
let lines = [
"Space step one decision",
"m/Tab Auto <-> Step",
"p pause / resume (Auto)",
"+ / - speed",
"PgUp/PgDn scroll the log (or the wheel)",
"Home/End log oldest / newest",
"c configure seats",
"r restart (new seed)",
"? toggle this help",
"Esc quit",
"",
"Your turn: Up/Down select, Enter play",
" or click a row, again to play",
];
#[expect(clippy::cast_possible_truncation, reason = "line count is tiny")]
let height = lines.len() as u16 + 4;
let inner = Modal::new(44, height)
.theme(theme)
.title("Controls")
.render(term.area(), term);
term.reset_style().fg(theme.fg);
print_rows(term, inner, 0, lines.iter().map(|line| (*line).to_owned()));
term.reset_style();
}
}
impl<G, B> App<B> for SessionApp<G>
where
G: PrintableGame + Clone,
G::Action: Debug,
B: Backend,
{
fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
let events: Vec<Event> = term.drain_events().collect();
let layout = Layout::new(term.area());
match self.overlay {
Overlay::Setup(cursor) => {
self.log.dragging = false;
self.handle_setup(cursor, &events);
}
Overlay::Help => {
self.log.dragging = false;
self.handle_help(&events);
}
Overlay::Handoff(seat) => {
self.log.dragging = false;
self.handle_handoff(seat, &events);
}
Overlay::None => {
self.handle_controls(&events);
self.handle_mouse(&layout, &events);
if !self.exit && matches!(self.overlay, Overlay::None) && !self.sim.is_terminal() {
match self.sim.awaiting_human() {
Some(player) if self.reveal_gated(player) => {
self.overlay = Overlay::Handoff(player);
}
Some(player) => self.handle_action_menu(player, &events),
None => {
if self.auto && !self.paused {
self.tick_ai(frame);
}
}
}
}
}
}
self.anchor_log();
self.log.rows = self.draw(term).max(1);
let _ = term.present();
if self.exit {
Flow::Exit
} else {
Flow::Continue
}
}
}
#[cfg(feature = "crossterm")]
pub fn run_session<G>(app: SessionApp<G>) -> std::io::Result<()>
where
G: PrintableGame + Clone,
G::Action: Debug,
{
retroglyph_crossterm::Crossterm::run(app)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use retroglyph_core::backend::Headless;
use retroglyph_core::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use retroglyph_core::grid::{Pos, Rect};
use retroglyph_core::{Backend, Flow, Frame, Terminal, step};
use turnbase::{ActivePlayers, Game, PlayerId};
use super::{SeatKind, SessionApp, build_sim, count_humans, standard_bots, viewer_for};
use crate::PrintableGame;
use crate::dashboard::{Layout, log_geometry, log_start, panel_inner};
#[derive(Clone)]
struct TwoSeat;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Play;
impl Game for TwoSeat {
type State = u32;
type Action = Play;
type View = u32;
fn new_initial_state(&self, _seed: u64) -> u32 {
0
}
fn num_players(&self) -> usize {
2
}
fn active_players(&self, state: &u32) -> ActivePlayers {
if *state >= 2 {
ActivePlayers::none()
} else {
ActivePlayers::one(PlayerId::new(*state % 2))
}
}
fn legal_actions(&self, state: &u32, _player: PlayerId) -> Vec<Play> {
if *state >= 2 { Vec::new() } else { vec![Play] }
}
fn apply(&self, state: &mut u32, _player: PlayerId, _action: Play) {
*state += 1;
}
fn is_terminal(&self, state: &u32) -> bool {
*state >= 2
}
fn reward(&self, _state: &u32, _player: PlayerId) -> f64 {
0.0
}
fn view(&self, state: &u32, _viewer: Option<PlayerId>) -> u32 {
*state
}
}
impl PrintableGame for TwoSeat {
fn draw_viewport<B: Backend>(&self, _view: &u32, _term: &mut Terminal<B>, _area: Rect) {}
fn get_stats(&self, _view: &u32) -> Vec<(String, String)> {
Vec::new()
}
fn format_action(&self, _action: &Play) -> String {
"play".to_owned()
}
}
fn drive(mut app: SessionApp<TwoSeat>, enter: bool, frames: u64) -> bool {
let mut term = Terminal::new(Headless::new(60, 20));
for frame in 0..frames {
if enter {
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
}
let ctx = Frame {
delta: Duration::from_millis(250),
frame,
};
if step(&mut term, &mut app, &ctx) == Flow::Exit {
break;
}
if app.is_terminal() {
return true;
}
}
app.is_terminal()
}
#[derive(Clone)]
struct LongGame;
const MANY_MOVES: u32 = 200;
impl Game for LongGame {
type State = u32;
type Action = Play;
type View = u32;
fn new_initial_state(&self, _seed: u64) -> u32 {
0
}
fn num_players(&self) -> usize {
1
}
fn active_players(&self, state: &u32) -> ActivePlayers {
if *state >= MANY_MOVES {
ActivePlayers::none()
} else {
ActivePlayers::one(PlayerId::new(0))
}
}
fn legal_actions(&self, state: &u32, _player: PlayerId) -> Vec<Play> {
if *state >= MANY_MOVES {
Vec::new()
} else {
vec![Play]
}
}
fn apply(&self, state: &mut u32, _player: PlayerId, _action: Play) {
*state += 1;
}
fn is_terminal(&self, state: &u32) -> bool {
*state >= MANY_MOVES
}
fn reward(&self, _state: &u32, _player: PlayerId) -> f64 {
0.0
}
fn view(&self, state: &u32, _viewer: Option<PlayerId>) -> u32 {
*state
}
}
impl PrintableGame for LongGame {
fn draw_viewport<B: Backend>(&self, _view: &u32, _term: &mut Terminal<B>, _area: Rect) {}
fn get_stats(&self, _view: &u32) -> Vec<(String, String)> {
Vec::new()
}
fn format_action(&self, _action: &Play) -> String {
"play".to_owned()
}
}
const TEST_COLS: u16 = 60;
const TEST_ROWS: u16 = 20;
fn scrolled_session() -> (SessionApp<LongGame>, Terminal<Headless>) {
let mut app = SessionApp::new(LongGame, standard_bots(), 7);
let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
for frame in 0..40 {
let ctx = Frame {
delta: Duration::from_secs(1),
frame,
};
let _ = step(&mut term, &mut app, &ctx);
}
assert!(
app.sim.log_history().len() > app.log.rows,
"the log must outgrow the panel for the scroll tests to mean anything"
);
(app, term)
}
fn pointer(
app: &mut SessionApp<LongGame>,
term: &mut Terminal<Headless>,
kind: MouseEventKind,
position: Pos,
) {
term.backend_mut().push_event(Event::Mouse(MouseEvent {
kind,
position,
pixel_position: None,
modifiers: KeyModifiers::NONE,
}));
let ctx = Frame {
delta: Duration::ZERO,
frame: 0,
};
let _ = step(term, app, &ctx);
}
#[test]
fn the_wheel_scrolls_the_log_only_over_the_log() {
let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
let inside = Pos::new(layout.log.left() + 2, layout.log.top() + 1);
let elsewhere = Pos::new(layout.viewport.left() + 2, layout.viewport.top() + 1);
let (mut app, mut term) = scrolled_session();
pointer(&mut app, &mut term, MouseEventKind::ScrollUp, inside);
assert_eq!(
app.log.offset,
super::WHEEL_LINES,
"a wheel notch over the log should scroll it back"
);
pointer(&mut app, &mut term, MouseEventKind::ScrollDown, inside);
assert_eq!(app.log.offset, 0, "scrolling forward returns to the tail");
pointer(&mut app, &mut term, MouseEventKind::ScrollUp, elsewhere);
assert_eq!(
app.log.offset, 0,
"the wheel elsewhere on the dashboard must not scroll the log"
);
}
#[test]
fn dragging_the_scrollbar_moves_through_the_log() {
let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
let (mut app, mut term) = scrolled_session();
let total = app.sim.log_history().len();
let geometry = log_geometry(layout.log, total);
let bar = geometry.bar.expect("a filled log should show a scrollbar");
let max_back = total.saturating_sub(geometry.visible);
pointer(
&mut app,
&mut term,
MouseEventKind::Down(MouseButton::Left),
Pos::new(bar.left(), bar.top()),
);
assert_eq!(app.log.offset, max_back, "the top of the track is oldest");
assert_eq!(
log_start(total, geometry.visible, app.log.offset),
0,
"which is line 0 in the panel's own coordinates"
);
pointer(
&mut app,
&mut term,
MouseEventKind::Moved,
Pos::new(bar.left(), TEST_ROWS - 1),
);
assert_eq!(app.log.offset, 0, "the bottom of the track is newest");
pointer(
&mut app,
&mut term,
MouseEventKind::Up(MouseButton::Left),
Pos::new(bar.left(), bar.bottom() - 1),
);
pointer(
&mut app,
&mut term,
MouseEventKind::Moved,
Pos::new(bar.left(), bar.top()),
);
assert_eq!(
app.log.offset, 0,
"a hover after the release scrolls nothing"
);
}
#[test]
fn clicking_an_action_row_selects_then_plays_it() {
let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
let actions = panel_inner(layout.actions);
let mut app = SessionApp::new(TwoSeat, standard_bots(), 11).with_human_seat(0);
let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
let ctx = Frame {
delta: Duration::ZERO,
frame: 0,
};
let _ = step(&mut term, &mut app, &ctx);
assert_eq!(app.sim.state(), &0, "nothing has been played yet");
let row = Pos::new(actions.left() + 2, actions.top());
let click = |app: &mut SessionApp<TwoSeat>, term: &mut Terminal<Headless>| {
term.backend_mut().push_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
position: row,
pixel_position: None,
modifiers: KeyModifiers::NONE,
}));
let _ = step(term, app, &ctx);
};
click(&mut app, &mut term);
assert_eq!(
app.sim.state(),
&1,
"clicking the selected action should play it"
);
}
#[test]
fn a_click_outside_the_actions_panel_plays_nothing() {
let layout = Layout::new(Rect::new(0, 0, TEST_COLS, TEST_ROWS));
let mut app = SessionApp::new(TwoSeat, standard_bots(), 12).with_human_seat(0);
let mut term = Terminal::new(Headless::new(TEST_COLS, TEST_ROWS));
let ctx = Frame {
delta: Duration::ZERO,
frame: 0,
};
term.backend_mut().push_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
position: Pos::new(layout.viewport.left() + 1, layout.viewport.top() + 1),
pixel_position: None,
modifiers: KeyModifiers::NONE,
}));
let _ = step(&mut term, &mut app, &ctx);
assert_eq!(app.sim.state(), &0, "the board is not a menu");
}
#[test]
fn viewer_follows_seat_config() {
let bots = standard_bots::<TwoSeat>();
let all_ai = [SeatKind::Ai(0), SeatKind::Ai(0)];
let sim = build_sim(&TwoSeat, &all_ai, &bots, 0);
assert_eq!(viewer_for(&all_ai, &sim), None);
let one = [SeatKind::Human, SeatKind::Ai(0)];
let sim = build_sim(&TwoSeat, &one, &bots, 0);
assert_eq!(viewer_for(&one, &sim), Some(PlayerId::new(0)));
let two = [SeatKind::Human, SeatKind::Human];
let sim = build_sim(&TwoSeat, &two, &bots, 0);
assert_eq!(viewer_for(&two, &sim), None);
assert_eq!(count_humans(&two), 2);
}
#[test]
fn all_ai_auto_plays_to_the_end() {
let app = SessionApp::new(TwoSeat, standard_bots(), 1);
assert!(
drive(app, false, 60),
"an all-AI match should finish on its own"
);
}
#[test]
fn one_human_needs_no_handoff() {
let app = SessionApp::new(TwoSeat, standard_bots(), 2).with_human_seat(0);
assert!(drive(app, true, 60), "one human + Enter should finish");
}
#[test]
fn two_humans_block_on_a_handoff() {
let app = SessionApp::new(TwoSeat, standard_bots(), 3)
.with_human_seat(0)
.with_human_seat(1);
assert!(
!drive(app, false, 60),
"a 2-human match must stall on the handoff without input"
);
let app = SessionApp::new(TwoSeat, standard_bots(), 3)
.with_human_seat(0)
.with_human_seat(1);
assert!(
drive(app, true, 60),
"Enter should pass the device and play both seats to the end"
);
}
}