summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/claude-player.rs35
-rw-r--r--src/game.rs120
-rw-r--r--src/local-player.rs49
-rw-r--r--src/main.rs3
-rw-r--r--src/player.rs54
5 files changed, 215 insertions, 46 deletions
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);
+ }
}