summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDylan <boss@tehbox.org>2026-10-22 17:17:02 +1300
committerDylan <boss@tehbox.org>2026-10-22 17:17:02 +1300
commit7dff6572462bdc59011f8ff8d1c6dc3a0494e44a (patch)
tree0e9e9bd39e9ee68fd5fd0c94afffb8204c637fc2
parent15bdaa5d550115d97be3f89126c924b49c10a70f (diff)
downloadtigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.tar.gz
tigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.zip
feat: Improvements to UI
Now uses ratatui
-rw-r--r--CLAUDE.md14
-rw-r--r--Cargo.lock1711
-rw-r--r--Cargo.toml3
-rw-r--r--src/board-view.rs412
-rw-r--r--src/board.rs60
-rw-r--r--src/claude-player.rs14
-rw-r--r--src/game.rs354
-rw-r--r--src/local-player.rs316
-rw-r--r--src/main.rs12
-rw-r--r--src/player.rs77
10 files changed, 2775 insertions, 198 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 29064ea..f65b35e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
-A Rust implementation of "Tigers and Sheep" (a Bagh-Chal–style hunt game) on a 5x5 point board. The crate/binary is still named `rust-hello` (leftover from the template), not `tigers-and-sheep`.
+A Rust implementation of "Tigers and Sheep" (a Bagh-Chal–style hunt game) on a 5x5 point board. The crate/binary is named `tigers-and-sheep-rs`. The UI uses ratatui (with its re-exported `ratatui::crossterm`).
## Environment & commands
@@ -20,10 +20,14 @@ 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 (`pick_move(board, turn) -> Move`). Also the private `MinimaxPlayer` trait: implementors supply `evaluate` (positive favours tigers) and optionally `depth()`, and a blanket impl makes every `MinimaxPlayer` a `Player`.
-- `src/game.rs` β€” `GameHandler::play_game(sheep_player, tiger_player)` runs the game loop. Sheep move first, and it returns the winning `Turn`.
-- `src/local-player.rs` (module `local_player`, pulled in via `#[path]` because of the hyphen) β€” `LocalPlayer`, a human at the terminal: renders the board, lists legal moves, and parses a move number or coordinates from stdin.
-- `src/main.rs` β€” plays a game between two `LocalPlayer`s and prints the winner.
+- `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/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.
+- `src/main.rs` β€” plays `LocalPlayer` (sheep) against `ClaudePlayer` (tigers) and prints the winner.
+
+Notation (`pos_name`, `impl Display for Move` in board.rs): files `a`–`e` are x 0–4, ranks are `5 - y` (y = 0 is drawn at the top as rank 5). Moves are `sc3` (place), `sc3c2` (sheep move), `ta5b4` (tiger step), `ta5xc3` (tiger jump).
Win check: `check_win(turn)` is called with the side about to move, and true means the side that *just* moved has won. Tigers win once 5 sheep are dead (`sheep_count() + sheep_left() <= 15`); otherwise the side that just moved wins if the side to move has no legal moves.
diff --git a/Cargo.lock b/Cargo.lock
index ee133b2..d354701 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,5 +3,1716 @@
version = 4
[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cc"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.2",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "csscolorparser"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
+dependencies = [
+ "lab",
+ "phf",
+]
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "deltae"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "either"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
+[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "finl_unicode"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indoc"
+version = "2.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kasuari"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
+dependencies = [
+ "hashbrown",
+ "portable-atomic",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "lab"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "line-clipping"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litrs"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "mac_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
+dependencies = [
+ "nix 0.31.3",
+ "windows-sys",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmem"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "palette"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
+dependencies = [
+ "approx",
+ "libm",
+ "palette_derive",
+ "palette_math",
+]
+
+[[package]]
+name = "palette_derive"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
+dependencies = [
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "palette_math"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pest"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23"
+dependencies = [
+ "memchr",
+ "psm",
+ "stacker",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710"
+dependencies = [
+ "pest",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-crossterm",
+ "ratatui-macros",
+ "ratatui-termina",
+ "ratatui-termwiz",
+ "ratatui-widgets",
+ "serde",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags 2.13.2",
+ "compact_str",
+ "critical-section",
+ "hashbrown",
+ "itertools",
+ "kasuari",
+ "lru",
+ "palette",
+ "serde",
+ "strum",
+ "thiserror 2.0.21",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratatui-crossterm"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
+dependencies = [
+ "cfg-if",
+ "crossterm",
+ "instability",
+ "ratatui-core",
+]
+
+[[package]]
+name = "ratatui-macros"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
+dependencies = [
+ "ratatui-core",
+ "ratatui-widgets",
+]
+
+[[package]]
+name = "ratatui-termina"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "termina",
+]
+
+[[package]]
+name = "ratatui-termwiz"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
+dependencies = [
+ "ratatui-core",
+ "termwiz",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags 2.13.2",
+ "hashbrown",
+ "indoc",
+ "instability",
+ "itertools",
+ "line-clipping",
+ "ratatui-core",
+ "serde",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "stacker"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termina"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
+dependencies = [
+ "bitflags 2.13.2",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "windows-sys",
+]
+
+[[package]]
+name = "terminfo"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
+dependencies = [
+ "fnv",
+ "nom",
+ "phf",
+ "phf_codegen",
+]
+
+[[package]]
+name = "termios"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "termwiz"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
+dependencies = [
+ "anyhow",
+ "base64",
+ "bitflags 2.13.2",
+ "fancy-regex",
+ "filedescriptor",
+ "finl_unicode",
+ "fixedbitset",
+ "hex",
+ "lazy_static",
+ "libc",
+ "log",
+ "memmem",
+ "nix 0.29.0",
+ "num-derive",
+ "num-traits",
+ "ordered-float",
+ "pest",
+ "pest_derive",
+ "phf",
+ "sha2",
+ "signal-hook",
+ "siphasher",
+ "terminfo",
+ "termios",
+ "thiserror 1.0.69",
+ "ucd-trie",
+ "unicode-segmentation",
+ "vtparse",
+ "wezterm-bidi",
+ "wezterm-blob-leases",
+ "wezterm-color-types",
+ "wezterm-dynamic",
+ "wezterm-input-types",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
name = "tigers-and-sheep-rs"
version = "0.1.0"
+dependencies = [
+ "ratatui",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-truncate"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "atomic",
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vtparse"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wezterm-bidi"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
+dependencies = [
+ "log",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-blob-leases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
+dependencies = [
+ "getrandom 0.3.4",
+ "mac_address",
+ "sha2",
+ "thiserror 1.0.69",
+ "uuid",
+]
+
+[[package]]
+name = "wezterm-color-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
+dependencies = [
+ "csscolorparser",
+ "deltae",
+ "lazy_static",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-dynamic"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
+dependencies = [
+ "log",
+ "ordered-float",
+ "strsim",
+ "thiserror 1.0.69",
+ "wezterm-dynamic-derive",
+]
+
+[[package]]
+name = "wezterm-dynamic-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "wezterm-input-types"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
+dependencies = [
+ "bitflags 1.3.2",
+ "euclid",
+ "lazy_static",
+ "serde",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
diff --git a/Cargo.toml b/Cargo.toml
index 646cfba..fd658b5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,3 +6,6 @@ edition = "2021"
[[bin]]
name = "tigers-and-sheep-rs"
path = "src/main.rs"
+
+[dependencies]
+ratatui = "0.30.2"
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})");
}
}