From 50028fc708ef0cfea9fd589c90e07b413ecb570f Mon Sep 17 00:00:00 2001 From: Dylan Date: Fri, 23 Oct 2026 13:16:48 +1300 Subject: feat: More improvements to UI --- src/claude-player.rs | 35 +++++++++++++-- src/game.rs | 120 ++++++++++++++++++++++++++++++++++++++------------- src/local-player.rs | 49 ++++++++++++++++++--- src/main.rs | 3 +- src/player.rs | 54 +++++++++++++++++++++-- 5 files changed, 215 insertions(+), 46 deletions(-) (limited to 'src') diff --git a/src/claude-player.rs b/src/claude-player.rs index 0a54ab5..92be85b 100644 --- a/src/claude-player.rs +++ b/src/claude-player.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, time::Duration}; -use crate::{board::{is_jump, GameBoard, Move, PieceType, Turn}, player::{CacheEntry, MinimaxPlayer}}; +use crate::{board::{is_jump, GameBoard, Move, PieceType, Turn}, player::{CacheEntry, MinimaxPlayer, SearchReport}}; /// Sheep that start the game: on the board plus in hand. const TOTAL_SHEEP: u32 = 20; @@ -9,14 +9,15 @@ const TOTAL_SHEEP: u32 = 20; pub struct ClaudePlayer { pub time_limit: Duration, - cache: HashMap<(GameBoard, Turn, usize), CacheEntry> + cache: HashMap<(GameBoard, Turn, usize), CacheEntry>, + last_search: Option, } impl Default for ClaudePlayer { fn default() -> Self { - Self { time_limit: Duration::from_secs(1), cache: HashMap::new() } + Self { time_limit: Duration::from_secs(1), cache: HashMap::new(), last_search: None } } } @@ -24,7 +25,7 @@ impl ClaudePlayer { pub fn new(time_limit: Duration) -> Self { - Self { time_limit, cache: HashMap::new() } + Self { time_limit, cache: HashMap::new(), last_search: None } } } @@ -63,9 +64,35 @@ impl MinimaxPlayer for ClaudePlayer fn name(&self) -> String { "Claude".to_string() } + fn set_last_search(&mut self, report: Option) { self.last_search = report; } + + fn last_search(&self) -> Option { self.last_search } + fn time_limit(&self) -> Duration { self.time_limit } fn cache(&mut self) -> Option<&mut dyn crate::player::ScoreCache> { Some(&mut self.cache) } } + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::player::Player; + + #[test] + fn pick_move_reports_its_search() + { + let mut player = ClaudePlayer::new(Duration::from_millis(50)); + let m = player.pick_move(GameBoard::default(), Turn::Tiger).unwrap(); + let report = player.last_search.expect("a report"); + assert_eq!(report.mv, m); + assert!(report.depth >= 1); + assert_eq!(report.score, player.minimax(GameBoard::default(), Turn::Tiger, report.depth, + f32::NEG_INFINITY, f32::INFINITY, None).unwrap()); + + player.reset(); + assert_eq!(player.last_search, None); + } +} diff --git a/src/game.rs b/src/game.rs index 73ae583..fcfc243 100644 --- a/src/game.rs +++ b/src/game.rs @@ -4,6 +4,7 @@ use ratatui::{ crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}, execute}, layout::{Constraint, Layout, Margin, Rect}, style::{Color, Modifier, Style}, + symbols::merge::MergeStrategy, text::{Line, Span}, widgets::{Block, Paragraph}, DefaultTerminal, Frame, @@ -34,6 +35,12 @@ enum Action Revert(usize), } +/// A bordered pane whose borders join up with its neighbours' where they overlap. +fn panel() -> Block<'static> +{ + Block::bordered().merge_borders(MergeStrategy::Exact) +} + fn side_name(turn: Turn) -> &'static str { match turn @@ -112,11 +119,7 @@ impl GameHandler { Action::None => (), Action::Quit => return Ok(self.winner()), - Action::Play(m) => - { - player.reset(); - self.play(m); - } + Action::Play(m) => self.play(m), Action::Revert(i) => { self.revert(i); @@ -163,17 +166,61 @@ impl GameHandler fn draw(&mut self, frame: &mut Frame, players: &[&mut dyn Player; 2]) { let (_, board_h) = BoardView::size(1); - let [top, bottom] = Layout::vertical([Constraint::Min(board_h + 4), Constraint::Length(6)]).areas(frame.area()); + // Neighbouring panes overlap by a cell, so they share one border that `panel` merges into ┬ ┴ ┼ + let [top, bottom] = Layout::vertical([Constraint::Min(board_h + 4), Constraint::Length(6)]).spacing(-1).areas(frame.area()); // History rows are like "123. ta5xc3 [↶]", so a fixed narrow pane fits them; the board gets the rest - let [board_pane, history_pane] = Layout::horizontal([Constraint::Min(0), Constraint::Length(20)]).areas(top); + let [board_pane, history_pane] = Layout::horizontal([Constraint::Min(0), Constraint::Length(20)]).spacing(-1).areas(top); + let [current_pane, last_pane] = Layout::horizontal([Constraint::Percentage(70), Constraint::Fill(1)]).spacing(-1).areas(bottom); let winner = self.winner(); let turn = self.turn(); let player = &*players[match turn { Turn::Sheep => 0, Turn::Tiger => 1 }]; + // The bottom row is drawn first, so the top row's bottom titles (keybinds, revert hint) + // land on the shared border line over it. Its own titles go on its bottom border. + // Player output, or the result once the game is over + match winner + { + Some(w) => + { + let block = panel().title_bottom(" Game over "); + let text = vec![ + Line::styled(format!("{} win!", side_name(w)), Style::new().add_modifier(Modifier::BOLD)), + Line::from("Click a move to revert to it, or press q to quit."), + ]; + frame.render_widget(Paragraph::new(text).block(block), current_pane); + } + None => + { + let block = panel().title_bottom(format!(" {}: {} ", side_name(turn), player.name())); + let inner = block.inner(current_pane); + frame.render_widget(block, current_pane); + frame.render_widget(player, inner); + } + } + + // The previous player's comments on the move it just made + let last = turn.next_turn(); + let previous = &*players[match last { Turn::Sheep => 0, Turn::Tiger => 1 }]; + let title = if self.history.is_empty() { " Last turn ".to_string() } + else + { + // Drop the name and shorten the label if the pane is too narrow for it + let full = format!(" Last turn: {} ({}) ", side_name(last), previous.name()); + let fits = full.chars().count() as u16 + 2 <= last_pane.width; + if fits { full } else { format!(" Last: {} ", side_name(last)) } + }; + let block = panel().title_bottom(title); + let inner = block.inner(last_pane); + frame.render_widget(block, last_pane); + if !self.history.is_empty() + { + previous.render_last_turn(inner, frame.buffer_mut()); + } + // Board // Keep in step with `handle_event` - let block = Block::bordered().title(" Board ").title_bottom(" click: select / move · q, Esc: quit "); + let block = panel().title(" Board ").title_bottom(" click: select / move · q, Esc: quit "); let inner = block.inner(board_pane); frame.render_widget(block, board_pane); // Leave a cell of margin, then centre the biggest board that fits @@ -190,7 +237,7 @@ impl GameHandler frame.render_widget(&view, padded); // Move history, scrolled to keep the newest move in view - let block = Block::bordered().title(" Moves ").title_bottom(" ↶ reverts "); + let block = panel().title(" Moves ").title_bottom(" ↶ reverts "); let inner = block.inner(history_pane); frame.render_widget(block, history_pane); self.history_rows.clear(); @@ -207,28 +254,8 @@ impl GameHandler frame.render_widget(line, rect); self.history_rows.push((rect, i)); } - - // Player output, or the result once the game is over - match winner - { - Some(w) => - { - let block = Block::bordered().title(" Game over "); - let text = vec![ - Line::styled(format!("{} win!", side_name(w)), Style::new().add_modifier(Modifier::BOLD)), - Line::from("Click a move to revert to it, or press q to quit."), - ]; - frame.render_widget(Paragraph::new(text).block(block), bottom); - } - None => - { - let block = Block::bordered().title(format!(" {}: {} ", side_name(turn), player.name())); - let inner = block.inner(bottom); - frame.render_widget(block, bottom); - frame.render_widget(player, inner); - } - } } + } #[cfg(test)] @@ -275,7 +302,7 @@ mod tests } use ratatui::{backend::TestBackend, crossterm::event::{MouseEvent, KeyEvent}, Terminal}; - use crate::local_player::LocalPlayer; + use crate::{claude_player::ClaudePlayer, local_player::LocalPlayer}; fn draw(game: &mut GameHandler, players: &[&mut dyn Player; 2]) -> Terminal { @@ -313,6 +340,8 @@ mod tests let screen = format!("{:?}", terminal.backend().buffer()); assert!(screen.contains("1. sc3 [↶]"), "{screen}"); assert!(screen.contains("Tigers: You"), "{screen}"); + assert!(screen.contains("Last: Sheep"), "{screen}"); + assert!(screen.contains("Played sc3"), "{screen}"); let (rect, i) = game.history_rows[0]; let action = game.handle_event(click(rect.x + 2, rect.y), &mut tiger, game.board(), game.turn(), false); @@ -342,6 +371,35 @@ mod tests assert_eq!(pos_at(area, at.0, at.1), Some((2, 2))); } + #[test] + fn last_turn_panel_shows_the_computer_search() + { + let mut game = GameHandler::default(); + let mut sheep = LocalPlayer::default(); + let mut tiger = ClaudePlayer::new(std::time::Duration::from_millis(50)); + game.play(Move::PlaceSheep { pos: (2, 2) }); + let m = tiger.pick_move(game.board(), game.turn()).unwrap(); + game.play(m); + + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + terminal.draw(|frame| game.draw(frame, &[&mut sheep, &mut tiger])).unwrap(); + let screen = format!("{:?}", terminal.backend().buffer()); + assert!(screen.contains("Last turn: Tigers (Claude)"), "{screen}"); + assert!(screen.contains(&format!("Played {m}")), "{screen}"); + assert!(screen.contains("Searched to depth"), "{screen}"); + assert!(screen.contains("Score: "), "{screen}"); + + // Borders are shared and joined where panes meet, and every pane reaches the right edge + let buf = terminal.backend().buffer(); + let row = |y: u16| (0..100).map(|x| buf[(x, y)].symbol()).collect::(); + assert!(row(0).starts_with("┌ Board ─") && row(0).ends_with("┐"), "{}", row(0)); + assert!(row(0).contains("┬ Moves "), "{}", row(0)); + assert_eq!(row(18).chars().next(), Some('├'), "{}", row(18)); + assert!(row(18).contains('┴') && row(18).contains('┬') && row(18).ends_with("┤"), "{}", row(18)); + assert!(row(18).contains(" click: select / move · q, Esc: quit "), "{}", row(18)); + assert!(row(23).starts_with("└ Sheep: You ─") && row(23).contains('┴') && row(23).ends_with("┘"), "{}", row(23)); + } + #[test] fn no_winner_at_the_start() { diff --git a/src/local-player.rs b/src/local-player.rs index 12a92ea..77c268c 100644 --- a/src/local-player.rs +++ b/src/local-player.rs @@ -18,6 +18,8 @@ pub struct LocalPlayer message: String, /// The position to play, for the player panel; set by `update`. status: Option<(GameBoard, Turn)>, + /// The move this player last made, for the last-turn panel. + played: Option, } /// The source and destination of a move. Placements have no source. @@ -41,6 +43,15 @@ fn is_mine(board: &GameBoard, pos: Pos, turn: Turn) -> bool impl LocalPlayer { + /// Clear the selection and note `mv` as played. + fn play(&mut self, mv: Move) -> Option + { + self.selected = None; + self.selected_moves.clear(); + self.played = Some(mv); + Some(mv) + } + fn click(&mut self, pos: Pos, board: GameBoard, turn: Turn) -> Option { self.message.clear(); @@ -51,7 +62,7 @@ impl LocalPlayer let mv = Move::PlaceSheep { pos }; if moves.contains(&mv) { - return Some(mv); + return self.play(mv); } if let Err(err) = board.check_move(mv) { @@ -62,9 +73,10 @@ impl LocalPlayer if let Some(mv) = self.selected_moves.iter().copied().find(|&m| ends(m).1 == pos) { - return Some(mv); + return self.play(mv); } - self.reset(); + self.selected = None; + self.selected_moves.clear(); if is_mine(&board, pos, turn) { let from_here: Vec = moves.into_iter().filter(|&m| ends(m).0 == Some(pos)).collect(); @@ -113,15 +125,26 @@ impl Player for LocalPlayer { self.selected = None; self.selected_moves.clear(); + self.played = None; + self.message.clear(); + } + + fn render_last_turn(&self, area: Rect, buf: &mut Buffer) + { + if let Some(mv) = self.played + { + Paragraph::new(format!("Played {mv}")).render(area, buf); + } } fn render(&self, area: Rect, buf: &mut Buffer) { - let mut text = Text::from(self.name()); + // The coordinator's panel title already shows the name + let mut text = Text::default(); if let Some((board, turn)) = self.status { let dead = TOTAL_SHEEP - board.sheep_count() - board.sheep_left(); - text.push_line(format!("Sheep in hand: {}, on board: {}, killed: {dead}", + text.push_line(format!("Sheep in hand: {} · on board: {} · killed: {dead}", board.sheep_left(), board.sheep_count())); let instruction = if turn == Turn::Sheep && board.sheep_left() > 0 { @@ -129,7 +152,7 @@ impl Player for LocalPlayer } else if self.selected.is_some() { - "Click a highlighted point to move there, or another piece to select it".to_string() + "Click a highlighted point, or another piece".to_string() } else { @@ -176,6 +199,20 @@ mod tests assert_eq!(player.handle_input(Input::Click((2, 2)), board, Turn::Sheep), Some(Move::PlaceSheep { pos: (2, 2) })); } + #[test] + fn played_move_is_remembered_until_reset() + { + let mut player = LocalPlayer::default(); + let mv = player.handle_input(Input::Click((2, 2)), GameBoard::default(), Turn::Sheep).unwrap(); + assert_eq!(player.played, Some(mv)); + let mut buf = Buffer::empty(Rect::new(0, 0, 20, 2)); + player.render_last_turn(buf.area, &mut buf); + assert_eq!((0..20).map(|x| buf[(x, 0)].symbol()).collect::().trim_end(), "Played sc3"); + + player.reset(); + assert_eq!(player.played, None); + } + #[test] fn click_on_occupied_point_explains_why() { diff --git a/src/main.rs b/src/main.rs index e83586d..54fb28f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,7 @@ mod claude_player; fn main() -> std::io::Result<()> { let mut game = GameHandler::default(); - // I play sheep, claude plays tiger - let winner = game.play_game(&mut LocalPlayer::default(), &mut ClaudePlayer::new(Duration::from_secs(2)))?; + let winner = game.play_game(&mut ClaudePlayer::new(Duration::from_secs(2)), &mut LocalPlayer::default())?; if let Some(winner) = winner { println!("{winner:?} wins!"); diff --git a/src/player.rs b/src/player.rs index 5f2bafb..3271593 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, time::{Duration, Instant}}; -use ratatui::{buffer::Buffer, crossterm::event::KeyEvent, layout::Rect, widgets::{Paragraph, Widget}}; +use ratatui::{buffer::Buffer, crossterm::event::KeyEvent, layout::Rect, widgets::{Paragraph, Widget, Wrap}}; use crate::board::{GameBoard, Move, Pos, Turn}; @@ -40,14 +40,18 @@ pub trait Player /// Points for the coordinator to highlight on the board. fn highlights(&self) -> Highlights { Highlights::default() } - /// Drop any half-made selection, e.g. after a move or a revert. + /// The game was reverted: drop any half-made selection, and anything said about the last move. fn reset(&mut self) {} - /// Draw into the player-output panel. By default this shows the player's name. + /// Draw into the player-output panel while it's this player's turn. By default this shows the player's name. fn render(&self, area: Rect, buf: &mut Buffer) { Paragraph::new(self.name()).render(area, buf); } + + /// Draw some end-of-turn comments on the move this player just made, shown beside the other + /// player's panel during their turn. By default this shows nothing. + fn render_last_turn(&self, _area: Rect, _buf: &mut Buffer) {} } /// Every player is a ratatui widget through `Player::render`. @@ -62,6 +66,17 @@ impl Widget for &dyn Player pub type ScoreType = f32; +/// What a `MinimaxPlayer`'s last `pick_move` found. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SearchReport +{ + pub mv: Move, + /// The deepest search that finished, in plies counting the move itself. + pub depth: usize, + /// The score of `mv` at `depth`; positive favours tigers, and ±`WIN` is a forced win. + pub score: ScoreType, +} + /// What a search has proved about a board's true score: it lies in `lower..=upper`. /// An alpha-beta cutoff only proves one side, so the other is left infinite. /// `best` is the move that gave the score, tried first when the position is searched again deeper. @@ -230,6 +245,12 @@ pub trait MinimaxPlayer fn time_limit(&self) -> Duration { Duration::from_secs(1) } fn name(&self) -> String { "Computer".to_string() } + + /// Override with `last_search` to keep each search's report for the last-turn panel. + /// By default it is dropped. + fn set_last_search(&mut self, _report: Option) {} + + fn last_search(&self) -> Option { None } } impl Player for P @@ -248,6 +269,7 @@ impl Player for P c.clear(); } let mut best = None; + let mut report = None; for depth in 1..=self.depth().max(1) { // Depth 1 ignores the deadline, so there is always a move to play @@ -259,6 +281,7 @@ impl Player for P break; }; best = m; + report = m.map(|mv| SearchReport { mv, depth, score }); // A forced win or loss won't change with more depth, // and past half the budget the next depth probably won't finish if score.abs() >= Self::WIN || start.elapsed() > time_limit / 2 @@ -266,13 +289,38 @@ impl Player for P break; } } + self.set_last_search(report); Some(best.expect("pick_move called with no legal moves")) } + fn reset(&mut self) + { + self.set_last_search(None); + } + fn render(&self, area: Rect, buf: &mut Buffer) { Paragraph::new(format!("{}\n\nThinking…", MinimaxPlayer::name(self))).render(area, buf); } + + fn render_last_turn(&self, area: Rect, buf: &mut Buffer) + { + let Some(r) = self.last_search() else { return }; + let score = if r.score >= Self::WIN + { + "forced win for tigers".to_string() + } + else if r.score <= -Self::WIN + { + "forced win for sheep".to_string() + } + else + { + format!("{:+.1} (+ favours tigers)", r.score) + }; + Paragraph::new(format!("Played {}\nSearched to depth {}\nScore: {score}", r.mv, r.depth)) + .wrap(Wrap { trim: true }).render(area, buf); + } } -- cgit v1.3.1