diff options
| -rw-r--r-- | CLAUDE.md | 4 | ||||
| -rw-r--r-- | src/claude-player.rs | 35 | ||||
| -rw-r--r-- | src/game.rs | 120 | ||||
| -rw-r--r-- | src/local-player.rs | 49 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/player.rs | 54 |
6 files changed, 217 insertions, 48 deletions
@@ -20,8 +20,8 @@ The toolchain comes from a Nix flake (`flake.nix`, loaded via direnv `.envrc` ↠## Architecture - `src/board.rs` — all game logic and its unit tests (`#[cfg(test)] mod tests` at the bottom). -- `src/player.rs` — the `Player` trait. Players are polled, not blocking: the coordinator calls `update(board, turn)` before each draw, `pick_move(board, turn) -> Option<Move>` once per loop (computer players search and return `Some`), and passes clicks/keys to `handle_input(Input, board, turn) -> Option<Move>`. `highlights()` gives points for the board to highlight, `reset()` drops a half-made selection, and `render(area, buf)` draws the player's panel (default: its name). `impl Widget for &dyn Player` makes every player a ratatui widget. Also the private-ish `MinimaxPlayer` trait: implementors supply `evaluate` (positive favours tigers) and optionally `depth()`/`name()`, and a blanket impl makes every `MinimaxPlayer` a `Player`. -- `src/game.rs` — `GameHandler::play_game(sheep_player, tiger_player)` runs a ratatui TUI (mouse capture on) until the user quits, returning the winner if there was one. Layout: board | move history on top, the current player's panel below. The board's bottom border lists the keybinds; keep it in step with `handle_event`. History is `Vec<(Move, GameBoard)>` (board *after* each move); the side to move comes from its length's parity (sheep first). Clicking a history row calls `revert(i)`, which goes back to the board *before* move `i`. +- `src/player.rs` — the `Player` trait. Players are polled, not blocking: the coordinator calls `update(board, turn)` before each draw, `pick_move(board, turn) -> Option<Move>` once per loop (computer players search and return `Some`), and passes clicks/keys to `handle_input(Input, board, turn) -> Option<Move>`. `highlights()` gives points for the board to highlight, `reset()` is called on both players after a revert (drop any selection and last-move comments; it is *not* called after a normal move, so players clear their own selection when they return a move), `render(area, buf)` draws the current player's panel (default: its name), and `render_last_turn(area, buf)` draws end-of-turn comments on the move it just made (default: nothing). `impl Widget for &dyn Player` makes every player a ratatui widget. Also the private-ish `MinimaxPlayer` trait: implementors supply `evaluate` (positive favours tigers) and optionally `depth()`/`name()`, plus `set_last_search`/`last_search` to keep a `SearchReport` (move, depth reached, score) that the blanket impl's `render_last_turn` shows; a blanket impl makes every `MinimaxPlayer` a `Player`. +- `src/game.rs` — `GameHandler::play_game(sheep_player, tiger_player)` runs a ratatui TUI (mouse capture on) until the user quits, returning the winner if there was one. Layout: board | move history on top; below, the current player's panel (70%) | the previous player's last-turn panel (30%). Panes overlap by one cell (`Layout::spacing(-1)`) and are built with `panel()` (`MergeStrategy::Exact`) so shared borders join up. The bottom row is drawn first and keeps its titles on its bottom border, because its top border is the same line as the board's bottom border, which lists the keybinds (keep them in step with `handle_event`). History is `Vec<(Move, GameBoard)>` (board *after* each move); the side to move comes from its length's parity (sheep first). Clicking a history row calls `revert(i)`, which goes back to the board *before* move `i`. - `src/board-view.rs` (module `board_view`) — `BoardView` widget (board with rank/file labels and highlights) and `pos_at`, which maps a mouse cell to the nearest point. Both pick the scale from the area (`scale_to_fit`: the largest whole-number scale whose `size(scale)` fits), so the coordinator sizes `board_area` to exactly `size(scale)` and the two agree. Pieces grow with the scale (`reach`): `T` at 1, `[T]` at 2, a 3x3 box from 3, which `size`, `cell` and `pos_at` all allow for. The history pane is a fixed 20 columns; the board pane gets the rest. - `src/local-player.rs` (module `local_player`, pulled in via `#[path]` because of the hyphen) — `LocalPlayer`, a human using the mouse: click a point to place a sheep, or click a piece then one of its highlighted destinations. - `src/claude-player.rs` — `ClaudePlayer`, the minimax computer player. 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<SearchReport>, } 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<SearchReport>) { self.last_search = report; } + + fn last_search(&self) -> Option<SearchReport> { 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<TestBackend> { @@ -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); @@ -343,6 +372,35 @@ mod tests } #[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::<String>(); + 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() { assert_eq!(GameHandler::default().winner(), None); 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<Move>, } /// 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<Move> + { + self.selected = None; + self.selected_moves.clear(); + self.played = Some(mv); + Some(mv) + } + fn click(&mut self, pos: Pos, board: GameBoard, turn: Turn) -> Option<Move> { 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<Move> = 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 { @@ -177,6 +200,20 @@ mod tests } #[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::<String>().trim_end(), "Played sc3"); + + player.reset(); + assert_eq!(player.played, None); + } + + #[test] fn click_on_occupied_point_explains_why() { let mut player = LocalPlayer::default(); 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<SearchReport>) {} + + fn last_search(&self) -> Option<SearchReport> { None } } impl<P: MinimaxPlayer> Player for P @@ -248,6 +269,7 @@ impl<P: MinimaxPlayer> 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<P: MinimaxPlayer> 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<P: MinimaxPlayer> 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); + } } |
