diff options
| author | Dylan <boss@tehbox.org> | 2026-10-22 17:17:02 +1300 |
|---|---|---|
| committer | Dylan <boss@tehbox.org> | 2026-10-22 17:17:02 +1300 |
| commit | 7dff6572462bdc59011f8ff8d1c6dc3a0494e44a (patch) | |
| tree | 0e9e9bd39e9ee68fd5fd0c94afffb8204c637fc2 /src | |
| parent | 15bdaa5d550115d97be3f89126c924b49c10a70f (diff) | |
| download | tigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.tar.gz tigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.zip | |
feat: Improvements to UI
Now uses ratatui
Diffstat (limited to 'src')
| -rw-r--r-- | src/board-view.rs | 412 | ||||
| -rw-r--r-- | src/board.rs | 60 | ||||
| -rw-r--r-- | src/claude-player.rs | 14 | ||||
| -rw-r--r-- | src/game.rs | 354 | ||||
| -rw-r--r-- | src/local-player.rs | 316 | ||||
| -rw-r--r-- | src/main.rs | 12 | ||||
| -rw-r--r-- | src/player.rs | 77 |
7 files changed, 1052 insertions, 193 deletions
diff --git a/src/board-view.rs b/src/board-view.rs new file mode 100644 index 0000000..d6eba71 --- /dev/null +++ b/src/board-view.rs @@ -0,0 +1,412 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::Widget}; + +use crate::{board::{GameBoard, Move, PieceType, Pos}, player::Highlights}; + +/// Columns between neighbouring points at scale 1. Twice `ROW_STEP`, since cells are about twice as tall as wide. +const COL_STEP: u16 = 4; +/// Rows between neighbouring points at scale 1. +const ROW_STEP: u16 = 2; +/// Columns taken by the rank labels on the left. +const LEFT: u16 = 3; + +/// The spacing between points, in (columns, rows), at `scale`. +const fn steps(scale: u16) -> (u16, u16) +{ + (COL_STEP * scale, ROW_STEP * scale) +} + +/// How far a drawn piece reaches past its point, in (columns, rows), at `scale`: +/// `T` at scale 1, `[T]` at scale 2, and a 3x3 box from scale 3 up. +const fn reach(scale: u16) -> (u16, u16) +{ + match scale + { + 1 => (0, 0), + 2 => (1, 0), + _ => (1, 1), + } +} + +/// The board with its lines, rank labels down the left and file labels underneath. +/// It is drawn at the largest whole-number scale that fits its area; at scale 1 it looks like: +/// +/// ```text +/// 5 T───·───·───·───T +/// │ ╲ │ ╱ │ ╲ │ ╱ │ +/// 4 ·───·───·───·───· +/// … +/// 1 T───·───·───·───T +/// +/// a b c d e +/// ``` +pub struct BoardView +{ + pub board: GameBoard, + pub highlights: Highlights, + pub last_move: Option<Move>, +} + +impl BoardView +{ + /// The (width, height) the board needs at `scale` (1 or more): the grid, the pieces sticking + /// out past its edge, then a blank row and the file labels. + pub const fn size(scale: u16) -> (u16, u16) + { + let (cs, rs) = steps(scale); + let (rx, ry) = reach(scale); + (LEFT + 4 * cs + 1 + rx, ry + 4 * rs + 1 + ry + 2) + } + + /// The largest scale whose board fits in `width` by `height`; 1 if even that doesn't fit. + pub fn scale_to_fit(width: u16, height: u16) -> u16 + { + let fits = |scale| { let (w, h) = BoardView::size(scale); w <= width && h <= height }; + let mut scale = 1; + while fits(scale + 1) + { + scale += 1; + } + scale + } + + /// The screen cell a point is drawn at at `scale`, relative to the widget's top-left. + /// The grid sits below a row for the top pieces' boxes once they are boxed. + fn cell((x, y): Pos, scale: u16) -> (u16, u16) + { + let (cs, rs) = steps(scale); + (LEFT + x as u16 * cs, reach(scale).1 + y as u16 * rs) + } + + fn piece_style(&self, pos: Pos, piece: &PieceType) -> Style + { + let mut style = match piece + { + PieceType::Tiger => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), + PieceType::Sheep => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), + PieceType::Empty => Style::new().fg(Color::DarkGray), + }; + let (from, to) = match self.last_move + { + Some(Move::PlaceSheep { pos }) => (None, Some(pos)), + Some(Move::MoveSheep { from, to } | Move::MoveTiger { from, to }) => (Some(from), Some(to)), + None => (None, None), + }; + if from == Some(pos) || to == Some(pos) + { + style = style.add_modifier(Modifier::UNDERLINED); + } + if self.highlights.targets.contains(&pos) + { + style = style.fg(Color::Green).add_modifier(Modifier::BOLD); + } + if self.highlights.selected == Some(pos) + { + style = style.add_modifier(Modifier::REVERSED); + } + style + } +} + +impl Widget for &BoardView +{ + fn render(self, area: Rect, buf: &mut Buffer) + { + let scale = BoardView::scale_to_fit(area.width, area.height); + let (cs, rs) = steps(scale); + let lines = Style::new().fg(Color::Gray); + let labels = Style::new().fg(Color::Cyan); + // Clip to the area so a small terminal doesn't index outside the buffer + let mut put = |cx: u16, cy: u16, s: &str, style: Style| + { + if cx < area.width && cy < area.height + { + buf.set_string(area.x + cx, area.y + cy, s, style); + } + }; + + // Lines first, so the pieces are drawn over them + for y in 0..5u8 + { + for x in 0..5u8 + { + let (cx, cy) = BoardView::cell((x, y), scale); + if x < 4 + { + put(cx + 1, cy, &"─".repeat(cs as usize - 1), lines); + } + if y < 4 + { + for k in 1..rs + { + put(cx, cy + k, "│", lines); + } + if x < 4 + { + // Diagonals run through points where x + y is even (see `GameBoard::on_line`). + // Each row down the diagonal moves cs / rs = 2 columns across. + let down_right = (x + y) % 2 == 0; + for k in 1..rs + { + let col = if down_right { cx + 2 * k } else { cx + cs - 2 * k }; + put(col, cy + k, if down_right { "╲" } else { "╱" }, lines); + } + } + } + } + } + + for y in 0..5u8 + { + let (_, cy) = BoardView::cell((0, y), scale); + put(0, cy, &(5 - y).to_string(), labels); + for x in 0..5u8 + { + let (cx, cy) = BoardView::cell((x, y), scale); + let piece = self.board.get((x, y)); + let targeted = self.highlights.targets.contains(&(x, y)); + let symbol = match piece + { + PieceType::Tiger => "T", + PieceType::Sheep => "S", + PieceType::Empty if targeted => "●", + PieceType::Empty => "·", + }; + let style = self.piece_style((x, y), &piece); + // The last move's underline is only for the symbol, not the box around it + let frame = style.remove_modifier(Modifier::UNDERLINED); + let boxed = targeted || !matches!(piece, PieceType::Empty); + match scale + { + _ if !boxed => put(cx, cy, symbol, style), + 1 => put(cx, cy, symbol, style), + 2 => + { + put(cx - 1, cy, "[", frame); + put(cx, cy, symbol, style); + put(cx + 1, cy, "]", frame); + } + _ => + { + // Where a line meets the box, join it on + put(cx - 1, cy - 1, if y > 0 { "┌┴┐" } else { "┌─┐" }, frame); + put(cx - 1, cy, if x > 0 { "┤" } else { "│" }, frame); + put(cx, cy, symbol, style); + put(cx + 1, cy, if x < 4 { "├" } else { "│" }, frame); + put(cx - 1, cy + 1, if y < 4 { "└┬┘" } else { "└─┘" }, frame); + } + } + } + } + let files_row = BoardView::size(scale).1 - 1; + for x in 0..5u8 + { + let (cx, _) = BoardView::cell((x, 0), scale); + put(cx, files_row, &((b'a' + x) as char).to_string(), labels); + } + } +} + +/// The board point nearest a click at screen cell (`column`, `row`), for a `BoardView` drawn in `area` +/// (at the same scale `render` picks for it). Clicks on the lines between points go to the nearest +/// point; clicks off the grid give None. +pub fn pos_at(area: Rect, column: u16, row: u16) -> Option<Pos> +{ + let scale = BoardView::scale_to_fit(area.width, area.height); + let (cs, rs) = steps(scale); + let (rx, ry) = reach(scale); + // Offsets from the top-left point, letting clicks land on the edge pieces' boxes + let cx = column.checked_sub(area.x + LEFT - rx)?; + let cy = row.checked_sub(area.y)?; + if cx > 4 * cs + 1 + rx || cy > 4 * rs + 2 * ry + { + return None; + } + // Shifted by the reach, so (cx, cy) are offsets from one column/row before the first point + let x = ((cx + cs / 2 - rx) / cs).min(4); + let y = ((cy + rs / 2 - ry) / rs).min(4); + Some((x as u8, y as u8)) +} + +#[cfg(test)] +mod tests +{ + use super::*; + + fn rendered(view: &BoardView, area: Rect) -> Buffer + { + let mut buf = Buffer::empty(Rect::new(0, 0, area.right() + 2, area.bottom() + 2)); + view.render(area, &mut buf); + buf + } + + fn symbol(buf: &Buffer, x: u16, y: u16) -> &str + { + buf[(x, y)].symbol() + } + + #[test] + fn renders_default_board_with_labels() + { + let area = Rect::new(1, 1, BoardView::size(1).0, BoardView::size(1).1); + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, area); + for (pos, s) in [((0, 0), "T"), ((4, 0), "T"), ((0, 4), "T"), ((4, 4), "T"), ((2, 2), "·")] + { + let (cx, cy) = BoardView::cell(pos, 1); + assert_eq!(symbol(&buf, area.x + cx, area.y + cy), s, "{pos:?}"); + } + assert_eq!(symbol(&buf, area.x, area.y), "5"); + assert_eq!(symbol(&buf, area.x, area.y + 8), "1"); + assert_eq!(symbol(&buf, area.x + LEFT, area.y + BoardView::size(1).1 - 1), "a"); + assert_eq!(symbol(&buf, area.x + LEFT + 16, area.y + BoardView::size(1).1 - 1), "e"); + // (0, 0) has an even sum, so its diagonal runs down-right + assert_eq!(symbol(&buf, area.x + LEFT + 2, area.y + 1), "╲"); + assert_eq!(symbol(&buf, area.x + LEFT + 6, area.y + 1), "╱"); + } + + #[test] + fn renders_target_highlights() + { + let area = Rect::new(0, 0, BoardView::size(1).0, BoardView::size(1).1); + let highlights = Highlights { selected: Some((0, 0)), targets: vec![(1, 1)] }; + let view = BoardView { board: GameBoard::default(), highlights, last_move: None }; + let buf = rendered(&view, area); + let (cx, cy) = BoardView::cell((1, 1), 1); + assert_eq!(symbol(&buf, cx, cy), "●"); + let (cx, cy) = BoardView::cell((0, 0), 1); + assert!(buf[(cx, cy)].modifier.contains(Modifier::REVERSED)); + } + + #[test] + fn renders_into_a_small_area_without_panicking() + { + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + rendered(&view, Rect::new(0, 0, 5, 3)); + } + + #[test] + fn scale_to_fit_picks_the_largest_board_that_fits() + { + for scale in 1..5 + { + let (w, h) = BoardView::size(scale); + assert_eq!(BoardView::scale_to_fit(w, h), scale); + assert_eq!(BoardView::scale_to_fit(w + 5, h + 5), scale); + assert_eq!(BoardView::scale_to_fit(w - 1, h), (scale - 1).max(1)); + assert_eq!(BoardView::scale_to_fit(w, h - 1), (scale - 1).max(1)); + } + assert_eq!(BoardView::scale_to_fit(0, 0), 1); + } + + #[test] + fn renders_bigger_when_there_is_room() + { + let (w, h) = BoardView::size(2); + let area = Rect::new(0, 0, w, h); + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, area); + let (cx, cy) = BoardView::cell((4, 4), 2); + assert_eq!((cx, cy), (LEFT + 32, 16)); + assert_eq!(symbol(&buf, cx, cy), "T"); + assert_eq!(symbol(&buf, 0, 16), "1"); + assert_eq!(symbol(&buf, cx, h - 1), "e"); + // Lines fill the gaps: three rows of │ between points, a diagonal two columns per row + for k in 1..4 + { + assert_eq!(symbol(&buf, LEFT, k), "│"); + assert_eq!(symbol(&buf, LEFT + 2 * k, k), "╲"); + } + assert_eq!(symbol(&buf, LEFT + 7, 0), "─"); + } + + fn row(buf: &Buffer, y: u16) -> String + { + (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect() + } + + #[test] + fn pieces_get_brackets_at_scale_2() + { + let (w, h) = BoardView::size(2); + let highlights = Highlights { selected: None, targets: vec![(1, 1)] }; + let view = BoardView { board: GameBoard::default(), highlights, last_move: None }; + let buf = rendered(&view, Rect::new(0, 0, w, h)); + assert!(row(&buf, 0).starts_with("5 [T]──────·"), "{}", row(&buf, 0)); + assert!(row(&buf, 4).starts_with("4 ·──────[●]──────·"), "{}", row(&buf, 4)); + } + + #[test] + fn pieces_get_boxes_from_scale_3() + { + let (w, h) = BoardView::size(3); + let mut board = GameBoard::default(); + board.make_move(Move::PlaceSheep { pos: (2, 2) }).unwrap(); + let view = BoardView { board, highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, Rect::new(0, 0, w, h)); + // Corner tiger: lines only join on the sides that have them + assert!(row(&buf, 0).starts_with(" ┌─┐"), "{}", row(&buf, 0)); + assert!(row(&buf, 1).starts_with("5 │T├───"), "{}", row(&buf, 1)); + assert!(row(&buf, 2).starts_with(" └┬┘"), "{}", row(&buf, 2)); + // Centre sheep: lines join on all four sides + let (cx, cy) = BoardView::cell((2, 2), 3); + let around = |dy: u16| (cx - 1..=cx + 1).map(|x| buf[(x, cy + dy - 1)].symbol()).collect::<String>(); + assert_eq!([around(0), around(1), around(2)], ["┌┴┐", "┤S├", "└┬┘"]); + assert_eq!(symbol(&buf, cx, h - 1), "c"); + } + + #[test] + fn pos_at_takes_clicks_on_a_piece_box() + { + let (w, h) = BoardView::size(3); + let area = Rect::new(4, 3, w, h); + for pos in [(0, 0), (4, 4), (2, 2), (4, 0)] + { + let (cx, cy) = BoardView::cell(pos, 3); + for (dx, dy) in [(-1, -1), (1, 1), (-1, 1), (1, -1)] + { + let column = (area.x + cx).checked_add_signed(dx).unwrap(); + let row = (area.y + cy).checked_add_signed(dy).unwrap(); + assert_eq!(pos_at(area, column, row), Some(pos), "{pos:?} {dx},{dy}"); + } + } + assert_eq!(pos_at(area, area.x + 1, area.y), None); + assert_eq!(pos_at(area, area.x + LEFT, area.y + h - 1), None); // file labels + } + + #[test] + fn pos_at_finds_every_drawn_point() + { + for scale in 1..6 + { + let (w, h) = BoardView::size(scale); + let area = Rect::new(3, 2, w, h); + for y in 0..5 + { + for x in 0..5 + { + let (cx, cy) = BoardView::cell((x, y), scale); + assert_eq!(pos_at(area, area.x + cx, area.y + cy), Some((x, y)), "scale {scale}"); + } + } + } + } + + #[test] + fn pos_at_snaps_line_clicks_to_nearest_point() + { + let area = Rect::new(0, 0, BoardView::size(1).0, BoardView::size(1).1); + assert_eq!(pos_at(area, LEFT + 1, 0), Some((0, 0))); + assert_eq!(pos_at(area, LEFT + 3, 0), Some((1, 0))); + assert_eq!(pos_at(area, LEFT + 16, 1), Some((4, 1))); + } + + #[test] + fn pos_at_rejects_clicks_off_the_grid() + { + let area = Rect::new(2, 2, BoardView::size(1).0, BoardView::size(1).1); + assert_eq!(pos_at(area, 0, 0), None); + assert_eq!(pos_at(area, area.x, area.y), None); // rank label + assert_eq!(pos_at(area, area.x + LEFT, area.y + 9), None); // below the last rank + assert_eq!(pos_at(area, area.x + LEFT + 18, area.y), None); + } +} diff --git a/src/board.rs b/src/board.rs index 500241f..51a2593 100644 --- a/src/board.rs +++ b/src/board.rs @@ -1,6 +1,6 @@ use std::{fmt::Display}; -type Pos = (u8, u8); +pub type Pos = (u8, u8); pub enum PieceType { @@ -20,7 +20,7 @@ impl Display for PieceType } } -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct GameBoard { sheep_mask: u32, @@ -53,6 +53,42 @@ pub enum Move MoveTiger{from: Pos, to: Pos} } +/// A point in chess-style notation: files `a`–`e` for x 0–4, ranks `5`–`1` for y 0–4 +/// (so y = 0, drawn at the top, is rank 5). +pub fn pos_name((x, y): Pos) -> String +{ + format!("{}{}", (b'a' + x) as char, 5 - y) +} + +/// True for a tiger move of two points, which always jumps (and kills) a sheep. +pub fn is_jump(mv: Move) -> bool +{ + match mv + { + Move::MoveTiger { from: (fx, fy), to: (tx, ty) } => fx.abs_diff(tx) == 2 || fy.abs_diff(ty) == 2, + _ => false, + } +} + +/// Chess-like notation: `s<pos>` places a sheep, `s<from><to>` moves one, +/// `t<from><to>` moves a tiger and `t<from>x<to>` is a tiger jump. +impl Display for Move +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result + { + match *self + { + Move::PlaceSheep { pos } => write!(f, "s{}", pos_name(pos)), + Move::MoveSheep { from, to } => write!(f, "s{}{}", pos_name(from), pos_name(to)), + Move::MoveTiger { from, to } => + { + let x = if is_jump(*self) { "x" } else { "" }; + write!(f, "t{}{x}{}", pos_name(from), pos_name(to)) + } + } + } +} + /// Why `check_move` rejected a move. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MoveError @@ -458,6 +494,26 @@ mod tests fn is_sheep(board: &GameBoard, pos: Pos) -> bool { matches!(board.get(pos), PieceType::Sheep) } fn is_empty(board: &GameBoard, pos: Pos) -> bool { matches!(board.get(pos), PieceType::Empty) } + // --- notation --- + + #[test] + fn pos_name_uses_files_and_ranks() + { + assert_eq!(pos_name((0, 4)), "a1"); + assert_eq!(pos_name((0, 0)), "a5"); + assert_eq!(pos_name((4, 4)), "e1"); + assert_eq!(pos_name((2, 2)), "c3"); + } + + #[test] + fn move_display_is_chess_like() + { + assert_eq!(Move::PlaceSheep { pos: (2, 2) }.to_string(), "sc3"); + assert_eq!(Move::MoveSheep { from: (2, 2), to: (2, 3) }.to_string(), "sc3c2"); + assert_eq!(Move::MoveTiger { from: (0, 0), to: (1, 1) }.to_string(), "ta5b4"); + assert_eq!(Move::MoveTiger { from: (0, 0), to: (2, 2) }.to_string(), "ta5xc3"); + } + // --- bit_idx / get / set --- #[test] diff --git a/src/claude-player.rs b/src/claude-player.rs index 4590c12..0a54ab5 100644 --- a/src/claude-player.rs +++ b/src/claude-player.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, time::Duration}; -use crate::{board::{GameBoard, Move, PieceType, Turn}, player::{CacheEntry, MinimaxPlayer}}; +use crate::{board::{is_jump, GameBoard, Move, PieceType, Turn}, player::{CacheEntry, MinimaxPlayer}}; /// Sheep that start the game: on the board plus in hand. const TOTAL_SHEEP: u32 = 20; @@ -61,19 +61,11 @@ impl MinimaxPlayer for ClaudePlayer + 2.0 * tiger_moves.len() as f32 } + fn name(&self) -> String { "Claude".to_string() } + fn time_limit(&self) -> Duration { self.time_limit } fn cache(&mut self) -> Option<&mut dyn crate::player::ScoreCache> { Some(&mut self.cache) } } - -/// True for a tiger move of two points, which always jumps (and kills) a sheep. -fn is_jump(mv: Move) -> bool -{ - match mv - { - Move::MoveTiger { from: (fx, fy), to: (tx, ty) } => fx.abs_diff(tx) == 2 || fy.abs_diff(ty) == 2, - _ => false, - } -} diff --git a/src/game.rs b/src/game.rs index 96f71b3..73ae583 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,44 +1,350 @@ -use crate::{board::{GameBoard, Turn}, player::Player}; +use std::io; +use ratatui::{ + crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}, execute}, + layout::{Constraint, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + DefaultTerminal, Frame, +}; + +use crate::{board::{GameBoard, Move, Turn}, board_view::{pos_at, BoardView}, player::{Input, Player}}; + +/// Runs a game in the terminal: draws the board, the move history and the current player's panel, +/// and passes mouse clicks on the board to the player whose turn it is. +#[derive(Default)] pub struct GameHandler { - board: GameBoard, + start: GameBoard, + /// Every move played so far, with the board after it. Sheep play the even-numbered moves. + history: Vec<(Move, GameBoard)>, + /// Where the last draw put the board, for turning clicks into points. + board_area: Rect, + /// Where the last draw put each visible history entry, with its index in `history`. + history_rows: Vec<(Rect, usize)>, +} + +/// What to do after an event. +enum Action +{ + None, + Quit, + Play(Move), + Revert(usize), } -impl Default for GameHandler +fn side_name(turn: Turn) -> &'static str { - fn default() -> Self + match turn { - Self - { - board: GameBoard::default() - } + Turn::Sheep => "Sheep", + Turn::Tiger => "Tigers", } } impl GameHandler { - // fn play_move<T: Player>(&mut self, player: &mut T) - // { - // let m = player.pick_move(self.board); - // self.board.make_move(m).unwrap(); - // } + /// The current board. + pub fn board(&self) -> GameBoard + { + self.history.last().map_or(self.start, |&(_, board)| board) + } + + /// The side to move. Sheep move first. + pub fn turn(&self) -> Turn + { + if self.history.len().is_multiple_of(2) { Turn::Sheep } else { Turn::Tiger } + } + + /// The side that has won, if the game is over. + pub fn winner(&self) -> Option<Turn> + { + // check_win runs before `turn`'s move, so whoever moved last has won. + self.board().check_win(self.turn()).then(|| self.turn().next_turn()) + } + + /// Play `m` for the side to move. Panics if it is illegal. + pub fn play(&mut self, m: Move) + { + let mut board = self.board(); + board.make_move(m).unwrap(); + self.history.push((m, board)); + } + + /// Go back to the board before move `i` (0-based), dropping it and every move after it. + pub fn revert(&mut self, i: usize) + { + self.history.truncate(i); + } - /// Play until someone wins, with `p1` as the sheep and `p2` as the tigers. Returns the winner. - pub fn play_game<T1: Player, T2: Player>(&mut self, p1: &mut T1, p2: &mut T2) -> Turn + /// Play in the terminal with `p1` as the sheep and `p2` as the tigers, until someone wins and + /// the user quits. Returns the winner, or None if the user quit before the game was won. + pub fn play_game<T1: Player, T2: Player>(&mut self, p1: &mut T1, p2: &mut T2) -> io::Result<Option<Turn>> { - let mut turn = Turn::Sheep; - while !self.board.check_win(turn) + let mut terminal = ratatui::init(); + let result = execute!(io::stdout(), EnableMouseCapture) + .and_then(|()| self.run(&mut terminal, [p1, p2])); + let _ = execute!(io::stdout(), DisableMouseCapture); + ratatui::restore(); + result + } + + fn run(&mut self, terminal: &mut DefaultTerminal, mut players: [&mut dyn Player; 2]) -> io::Result<Option<Turn>> + { + let idx = |turn: Turn| match turn { Turn::Sheep => 0, Turn::Tiger => 1 }; + loop { - let m = match turn + let (board, turn) = (self.board(), self.turn()); + let over = self.winner().is_some(); + players[idx(turn)].update(board, turn); + // Drawn before `pick_move`, so a computer's "thinking" panel is on screen while it searches + terminal.draw(|frame| self.draw(frame, &players))?; + + let player = &mut *players[idx(turn)]; + let action = match (!over).then(|| player.pick_move(board, turn)).flatten() { - Turn::Sheep => p1.pick_move(self.board, turn), - Turn::Tiger => p2.pick_move(self.board, turn), + Some(m) => Action::Play(m), + None => self.handle_event(event::read()?, player, board, turn, over), }; - self.board.make_move(m).unwrap(); - turn = turn.next_turn(); + + match action + { + Action::None => (), + Action::Quit => return Ok(self.winner()), + Action::Play(m) => + { + player.reset(); + self.play(m); + } + Action::Revert(i) => + { + self.revert(i); + players.iter_mut().for_each(|p| p.reset()); + } + } } - // check_win runs before `turn`'s move, so whoever moved last has won. - turn.next_turn() + } + + fn handle_event(&self, ev: Event, player: &mut dyn Player, board: GameBoard, turn: Turn, over: bool) -> Action + { + match ev + { + Event::Key(key) if key.kind == KeyEventKind::Press => + { + let ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl_c || matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) + { + return Action::Quit; + } + if over + { + return Action::None; + } + player.handle_input(Input::Key(key), board, turn).map_or(Action::None, Action::Play) + } + Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => + { + let at = (mouse.column, mouse.row).into(); + if let Some(&(_, i)) = self.history_rows.iter().find(|(rect, _)| rect.contains(at)) + { + return Action::Revert(i); + } + match pos_at(self.board_area, mouse.column, mouse.row) + { + Some(pos) if !over => player.handle_input(Input::Click(pos), board, turn).map_or(Action::None, Action::Play), + _ => Action::None, + } + } + _ => Action::None, + } + } + + 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()); + // 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 winner = self.winner(); + let turn = self.turn(); + let player = &*players[match turn { Turn::Sheep => 0, Turn::Tiger => 1 }]; + + // Board + // Keep in step with `handle_event` + let block = Block::bordered().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 + let room = inner.inner(Margin::new(1, 1)); + let (board_w, board_h) = BoardView::size(BoardView::scale_to_fit(room.width, room.height)); + let padded = room.centered(Constraint::Length(board_w), Constraint::Length(board_h)); + self.board_area = padded; + let view = BoardView + { + board: self.board(), + highlights: if winner.is_none() { player.highlights() } else { Default::default() }, + last_move: self.history.last().map(|&(m, _)| m), + }; + 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 inner = block.inner(history_pane); + frame.render_widget(block, history_pane); + self.history_rows.clear(); + let first = self.history.len().saturating_sub(inner.height as usize); + for (row, (i, (m, _))) in self.history.iter().enumerate().skip(first).enumerate() + { + let rect = Rect { y: inner.y + row as u16, height: 1, ..inner }; + let side = if i % 2 == 0 { Color::White } else { Color::Yellow }; + let line = Line::from(vec![ + Span::styled(format!("{:>3}. ", i + 1), Style::new().fg(Color::DarkGray)), + Span::styled(format!("{:<8}", m.to_string()), Style::new().fg(side)), + Span::styled("[↶]", Style::new().fg(Color::Cyan)), + ]); + 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)] +mod tests +{ + use super::*; + + #[test] + fn turn_alternates_starting_with_sheep() + { + let mut game = GameHandler::default(); + assert_eq!(game.turn(), Turn::Sheep); + game.play(Move::PlaceSheep { pos: (2, 2) }); + assert_eq!(game.turn(), Turn::Tiger); + game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) }); + assert_eq!(game.turn(), Turn::Sheep); + } + + #[test] + fn revert_goes_back_to_before_the_move() + { + let mut game = GameHandler::default(); + let start = game.board(); + game.play(Move::PlaceSheep { pos: (2, 2) }); + let after_first = game.board(); + game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) }); + game.play(Move::PlaceSheep { pos: (3, 3) }); + + game.revert(1); + assert_eq!(game.board(), after_first); + assert_eq!(game.turn(), Turn::Tiger); + assert_eq!(game.history.len(), 1); + + game.revert(0); + assert_eq!(game.board(), start); + assert_eq!(game.turn(), Turn::Sheep); + } + + #[test] + #[should_panic] + fn play_rejects_illegal_moves() + { + GameHandler::default().play(Move::PlaceSheep { pos: (0, 0) }); + } + + use ratatui::{backend::TestBackend, crossterm::event::{MouseEvent, KeyEvent}, Terminal}; + use crate::local_player::LocalPlayer; + + fn draw(game: &mut GameHandler, players: &[&mut dyn Player; 2]) -> Terminal<TestBackend> + { + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + terminal.draw(|frame| game.draw(frame, players)).unwrap(); + terminal + } + + fn click(column: u16, row: u16) -> Event + { + Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row, modifiers: KeyModifiers::NONE }) + } + + #[test] + fn clicks_on_the_board_and_history() + { + let mut game = GameHandler::default(); + let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default()); + sheep.update(game.board(), game.turn()); + let terminal = draw(&mut game, &[&mut sheep, &mut tiger]); + let screen = format!("{:?}", terminal.backend().buffer()); + assert!(screen.contains("Sheep: You"), "{screen}"); + assert!(screen.contains("Click an empty point to place a sheep"), "{screen}"); + assert!(screen.contains(" a b c d e"), "{screen}"); + assert!(screen.contains("5 T───·───·───·───T"), "{screen}"); + + // Click c3, the centre point + let area = game.board_area; + let action = game.handle_event(click(area.x + 3 + 8, area.y + 4), &mut sheep, game.board(), game.turn(), false); + let Action::Play(m) = action else { panic!("no move") }; + assert_eq!(m, Move::PlaceSheep { pos: (2, 2) }); + game.play(m); + + let terminal = draw(&mut game, &[&mut sheep, &mut tiger]); + let screen = format!("{:?}", terminal.backend().buffer()); + assert!(screen.contains("1. sc3 [↶]"), "{screen}"); + assert!(screen.contains("Tigers: You"), "{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); + assert!(matches!(action, Action::Revert(0)) && i == 0); + + let q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(matches!(game.handle_event(q, &mut tiger, game.board(), game.turn(), false), Action::Quit)); + } + + #[test] + fn board_grows_in_a_big_terminal() + { + let mut game = GameHandler::default(); + game.play(Move::PlaceSheep { pos: (2, 2) }); + let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default()); + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| game.draw(frame, &[&mut sheep, &mut tiger])).unwrap(); + let area = game.board_area; + assert!(area.height > BoardView::size(1).1, "{area:?}"); + let (w, h) = (area.width, area.height); + assert_eq!(BoardView::size(BoardView::scale_to_fit(w, h)), (w, h)); + + // Find the sheep on screen and click it + let buf = terminal.backend().buffer(); + let at = (area.left()..area.right()).flat_map(|x| (area.top()..area.bottom()).map(move |y| (x, y))) + .find(|&(x, y)| buf[(x, y)].symbol() == "S").expect("sheep drawn"); + assert_eq!(pos_at(area, at.0, at.1), Some((2, 2))); + } + + #[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 962460e..12a92ea 100644 --- a/src/local-player.rs +++ b/src/local-player.rs @@ -1,156 +1,148 @@ -use std::io::{self, BufRead, Write}; +use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Style}, text::{Line, Text}, widgets::{Paragraph, Widget, Wrap}}; -use crate::{board::{GameBoard, Move, PieceType, Turn}, player::Player}; +use crate::{board::{GameBoard, Move, PieceType, Pos, Turn}, player::{Highlights, Input, Player}}; -/// A human player at the terminal. Shows the board, lists the legal moves and reads a choice from stdin. -pub struct LocalPlayer; +/// Sheep that start the game: on the board plus in hand. +const TOTAL_SHEEP: u32 = 20; -impl Player for LocalPlayer +/// A human player at the terminal, using the mouse: click a piece to select it, then click one +/// of its highlighted destinations. While sheep are in hand, click an empty point to place one. +#[derive(Default)] +pub struct LocalPlayer +{ + /// The piece whose moves are highlighted. + selected: Option<Pos>, + /// The legal moves of `selected`, kept for `highlights`. + selected_moves: Vec<Move>, + /// Why the last click didn't do anything, if it didn't. + message: String, + /// The position to play, for the player panel; set by `update`. + status: Option<(GameBoard, Turn)>, +} + +/// The source and destination of a move. Placements have no source. +fn ends(mv: Move) -> (Option<Pos>, Pos) +{ + match mv + { + Move::PlaceSheep { pos } => (None, pos), + Move::MoveSheep { from, to } | Move::MoveTiger { from, to } => (Some(from), to), + } +} + +fn is_mine(board: &GameBoard, pos: Pos, turn: Turn) -> bool { - fn pick_move(&mut self, board: GameBoard, turn: Turn) -> Move + match turn { - let mut moves = board.get_moves(turn); - moves.sort(); - print!("{}", render_board(&board)); - println!(); - println!("{:?} to move. Sheep in hand: {}, on board: {}", turn, board.sheep_left(), board.sheep_count()); - for (i, mv) in moves.iter().enumerate() + Turn::Tiger => matches!(board.get(pos), PieceType::Tiger), + Turn::Sheep => matches!(board.get(pos), PieceType::Sheep), + } +} + +impl LocalPlayer +{ + fn click(&mut self, pos: Pos, board: GameBoard, turn: Turn) -> Option<Move> + { + self.message.clear(); + let moves = board.get_moves(turn); + + if turn == Turn::Sheep && board.sheep_left() > 0 { - println!("{:>3}) {}", i + 1, describe_move(*mv)); + let mv = Move::PlaceSheep { pos }; + if moves.contains(&mv) + { + return Some(mv); + } + if let Err(err) = board.check_move(mv) + { + self.message = err.to_string(); + } + return None; } - println!("Enter a move number, a point \"x y\" to place, or \"x y x y\" to move from/to. \"q\" quits."); - let stdin = io::stdin(); - let mut lines = stdin.lock().lines(); - loop + if let Some(mv) = self.selected_moves.iter().copied().find(|&m| ends(m).1 == pos) { - print!("> "); - io::stdout().flush().unwrap(); - let line = match lines.next() - { - Some(Ok(line)) => line, - _ => std::process::exit(0), - }; - if line.trim() == "q" + return Some(mv); + } + self.reset(); + if is_mine(&board, pos, turn) + { + let from_here: Vec<Move> = moves.into_iter().filter(|&m| ends(m).0 == Some(pos)).collect(); + if from_here.is_empty() { - std::process::exit(0); + self.message = "That piece can't move".to_string(); } - match parse_choice(&line, &board, turn, &moves) + else { - Ok(mv) => return mv, - Err(err) => println!("{err}"), + self.selected = Some(pos); + self.selected_moves = from_here; } } + None } } -/// Turn a line of input into one of `moves`, or explain why it isn't one. -fn parse_choice(input: &str, board: &GameBoard, turn: Turn, moves: &[Move]) -> Result<Move, String> +impl Player for LocalPlayer { - let nums: Vec<u8> = input - .split(|c: char| c.is_whitespace() || c == ',') - .filter(|s| !s.is_empty()) - .map(|s| s.parse().map_err(|_| format!("\"{s}\" isn't a number"))) - .collect::<Result<_, _>>()?; - - let mv = match nums[..] - { - [n] => - { - return moves.get((n as usize).wrapping_sub(1)).copied() - .ok_or_else(|| format!("Pick a move from 1 to {}", moves.len())); - } - [x, y] => Move::PlaceSheep { pos: (x, y) }, - [fx, fy, tx, ty] => match turn - { - Turn::Sheep => Move::MoveSheep { from: (fx, fy), to: (tx, ty) }, - Turn::Tiger => Move::MoveTiger { from: (fx, fy), to: (tx, ty) }, - }, - _ => return Err("Enter 1, 2 or 4 numbers".to_string()), - }; + fn name(&self) -> String { "You".to_string() } - if points(mv).iter().any(|&(x, y)| x > 4 || y > 4) + fn update(&mut self, board: GameBoard, turn: Turn) { - return Err("Points go from 0 to 4".to_string()); + self.status = Some((board, turn)); } - if moves.contains(&mv) - { - return Ok(mv); - } - match board.check_move(mv) - { - Err(err) => Err(err.to_string()), - // Legal by the rules but not this side's move, e.g. placing as the tiger. - Ok(()) => Err(format!("{} isn't a move for {:?}", describe_move(mv), turn)), - } -} -fn points(mv: Move) -> Vec<(u8, u8)> -{ - match mv + fn handle_input(&mut self, input: Input, board: GameBoard, turn: Turn) -> Option<Move> { - Move::PlaceSheep { pos } => vec![pos], - Move::MoveSheep { from, to } | Move::MoveTiger { from, to } => vec![from, to], + match input + { + Input::Click(pos) => self.click(pos, board, turn), + Input::Key(_) => None, + } } -} -fn describe_move(mv: Move) -> String -{ - match mv + fn highlights(&self) -> Highlights { - Move::PlaceSheep { pos: (x, y) } => format!("Place sheep at {x} {y}"), - Move::MoveSheep { from: (fx, fy), to: (tx, ty) } => format!("Sheep {fx} {fy} -> {tx} {ty}"), - Move::MoveTiger { from: (fx, fy), to: (tx, ty) } => + Highlights { - let jump = if fx.abs_diff(tx) == 2 || fy.abs_diff(ty) == 2 { " (jump)" } else { "" }; - format!("Tiger {fx} {fy} -> {tx} {ty}{jump}") + selected: self.selected, + targets: self.selected_moves.iter().map(|&m| ends(m).1).collect(), } } -} -/// The board with its lines, x across the top and y down the side: -/// -/// ```text -/// 0 1 2 3 4 -/// 0 T - . - . - . - T -/// | \ | / | \ | / | -/// 1 . - . - . - . - . -/// ``` -fn render_board(board: &GameBoard) -> String -{ - let mut out = String::from(" 0 1 2 3 4\n"); - for y in 0..5u8 + fn reset(&mut self) + { + self.selected = None; + self.selected_moves.clear(); + } + + fn render(&self, area: Rect, buf: &mut Buffer) { - out += &format!("{y} "); - for x in 0..5u8 + let mut text = Text::from(self.name()); + if let Some((board, turn)) = self.status { - out += match board.get((x, y)) + let dead = TOTAL_SHEEP - board.sheep_count() - board.sheep_left(); + 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 { - PieceType::Tiger => "T", - PieceType::Sheep => "S", - PieceType::Empty => ".", - }; - if x < 4 + "Click an empty point to place a sheep".to_string() + } + else if self.selected.is_some() { - out += " - "; + "Click a highlighted point to move there, or another piece to select it".to_string() } + else + { + format!("Click a {} to move", if turn == Turn::Tiger { "tiger" } else { "sheep" }) + }; + text.push_line(instruction); } - out += "\n"; - if y < 4 + if !self.message.is_empty() { - out += " "; - for x in 0..5u8 - { - out += "|"; - if x < 4 - { - out += if (x + y) % 2 == 0 { " \\ " } else { " / " }; - } - } - out += "\n"; + text.push_line(Line::styled(self.message.clone(), Style::new().fg(Color::Red))); } + Paragraph::new(text).wrap(Wrap { trim: false }).render(area, buf); } - out } #[cfg(test)] @@ -158,52 +150,86 @@ mod tests { use super::*; - #[test] - fn render_default_board() + /// The default board with all 20 sheep placed, on every point but (2, 1). + fn moving_board() -> GameBoard { - let expected = " 0 1 2 3 4\n\ - 0 T - . - . - . - T\n | \\ | / | \\ | / |\n\ - 1 . - . - . - . - .\n | / | \\ | / | \\ |\n\ - 2 . - . - . - . - .\n | \\ | / | \\ | / |\n\ - 3 . - . - . - . - .\n | / | \\ | / | \\ |\n\ - 4 T - . - . - . - T\n"; - assert_eq!(render_board(&GameBoard::default()), expected); + let mut board = GameBoard::default(); + for y in 0..5 + { + for x in 0..5 + { + if (x, y) != (2, 1) && matches!(board.get((x, y)), PieceType::Empty) + { + board.make_move(Move::PlaceSheep { pos: (x, y) }).unwrap(); + } + } + } + assert_eq!(board.sheep_left(), 0); + board } #[test] - fn parse_choice_by_number() + fn click_places_a_sheep() { + let mut player = LocalPlayer::default(); let board = GameBoard::default(); - let moves = board.get_moves(Turn::Tiger); - assert_eq!(parse_choice("1", &board, Turn::Tiger, &moves), Ok(moves[0])); - assert_eq!(parse_choice(" 3 ", &board, Turn::Tiger, &moves), Ok(moves[2])); - assert!(parse_choice("0", &board, Turn::Tiger, &moves).is_err()); - assert!(parse_choice("99", &board, Turn::Tiger, &moves).is_err()); + assert_eq!(player.handle_input(Input::Click((2, 2)), board, Turn::Sheep), Some(Move::PlaceSheep { pos: (2, 2) })); + } + + #[test] + fn click_on_occupied_point_explains_why() + { + let mut player = LocalPlayer::default(); + assert_eq!(player.handle_input(Input::Click((0, 0)), GameBoard::default(), Turn::Sheep), None); + assert_eq!(player.message, "Can't place a sheep on an occupied point!"); } #[test] - fn parse_choice_by_points() + fn select_highlight_then_move_tiger() { + let mut player = LocalPlayer::default(); let board = GameBoard::default(); - let sheep = board.get_moves(Turn::Sheep); - let tiger = board.get_moves(Turn::Tiger); - assert_eq!(parse_choice("2 2", &board, Turn::Sheep, &sheep), Ok(Move::PlaceSheep { pos: (2, 2) })); - assert_eq!(parse_choice("2,2", &board, Turn::Sheep, &sheep), Ok(Move::PlaceSheep { pos: (2, 2) })); - assert_eq!(parse_choice("0 0 1 1", &board, Turn::Tiger, &tiger), Ok(Move::MoveTiger { from: (0, 0), to: (1, 1) })); + assert_eq!(player.handle_input(Input::Click((0, 0)), board, Turn::Tiger), None); + let mut targets = player.highlights().targets; + targets.sort(); + assert_eq!(player.highlights().selected, Some((0, 0))); + assert_eq!(targets, vec![(0, 1), (1, 0), (1, 1)]); + assert_eq!(player.handle_input(Input::Click((1, 1)), board, Turn::Tiger), + Some(Move::MoveTiger { from: (0, 0), to: (1, 1) })); } #[test] - fn parse_choice_explains_bad_input() + fn clicking_another_piece_reselects() { + let mut player = LocalPlayer::default(); let board = GameBoard::default(); - let sheep = board.get_moves(Turn::Sheep); - let tiger = board.get_moves(Turn::Tiger); - assert_eq!(parse_choice("0 0", &board, Turn::Sheep, &sheep), Err("Can't place a sheep on an occupied point!".to_string())); - assert_eq!(parse_choice("0 0 2 0", &board, Turn::Tiger, &tiger), Err("Tigers can only jump over a sheep!".to_string())); - assert_eq!(parse_choice("2 2", &board, Turn::Tiger, &tiger), Err("Place sheep at 2 2 isn't a move for Tiger".to_string())); - assert_eq!(parse_choice("5 0", &board, Turn::Sheep, &sheep), Err("Points go from 0 to 4".to_string())); - assert!(parse_choice("a", &board, Turn::Sheep, &sheep).is_err()); - assert!(parse_choice("1 2 3", &board, Turn::Sheep, &sheep).is_err()); - assert!(parse_choice("", &board, Turn::Sheep, &sheep).is_err()); + player.handle_input(Input::Click((0, 0)), board, Turn::Tiger); + player.handle_input(Input::Click((4, 4)), board, Turn::Tiger); + assert_eq!(player.highlights().selected, Some((4, 4))); + // (1, 1) isn't reachable from (4, 4), so it just deselects + assert_eq!(player.handle_input(Input::Click((1, 1)), board, Turn::Tiger), None); + assert_eq!(player.highlights(), Highlights::default()); + } + + #[test] + fn select_and_move_sheep() + { + let mut player = LocalPlayer::default(); + let board = moving_board(); + assert_eq!(player.handle_input(Input::Click((2, 2)), board, Turn::Sheep), None); + assert_eq!(player.highlights().targets, vec![(2, 1)]); + assert_eq!(player.handle_input(Input::Click((2, 1)), board, Turn::Sheep), + Some(Move::MoveSheep { from: (2, 2), to: (2, 1) })); + } + + #[test] + fn stuck_piece_is_not_selected() + { + let mut player = LocalPlayer::default(); + let board = moving_board(); + // (0, 4) is a tiger boxed in by sheep with nothing empty behind them + assert_eq!(player.handle_input(Input::Click((0, 4)), board, Turn::Tiger), None); + assert_eq!(player.highlights().selected, None); + assert_eq!(player.message, "That piece can't move"); } } diff --git a/src/main.rs b/src/main.rs index 981d3dd..e83586d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,20 @@ use crate::{claude_player::ClaudePlayer, game::GameHandler, local_player::LocalP mod board; mod player; mod game; +#[path = "board-view.rs"] +mod board_view; #[path = "local-player.rs"] mod local_player; #[path = "claude-player.rs"] mod claude_player; -fn main() { +fn main() -> std::io::Result<()> { let mut game = GameHandler::default(); // I play sheep, claude plays tiger - let winner = game.play_game(&mut LocalPlayer, &mut ClaudePlayer::new(Duration::from_secs(2))); - println!("{winner:?} wins!"); + let winner = game.play_game(&mut LocalPlayer::default(), &mut ClaudePlayer::new(Duration::from_secs(2)))?; + if let Some(winner) = winner + { + println!("{winner:?} wins!"); + } + Ok(()) } diff --git a/src/player.rs b/src/player.rs index f46e36b..5f2bafb 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,10 +1,63 @@ use std::{collections::HashMap, time::{Duration, Instant}}; -use crate::board::{GameBoard, Move, Turn}; +use ratatui::{buffer::Buffer, crossterm::event::KeyEvent, layout::Rect, widgets::{Paragraph, Widget}}; +use crate::board::{GameBoard, Move, Pos, Turn}; + +/// Input the game coordinator passes on to the player whose turn it is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Input +{ + /// A click on a board point. + Click(Pos), + Key(KeyEvent), +} + +/// Points for the coordinator to highlight on the board. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Highlights +{ + pub selected: Option<Pos>, + pub targets: Vec<Pos>, +} + +/// A side in the game. The coordinator calls `pick_move` once per loop while it's this player's +/// turn, and passes on input with `handle_input`; whichever returns a move first plays it. pub trait Player { - fn pick_move(&mut self, board: GameBoard, turn: Turn) -> Move; + fn name(&self) -> String; + + /// Called before each draw while it's this player's turn, with the position to play. + fn update(&mut self, _board: GameBoard, _turn: Turn) {} + + /// Computer players search here and return Some. Interactive players return None and + /// wait for `handle_input`. + fn pick_move(&mut self, _board: GameBoard, _turn: Turn) -> Option<Move> { None } + + /// A click on a board point, or a key press, while it's this player's turn. + fn handle_input(&mut self, _input: Input, _board: GameBoard, _turn: Turn) -> Option<Move> { None } + + /// 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. + fn reset(&mut self) {} + + /// Draw into the player-output panel. By default this shows the player's name. + fn render(&self, area: Rect, buf: &mut Buffer) + { + Paragraph::new(self.name()).render(area, buf); + } +} + +/// Every player is a ratatui widget through `Player::render`. +/// (A blanket `impl<P: Player> Widget for &P` would break the orphan rule.) +impl Widget for &dyn Player +{ + fn render(self, area: Rect, buf: &mut Buffer) + { + Player::render(self, area, buf); + } } pub type ScoreType = f32; @@ -175,14 +228,17 @@ pub trait MinimaxPlayer /// How long `pick_move` may spend searching. fn time_limit(&self) -> Duration { Duration::from_secs(1) } + + fn name(&self) -> String { "Computer".to_string() } } impl<P: MinimaxPlayer> Player for P { - + fn name(&self) -> String { MinimaxPlayer::name(self) } + /// Iterative deepening: searches depth 1, 2, … until time runs out, each depth trying the /// previous one's best moves first, and plays the best move from the deepest finished search. - fn pick_move(&mut self, board: GameBoard, turn: Turn) -> Move { + fn pick_move(&mut self, board: GameBoard, turn: Turn) -> Option<Move> { let start = Instant::now(); let time_limit = self.time_limit(); let deadline = start + time_limit; @@ -210,8 +266,13 @@ impl<P: MinimaxPlayer> Player for P break; } } - best.expect("pick_move called with no legal moves") + Some(best.expect("pick_move called with no legal moves")) } + + fn render(&self, area: Rect, buf: &mut Buffer) + { + Paragraph::new(format!("{}\n\nThinking…", MinimaxPlayer::name(self))).render(area, buf); + } } @@ -366,7 +427,7 @@ mod tests player.time_limit = Duration::from_millis(50); let board = GameBoard::default(); let start = Instant::now(); - let m = player.pick_move(board, Turn::Sheep); + let m = player.pick_move(board, Turn::Sheep).unwrap(); assert!(start.elapsed() < Duration::from_millis(500), "took {:?}", start.elapsed()); assert_eq!(board.check_move(m), Ok(())); } @@ -380,7 +441,7 @@ mod tests { let mut player = TestPlayer::new(4, cached); let best = reference(&mut player, board, turn, 4); - let m = player.pick_move(board, turn); + let m = player.pick_move(board, turn).unwrap(); let score = reference(&mut player, board.after_move_unchecked(m), turn.next_turn(), 3); assert_eq!(score, best, "{turn:?} picked {m:?} (cached: {cached})"); } @@ -413,7 +474,7 @@ mod tests for cached in [false, true] { - let m = TestPlayer::new(3, cached).pick_move(board, Turn::Tiger); + let m = TestPlayer::new(3, cached).pick_move(board, Turn::Tiger).unwrap(); assert!(board.after_move_unchecked(m).check_win(Turn::Sheep), "picked {m:?} (cached: {cached})"); } } |
