diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..d7d451e --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,224 @@ +# Contributing to CheckAI + +Thank you for your interest in contributing to CheckAI — a Rust-powered chess +server and CLI for AI agents, with REST, WebSocket, and deep analysis APIs. +This guide covers everything you need to get from a fresh clone to a merged +pull request. + +By participating in this project you agree to follow our +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Table of contents + +- [Development setup](#development-setup) +- [Project layout](#project-layout) +- [Building and testing](#building-and-testing) +- [Quality gates](#quality-gates) +- [Internationalization (i18n)](#internationalization-i18n) +- [Commit conventions](#commit-conventions) +- [Pull request process](#pull-request-process) +- [Reporting bugs and requesting features](#reporting-bugs-and-requesting-features) + +## Development setup + +### Prerequisites + +| Tool | Version | Used for | +| ---- | ------- | -------- | +| [Rust](https://rustup.rs/) | stable (edition 2024) | Core server, CLI, engine, WASM crate | +| [Bun](https://bun.sh/) | 1.3.x (CI pins 1.3.10) | Web UI, desktop app, docs, npm package | +| [Node.js](https://nodejs.org/) | 22 | Electron tooling for the desktop app | +| [wasm-pack](https://rustwasm.github.io/wasm-pack/) | 0.12.x | Building the WebAssembly package (optional) | + +If you use [mise](https://mise.jdx.dev/), the repository's `mise.toml` installs +the Rust toolchain for you. + +### Getting started + +```bash +git clone https://github.com/JosunLP/checkai.git +cd checkai + +# Build the web UI once so it can be embedded into the Rust binary +cd web && bun install --frozen-lockfile && bun run build && cd .. + +# Build and test the Rust core +cargo build +cargo test +``` + +> **Note:** `build.rs` ensures `web/dist/` exists, but without a frontend build +> the embedded web UI will be empty or outdated. Rebuild `web/` whenever you +> change `web/src/` and want to validate the embedded UI. + +## Project layout + +```text +checkai/ +├── src/ Rust core: engine (movegen, eval, search), REST API (api.rs), +│ WebSocket (ws.rs), analysis, export, storage, terminal UI, CLI +├── locales/ rust-i18n locale files — 8 languages (en is the fallback) +├── web/ TypeScript/Vite SPA; build output (web/dist/) is embedded +│ into the Rust binary via rust-embed +├── desktop/ Electron desktop app built with Svelte +├── wasm/ WebAssembly crate; shares engine sources with src/ via +│ #[path = "../../src/..."] includes +├── npm/ Packaging for the @josunlp/checkai npm/Bun package (WASM) +├── docs/ VitePress documentation site, incl. the agent protocol +│ reference (docs/AGENT.md) +├── scripts/ Install/uninstall and maintenance scripts +└── .github/ CI workflows, issue forms, and community health files +``` + +Because `wasm/` re-includes engine sources from `src/`, changes to shared +modules such as `src/types.rs`, `src/game.rs`, `src/movegen.rs`, `src/eval.rs`, +or `src/search.rs` can affect the server, terminal mode, **and** the npm/WASM +package at the same time. Always check which targets are affected. + +## Building and testing + +### Rust core (repository root) + +```bash +cargo fmt --all -- --check # formatting +RUSTFLAGS=-Dwarnings cargo clippy --all-targets --all-features # lints +cargo test --all-features # tests +cargo build --release # release build +``` + +### Web UI (`web/`) + +```bash +cd web +bun install --frozen-lockfile +bun run check # typecheck + lint + format check +bun run build # production build into web/dist/ +``` + +### Desktop app (`desktop/`) + +```bash +cd desktop +bun install --frozen-lockfile +bun run check # validate the desktop app +bun run pack # smoke-test electron-builder packaging +``` + +### Documentation (`docs/`) + +```bash +cd docs +bun install --frozen-lockfile +bun run docs:build # build the VitePress site +``` + +### WASM / npm package (`npm/`) + +```bash +cd npm +bun run build # builds the wasm/ crate via wasm-pack and prepares the package +``` + +## Quality gates + +CI must stay green. Every pull request runs: + +1. `cargo fmt --all -- --check` +2. `cargo clippy --all-targets --all-features` with `RUSTFLAGS=-Dwarnings` + (every warning is an error) +3. `cargo test --all-features` on Linux, macOS, and Windows +4. Frontend lint/format/typecheck and desktop validation when applicable + +Additional expectations: + +- Every public item carries a rustdoc comment (`///`); modules start with + `//!` docs. Code comments are written in English. +- Prefer small, single-responsibility structs and traits; keep the code DRY. +- `src/storage.rs` defines a versioned binary save format — intentional format + changes need a version bump and a migration strategy. +- Keep `README.md` and `docs/AGENT.md` in sync with protocol, analysis, or + evaluation changes so external agents do not integrate against stale rules. + +## Internationalization (i18n) + +All user-facing CLI and server strings go through +[rust-i18n](https://crates.io/crates/rust-i18n). English (`locales/en.yml`) is +the default and fallback language. + +CheckAI ships **8 locales**, and every key must exist in all of them: + +```text +locales/en.yml locales/de.yml locales/es.yml locales/fr.yml +locales/ja.yml locales/pt.yml locales/ru.yml locales/zh-CN.yml +``` + +### Adding a new key + +1. Pick a namespaced, dotted key, e.g. `terminal.cmd_move`. +2. Add it to `locales/en.yml` first with the English text. Use `%{name}` + placeholders for interpolated values: + + ```yaml + terminal.move_status: ' Move %{num}. %{color} to move.' + ``` + +3. Add the translated entry under the **same key** to the other 7 locale + files. If you cannot provide a confident translation, copy the English text + and flag it in your PR description so a native speaker can refine it. +4. Use the key from Rust code via the `t!` macro: + + ```rust + println!("{}", t!("terminal.move_status", num = move_number, color = color_name)); + ``` + +5. Verify nothing is missing — a quick check is to diff the key sets: + + ```bash + for f in locales/*.yml; do echo "$f: $(grep -c '^[a-z]' "$f")"; done + ``` + + All 8 files should report the same key count. + +Never hard-code user-facing English strings in Rust source; reviewers will ask +you to move them into the locale files. + +## Commit conventions + +The project uses [Conventional Commits](https://www.conventionalcommits.org/)-style +messages. Use a lowercase type prefix followed by a concise, imperative summary: + +```text +feat: add icon generation script and update build process +fix: handle missing checksum entries +docs: clarify install version examples +chore: update dependencies in package.json +``` + +Common types: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `ci`, +`perf`. Keep commits focused — one logical change per commit. + +## Pull request process + +1. Fork the repository and create a topic branch from `development` + (feature work targets `development`; `main` tracks releases). +2. Make your changes, following the quality gates and i18n rules above. +3. Run the full local check suite (fmt, clippy, test, plus frontend checks if + you touched `web/` or `desktop/`). +4. Update documentation and `CHANGELOG.md` for user-visible changes. +5. Open a pull request and fill in the PR template completely. +6. A maintainer (@JosunLP) will review your PR. Address review feedback with + follow-up commits; avoid force-pushing during an active review. +7. Once CI is green and the review is approved, a maintainer merges the PR. + +## Reporting bugs and requesting features + +Please use the issue forms: + +- [Bug report](https://github.com/JosunLP/checkai/issues/new?template=bug_report.yml) +- [Feature request](https://github.com/JosunLP/checkai/issues/new?template=feature_request.yml) +- [Question](https://github.com/JosunLP/checkai/issues/new?template=question.yml) + +For security vulnerabilities, **do not open a public issue** — follow the +process in [SECURITY.md](SECURITY.md) instead. + +Thank you for helping make CheckAI better! diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea7..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f6f3cc5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,87 @@ +name: 🐞 Bug report +description: Report a problem with the CheckAI server, CLI, engine, or clients. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report! Please search the + existing issues first to avoid duplicates, and never include secrets + (tokens, passwords) in logs you paste here. + - type: textarea + id: summary + attributes: + label: Summary + description: A clear and concise description of the bug. + validations: + required: true + - type: dropdown + id: component + attributes: + label: Affected component + options: + - CLI (play / analyze / bench / export) + - Server (REST API) + - WebSocket API + - Chess engine / analysis (search, evaluation) + - Web UI + - Desktop app + - WASM / npm package + - Documentation + - Other / not sure + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Exact commands, API requests, FEN strings, or moves that trigger the bug. + placeholder: | + 1. Run `checkai analyze --fen "..."` + 2. ... + 3. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include error messages, stack traces, or relevant logs. + validations: + required: true + - type: input + id: version + attributes: + label: CheckAI version + description: Output of `checkai version` (or the git commit hash). + placeholder: "0.8.0" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + - type: input + id: env + attributes: + label: Environment details + description: Rust version (`rustc --version`); browser/Node version for UI bugs. + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that helps — screenshots, configuration, FEN strings. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..aa99a92 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 📖 Documentation + url: https://github.com/JosunLP/checkai#readme + about: Read the README and the docs before opening an issue. + - name: 💬 Questions & discussions + url: https://github.com/JosunLP/checkai/discussions + about: Ask questions, share ideas, and get help from the community. + - name: 🔒 Report a security vulnerability + url: https://github.com/JosunLP/checkai/security/advisories/new + about: Please report security issues privately, not as public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..15ebf2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,51 @@ +name: 💡 Feature request +description: Suggest an idea or improvement for CheckAI. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for sharing your idea! Please check existing issues and + discussions first to avoid duplicates. + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem does this solve, or what are you trying to achieve? + placeholder: "I'm always frustrated when ..." + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: A clear and concise description of what you would like to happen. + validations: + required: true + - type: dropdown + id: component + attributes: + label: Affected area + options: + - CLI + - Server / REST API + - WebSocket API + - Chess engine / analysis + - Web UI + - Desktop app + - WASM / npm package + - Documentation + - Other + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative solutions or features you have considered. + - type: textarea + id: context + attributes: + label: Additional context + description: Mockups, references to other engines/tools, or related issues. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f01d33a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,63 @@ + + +## Summary + + + +## Related issues + + + +## Type of change + + + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that changes existing behavior or APIs) +- [ ] Documentation only +- [ ] CI / tooling / dependencies + +## Affected areas + + + +- [ ] Rust core (`src/` — engine, CLI, REST API, WebSocket, analysis, storage) +- [ ] Web UI (`web/`) +- [ ] Desktop app (`desktop/`) +- [ ] WASM crate / npm package (`wasm/`, `npm/`) +- [ ] Documentation (`docs/`, `README.md`) +- [ ] Locales (`locales/`) + +## Quality gates + + + +- [ ] `cargo fmt --all -- --check` passes +- [ ] `RUSTFLAGS=-Dwarnings cargo clippy --all-targets --all-features` passes with no warnings +- [ ] `cargo test --all-features` passes +- [ ] Frontend checks pass if `web/` or `desktop/` changed (`bun run check` in the affected directory) + +## Internationalization + + + +- [ ] All user-facing strings go through `rust-i18n` (`t!("key", ...)`) — no hard-coded English in code paths users see +- [ ] Every new or changed i18n key exists in **all 8** locale files (`locales/en.yml`, `de.yml`, `es.yml`, `fr.yml`, `ja.yml`, `pt.yml`, `ru.yml`, `zh-CN.yml`) +- [ ] No i18n changes in this PR + +## Documentation + +- [ ] Public Rust items have rustdoc comments (`///`, `//!` for modules) +- [ ] `README.md`, `docs/`, and `docs/AGENT.md` are updated if protocol, API, or user-visible behavior changed +- [ ] `CHANGELOG.md` has an entry under the appropriate heading (for user-visible changes) +- [ ] No documentation changes needed + +## Additional notes + + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..c844952 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,60 @@ +# Security Policy + +## Supported versions + +CheckAI follows a rolling-release model. Only the **latest released version** +receives security updates. Please make sure you can reproduce any issue on the +most recent release (or `main`) before reporting it. + +| Version | Supported | +| -------------- | --------- | +| Latest release | ✅ | +| Older releases | ❌ | + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +pull requests, or discussions.** + +Instead, report them privately through GitHub Security Advisories: + +➡️ **** + +Please include as much of the following as possible: + +- The affected component (CLI, REST API, WebSocket API, engine, web/desktop UI, + WASM/npm package). +- The CheckAI version (`checkai version`) and your operating system. +- A clear description of the vulnerability and its potential impact. +- Step-by-step reproduction instructions, including any required FEN strings, + requests, or payloads. + +We will acknowledge your report as quickly as we can, keep you informed about +the progress of a fix, and credit you in the release notes if you wish. + +## Disclosure policy + +We follow a coordinated-disclosure process: we ask that you give us a +reasonable opportunity to investigate and release a fix before any public +disclosure. + +## Deployment security notes + +CheckAI is primarily designed as a developer tool and an integration target for +AI agents. Keep the following in mind when deploying it: + +- **Binds to `0.0.0.0` by default.** `checkai serve` listens on all interfaces. + Bind to `127.0.0.1` (via `--host`) or place it behind a firewall/reverse + proxy when you do not want it reachable from the network. +- **Permissive CORS.** The REST/WebSocket API enables cross-origin access for + all origins so browser-based and agent clients can connect easily. Run it on + a trusted network or add an authenticating proxy if you need access control. +- **No built-in authentication.** There is no user authentication layer; any + client that can reach the port can create and manipulate games. +- **Self-update.** `checkai update` downloads release binaries from GitHub over + HTTPS and replaces the running executable. Only run it where that is + acceptable. + +These are intentional design choices for a local/trusted-network developer +tool, not vulnerabilities — but they are important to understand before +exposing CheckAI to untrusted networks. diff --git a/CHANGELOG.md b/CHANGELOG.md index de9cca4..933554e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-06-12 + +### Added + +- **Animated terminal CLI** — A new terminal experience built on `crossterm` and `indicatif`: animated boards, an evaluation bar, and live search spinners/progress bars. Every effect is TTY-gated and degrades gracefully to clean, parseable plain text when stdout is not a terminal, when `--no-color` is passed, or when `NO_COLOR` is set +- **`play` vs the built-in engine** — `checkai play` can now play against the built-in engine, streaming a live animated search and announcing each move with its evaluation. Flags: `--vs `, `--color `, `--level <1-10>`, `--movetime`, `--depth`, `--fen`, `--ascii`, `--flip`. In-game commands now include `hint`, `undo`, and `fen` +- **`watch` command** — Watch an engine-vs-engine showcase. Set both sides with `--level`, or asymmetrically with `--level-white` / `--level-black`; control pacing with `--delay` and length with `--max-moves` (`--movetime`, `--ascii` also supported) +- **`analyze` command** — Analyze a position (`--fen`) or annotate a whole game (`--moves`) with a live, animated iterative-deepening display, then print the best move, evaluation, forced-mate distance, depth/nodes/time, and principal variation (`--depth`, `--movetime`) +- **`bench` command** — Run the fixed engine benchmark suite over a set of positions, reporting nodes, time, and nodes-per-second (`--depth`, default 12; or `--movetime`) +- **`perft` command** — Verify move generation with perft node counts to a given depth (positional `DEPTH`, default 5; `--fen`, `--divide`) +- **`uci` command** — Run as a UCI engine on stdin/stdout for chess GUIs and match runners (e.g. via cutechess-cli). UCI output is a machine protocol and is intentionally not internationalized +- **Global `--no-color` flag** — Added to every command alongside the existing `--lang`; disables ANSI styling (the `NO_COLOR` env var is honored too) +- **FEN position loading** — `Game::from_fen`, `Board::from_piece_placement`, and `CastlingRights::from_fen` parse and validate a full FEN so `play`, `analyze`, and `perft` can start from arbitrary positions, with accompanying tests +- **Completed 8-language i18n** — Localized all CLI strings, including the engine labels and the `play`, `watch`, `analyze`, `bench`, and `perft` flows, across all eight bundled languages (EN, DE, FR, ES, ZH, JA, PT, RU); English remains the source of truth and fallback +- **Community health files** — Added `CONTRIBUTING.md`, `SECURITY.md`, a pull-request template, and structured GitHub issue forms (bug report, feature request) replacing the previous Markdown issue templates +- **Engine test coverage** — Added tests for skill-level scaling, node-limit bounding, and a forced mate-in-two, plus opt-in nodes-per-second and node-count benchmark diagnostics + +### Changed + +- **`play` now defaults to playing vs the engine** — Running `checkai play` with no flags starts a game against the built-in engine (level 5) instead of a local two-player game; pass `--vs human` for the previous two-player behavior +- **Search engine overhaul** — Substantially strengthened the alpha-beta / PVS search: + - Full Static Exchange Evaluation (swap algorithm with x-ray and en-passant handling) replaces the previous optimistic capture heuristic, driving both capture ordering and pruning + - The transposition table gains generation-based aging, depth-preferred replacement, and a cached static evaluation, and is now probed and stored inside quiescence search + - Hard time and node limits are enforced inside the tree via the `SearchLimits` / `IterationInfo` / `search_limited` contract, discarding partial iterations, with per-iteration progress reported to live CLI displays and UCI `info` output through a callback + - Added mate-distance pruning, reverse futility pruning, adaptive null-move pruning with a high-depth verification search, table-driven Late Move Reductions, Late Move Pruning, Internal Iterative Reduction, check extensions, and a corrected counter-move heuristic + - Quiescence search now resolves check evasions and applies per-capture delta pruning, SEE pruning, and transposition-table cutoffs + - History scores use a gravity-style update with maluses for quiet moves that failed before a cutoff +- **Evaluation** — The PeSTO-style evaluation adds pawn-structure terms (passed, doubled, isolated, backward, and connected pawns), king-safety penalties (open files and a weakened pawn shield), per-piece mobility, and a tempo bonus on top of the tapered piece-square tables and bishop-pair / rook-file bonuses +- **Animated CLI welcome screen** — The welcome screen and terminal banner now reveal with a subtle animation and list the new commands +- **Version metadata** — Bumped the Rust crate, WASM crate, npm package, web UI, desktop app, OpenAPI metadata, and VitePress version label to 0.8.0 + ## [0.7.0] - 2026-05-13 ### Added @@ -254,7 +285,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Game archiving with zstd compression - Web UI for browser-based game viewing -[Unreleased]: https://github.com/JosunLP/checkai/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/JosunLP/checkai/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/JosunLP/checkai/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/JosunLP/checkai/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/JosunLP/checkai/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/JosunLP/checkai/compare/v0.5.1...v0.5.2 diff --git a/Cargo.lock b/Cargo.lock index 7658a04..4a7cb96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,7 +517,7 @@ dependencies = [ [[package]] name = "checkai" -version = "0.7.0" +version = "0.8.0" dependencies = [ "actix", "actix-cors", @@ -525,7 +525,9 @@ dependencies = [ "actix-web-actors", "clap", "colored", + "crossterm", "env_logger", + "indicatif", "log", "reqwest", "rust-embed", @@ -535,6 +537,7 @@ dependencies = [ "serde_json", "sys-locale", "tokio", + "unicode-width", "utoipa", "utoipa-swagger-ui", "uuid", @@ -615,6 +618,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -718,6 +733,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "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" @@ -812,6 +854,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -824,6 +875,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1342,6 +1399,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1488,12 +1558,24 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "local-channel" version = "0.1.5" @@ -2026,6 +2108,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2271,6 +2366,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -2685,12 +2801,24 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2948,6 +3076,22 @@ dependencies = [ "rustls-pki-types", ] +[[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-util" version = "0.1.11" @@ -2957,6 +3101,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 06a2d9e..bd89027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "checkai" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "A chess server and CLI that lets AI agents play chess against each other via REST API" license = "MIT" @@ -50,3 +50,10 @@ semver = "1" # Internationalization (i18n) rust-i18n = "4" sys-locale = "0.3" + +# Interactive terminal UI: raw input, cursor control, terminal size +crossterm = "0.29" +# Animated spinners and progress bars for the CLI +indicatif = "0.18" +# Display width of strings (CJK-aware) for correct box-drawing alignment +unicode-width = "0.2" diff --git a/README.md b/README.md index 3d875d4..23638a1 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ A Rust-powered chess server and CLI with REST, WebSocket, and deep analysis APIs ### Chess Engine - **Full FIDE 2023 Rules** — Move generation and validation with castling, en passant, promotion, check/checkmate/stalemate, and all draw conditions (50-move rule, threefold repetition, insufficient material) -- **Deep Game Analysis** — Asynchronous engine with 30+ ply depth, PVS/Negascout, transposition table, null-move pruning, LMR, SEE, futility pruning, killer/history heuristics, and quiescence search -- **PeSTO Evaluation** — Midgame/endgame piece-square tables with king safety, pawn shield analysis, piece mobility, and phase interpolation +- **Search** — Iterative deepening with aspiration windows; Principal Variation Search (PVS / Negascout) with re-searches; a transposition table with generation-based aging, depth-preferred replacement, and a cached static eval (probed in both main and quiescence search); adaptive null-move pruning with a high-depth verification search; table-driven Late Move Reductions; Late Move Pruning; reverse-futility (static null move), classic futility, and razoring at frontier nodes; Internal Iterative Reduction; check extensions; mate-distance pruning; full Static Exchange Evaluation (SEE) for capture ordering and pruning; killer-move, counter-move, and gravity-style history heuristics; in-tree repetition detection and 50-move awareness; and a quiescence search with stand-pat, per-capture delta pruning, SEE pruning, and TT cutoffs. Hard time/node limits are enforced inside the tree via `SearchLimits` / `search_limited`, discarding partial iterations +- **PeSTO Evaluation** — Tapered midgame/endgame piece-square tables interpolated by game phase, plus pawn structure (passed, doubled, isolated, backward, connected pawns), bishop-pair bonus, rook open/semi-open file bonuses, king safety (open-file and pawn-shield penalties), per-piece mobility, and a tempo bonus. The evaluation is always relative to the side to move +- **Async Game Analysis** — A separate job-based analysis service performs deep (30+ ply) game review on top of the same engine - **Opening Book** — Polyglot `.bin` format with binary search lookups - **Endgame Tablebases** — Syzygy tablebase detection with analytical evaluation for common endgames @@ -33,7 +34,7 @@ A Rust-powered chess server and CLI with REST, WebSocket, and deep analysis APIs - **Analysis API** — Separate `/api/analysis/*` endpoints for asynchronous game review with job progress, completed summaries, and per-move annotations - **WebSocket API** — Full real-time API at `/ws` mirroring REST endpoints with push notifications and game subscriptions - **Swagger/OpenAPI** — Auto-generated interactive API docs at `/swagger-ui/` -- **Terminal Interface** — Colored board display with interactive move input for local two-player games +- **Animated Terminal CLI** — A richly-featured terminal experience: play against the built-in engine (10-level ladder) or a second human, watch an engine-vs-engine showcase, analyze positions and games, benchmark the engine, run perft, and speak UCI for chess GUIs. Animated boards, eval bar, and search spinners (built on `crossterm` + `indicatif`) render only on a TTY and degrade to clean plain text when piped; honors `--no-color` / `NO_COLOR` ### Web & Deployment @@ -50,12 +51,12 @@ A Rust-powered chess server and CLI with REST, WebSocket, and deep analysis APIs Recommended: pin the release you want and verify the downloaded binary against the published SHA-256 checksums before installing it. The examples below use -`v0.7.0`; check the [Releases](https://github.com/JosunLP/checkai/releases) +`v0.8.0`; check the [Releases](https://github.com/JosunLP/checkai/releases) page and replace it with the current or desired release tag. ```bash # Linux / macOS -CHECKAI_VERSION=v0.7.0 +CHECKAI_VERSION=v0.8.0 OS="$(uname -s | tr '[:upper:]' '[:lower:]')" [ "$OS" = "darwin" ] || OS="linux" ARCH="$(uname -m)" @@ -83,7 +84,7 @@ sudo install -m 0755 "${ASSET}" /usr/local/bin/checkai ```powershell # Windows (PowerShell) -$Version = "v0.7.0" +$Version = "v0.8.0" $Asset = "checkai-windows-x86_64.exe" $BaseUrl = "https://github.com/JosunLP/checkai/releases/download/$Version" Invoke-WebRequest "$BaseUrl/$Asset" -OutFile $Asset @@ -201,6 +202,63 @@ See the [package README](npm/README.md) for the full API reference. checkai play ``` +## Command-Line Interface + +`checkai` is a single binary with the subcommands below. Two global options apply to every command: `-l, --lang ` (override the locale, e.g. `de`, `fr`, `zh-CN`) and `--no-color` (disable colored output; the `NO_COLOR` env var is honored too). Run `checkai --help` for the full flag list. + +| Command | Description | Example | +|-----------|-----------------------------------------------------------------------|-------------------------------------------------| +| `serve` | Start the REST + WebSocket API server with Swagger UI | `checkai serve --port 3000` | +| `play` | Play in the terminal — **defaults to playing vs the built-in engine** | `checkai play --level 9 --color black` | +| `watch` | Watch the engine play itself (engine-vs-engine showcase) | `checkai watch --level-white 9 --level-black 3` | +| `analyze` | Analyze a position (`--fen`) or annotate a whole game (`--moves`) | `checkai analyze --fen "" --depth 16` | +| `bench` | Run the fixed engine benchmark suite (nodes, time, NPS) | `checkai bench --depth 12` | +| `perft` | Verify move generation with perft node counts | `checkai perft 5 --divide` | +| `uci` | Run as a UCI engine on stdin/stdout (for chess GUIs / match runners) | `checkai uci` | +| `export` | Export archived games as text, PGN, or JSON | `checkai export --all --format pgn` | +| `update` | Update CheckAI to the latest version from GitHub | `checkai update` | +| `version` | Print the current version | `checkai version` | + +Highlights: + +- **`play` plays vs the engine by default** — pick a strength on the 1–10 level ladder (`--level`, default 5) and a side (`--color white|black|random`). Use `--vs human` for a local two-player game. Other flags: `--movetime`, `--depth`, `--fen`, `--ascii`, `--flip`. +- **`watch`** runs an engine-vs-engine showcase — set both sides with `--level`, or asymmetrically with `--level-white` / `--level-black`; tune pacing with `--delay` (ms between moves) and cap length with `--max-moves`. +- **Animated, TTY-aware UI** — animated boards, an evaluation bar, and live search spinners/progress bars (powered by `crossterm` + `indicatif`) appear only when stdout is a terminal; piped output stays plain and parseable. +- **Color & locale** — `--no-color` / the `NO_COLOR` env var disable ANSI styling, and `--lang` selects one of 8 bundled languages (English is the source of truth and fallback). + +```bash +checkai play # White vs the engine at level 5 +checkai play --vs human --ascii # Local two-player game, ASCII board +checkai watch --movetime 200 --delay 0 # Fast engine-vs-engine game +checkai analyze --moves "e2e4 e7e5 g1f3" # Annotate a line move by move +checkai bench --depth 8 # Faster, shallower benchmark +checkai perft 6 # Exact node counts up to depth 6 +``` + +### UCI Mode + +`checkai uci` speaks the UCI protocol on stdin/stdout so you can drop CheckAI into any UCI-compatible GUI (Arena, Cute Chess, BanksiaGUI, …) or match runner. The UCI output is a machine protocol and is intentionally not localized. + +```text +$ checkai uci +uci +position startpos moves e2e4 +go movetime 1000 +quit +``` + +Want to know how strong it really is? Measure it yourself. With [cutechess-cli](https://github.com/cutechess/cutechess) you can run CheckAI against any other UCI engine — for example Stockfish as a convenient, widely-available opponent — and read the result off the scoreboard: + +```bash +cutechess-cli \ + -engine name=CheckAI cmd=checkai arg=uci \ + -engine name=Opponent cmd=stockfish \ + -each proto=uci tc=10+0.1 -games 100 -repeat -recover \ + -pgnout match.pgn +``` + +Adjust the opponent, time control, and game count to taste; the resulting score and Elo estimate are the honest measure of CheckAI's playing strength. + ## API Reference ### Game Endpoints @@ -312,16 +370,21 @@ ws.onmessage = (event) => { ## Terminal Commands +These commands are available during an interactive `checkai play` game (single-letter aliases are shown by `help`): + | Command | Description | | --------- | ------------------------------------ | | `e2e4` | Move piece (from-to notation) | | `e7e8Q` | Pawn promotion (append piece letter) | | `moves` | List all legal moves | | `board` | Show current board | -| `resign` | Resign the game | -| `draw` | Claim a draw (if eligible) | | `history` | Show move history | +| `fen` | Show the current position as FEN | | `json` | Game state as JSON | +| `hint` | Ask the engine for a suggested move | +| `undo` | Take back the last move | +| `resign` | Resign the game | +| `draw` | Claim a draw (if eligible) | | `help` | Show help | | `quit` | Quit | diff --git a/desktop/package.json b/desktop/package.json index 73af91d..c0580dd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@checkai/desktop", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "main": "dist-electron/electron-main.js", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 48bb99d..6e2d4d1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -32,7 +32,7 @@ export default defineConfig({ { text: 'API Reference', link: '/api/rest' }, { text: 'Agent Protocol', link: '/agent/overview' }, { - text: 'v0.7.0', + text: 'v0.8.0', items: [ { text: 'Changelog', diff --git a/docs/changelog.md b/docs/changelog.md index 4b09a25..924ca36 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,30 @@ All notable changes to CheckAI are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.8.0] — 2026-06-12 + +### Added + +- **Animated terminal CLI** — A new terminal experience built on `crossterm` + `indicatif` (animated boards, evaluation bar, live search spinners/progress bars) that is TTY-gated and degrades to clean plain text when piped, with `--no-color`, or with `NO_COLOR` +- **`play` vs the built-in engine** — `checkai play` now plays against the engine, streaming a live animated search and announcing each move with its evaluation, via `--vs `, `--color `, `--level <1-10>`, `--movetime`, `--depth`, `--fen`, `--ascii`, and `--flip`; in-game commands gain `hint`, `undo`, and `fen` +- **`watch` command** — Engine-vs-engine showcase, with `--level` / `--level-white` / `--level-black`, plus `--delay`, `--max-moves`, `--movetime`, and `--ascii` +- **`analyze` command** — Analyze a FEN (`--fen`) or annotate a whole game (`--moves`) with a live animated iterative-deepening display, then print the best move, evaluation, mate distance, depth/nodes/time, and principal variation (`--depth`, `--movetime`) +- **`bench` command** — Run the fixed engine benchmark suite, reporting nodes, time, and nodes-per-second (`--depth`, `--movetime`) +- **`perft` command** — Verify move generation with perft node counts (`DEPTH`, `--fen`, `--divide`) +- **`uci` command** — Run as a UCI engine on stdin/stdout for chess GUIs and match runners; UCI output is intentionally not internationalized +- **Global `--no-color` flag** — Added to every command (alongside `--lang`); honors `NO_COLOR` too +- **FEN position loading** — `Game::from_fen`, `Board::from_piece_placement`, and `CastlingRights::from_fen` parse and validate full FEN strings for `play`, `analyze`, and `perft` +- **Completed 8-language i18n** — Localized all CLI strings, including the engine labels and the `play`, `watch`, `analyze`, `bench`, and `perft` flows, across all eight bundled languages +- **Community health files** — `CONTRIBUTING.md`, `SECURITY.md`, a pull-request template, and structured GitHub issue forms replacing the previous Markdown templates + +### Changed + +- **`play` now defaults to playing vs the engine** — Running `checkai play` with no flags starts a game against the engine (level 5) instead of a two-player game; use `--vs human` for the previous behavior +- **Search engine overhaul** — Full Static Exchange Evaluation, transposition-table aging with depth-preferred replacement and quiescence integration, in-tree hard time/node limits via the `SearchLimits` / `IterationInfo` / `search_limited` contract with per-iteration progress reporting (consumed by the live CLI displays and UCI `info`), mate-distance pruning, reverse futility pruning, adaptive null-move pruning with verification, table-driven Late Move Reductions, Late Move Pruning, Internal Iterative Reduction, check extensions, a corrected counter-move heuristic, gravity-style history with maluses, and a stronger quiescence search (check evasions, delta and SEE pruning) +- **Evaluation** — Added pawn-structure terms (passed, doubled, isolated, backward, connected pawns), king-safety penalties (open files, weakened pawn shield), per-piece mobility, and a tempo bonus on top of the tapered piece-square tables and bishop-pair / rook-file bonuses +- **Animated CLI welcome screen** — Animated welcome screen and terminal banner, now listing the new commands +- **Version metadata** — Bumped Rust crate, WASM crate, npm package, web UI, desktop app, OpenAPI metadata, and VitePress version label to 0.8.0 + ## [0.7.0] — 2026-05-13 ### Added diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 13a87dc..4ab2a70 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -16,14 +16,14 @@ ### Pre-built Binaries (Recommended) Pin the release you want and verify the downloaded binary against the published -SHA-256 checksums before installing it. The examples below use `v0.7.0`; check +SHA-256 checksums before installing it. The examples below use `v0.8.0`; check the [Releases](https://github.com/JosunLP/checkai/releases) page and replace it with the current or desired release tag. ::: code-group ```bash [Linux / macOS] -CHECKAI_VERSION=v0.7.0 +CHECKAI_VERSION=v0.8.0 OS="$(uname -s | tr '[:upper:]' '[:lower:]')" [ "$OS" = "darwin" ] || OS="linux" ARCH="$(uname -m)" @@ -50,7 +50,7 @@ sudo install -m 0755 "${ASSET}" /usr/local/bin/checkai ``` ```powershell [Windows (PowerShell)] -$Version = "v0.7.0" +$Version = "v0.8.0" $Asset = "checkai-windows-x86_64.exe" $BaseUrl = "https://github.com/JosunLP/checkai/releases/download/$Version" Invoke-WebRequest "$BaseUrl/$Asset" -OutFile $Asset diff --git a/docs/index.md b/docs/index.md index 0a078b9..5559fb4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,8 +27,11 @@ features: title: REST & WebSocket API details: JSON-based API for AI agents and the web UI. FEN/PGN import/export, real-time WebSocket events, and interactive Swagger docs. - icon: 🔬 - title: Deep Analysis Engine - details: Async analysis with 30+ ply depth, PVS, SEE, futility pruning, king safety, mobility scoring, and PeSTO evaluation. + title: Analysis Engine + details: Iterative-deepening PVS with a transposition table, null-move pruning, LMR, SEE, futility/razoring, and quiescence search, plus a PeSTO evaluation with pawn structure, king safety, and mobility. + - icon: ⌨️ + title: Animated Terminal CLI + details: Play vs the engine (10-level ladder) or a human, watch engine-vs-engine games, analyze, bench, perft, and speak UCI for chess GUIs — with TTY-aware animations that fall back to plain text. - icon: 🖥️ title: Modern Web UI details: TypeScript web app with bQuery signals, Tailwind CSS v4, analysis panel, FEN/PGN tools, board flip, and promotion dialog. diff --git a/locales/de.yml b/locales/de.yml index 574b5fa..fa87075 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -212,3 +212,127 @@ analysis.tablebase_failed: 'Syzygy-Endspieldatenbank konnte nicht geladen werden analysis.engine_config: 'Analyse-Engine: Tiefe=%{depth}, TT=%{tt}MB' analysis.archive_load_failed: 'Archiviertes Spiel konnte nicht geladen werden' analysis.archive_replay_failed: 'Archiviertes Spiel konnte nicht wiedergegeben werden' + +# --------------------------------------------------------------------------- +# Engine-Ausgabe (Analyse & Live-Suchfortschritt) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Analyze-Befehl +# --------------------------------------------------------------------------- +analyze.no_moves: 'Die --moves-Liste ist leer.' +analyze.no_best_move: 'Kein bester Zug gefunden — die Stellung könnte beendet sein.' + +# --------------------------------------------------------------------------- +# Bench-Befehl +# --------------------------------------------------------------------------- +bench.header: 'Engine-Benchmark' + +# --------------------------------------------------------------------------- +# Spiel gegen die Engine +# --------------------------------------------------------------------------- +play.engine_thinking: 'Engine denkt nach (Stufe %{level})…' + +# CLI-Befehlsbeschreibungen (Engine-Befehle) +cli.cmd_analyze_desc: 'Eine Stellung oder Partie mit der Engine analysieren' +cli.cmd_bench_desc: 'Die Engine-Benchmark-Suite ausführen' + +# --------------------------------------------------------------------------- +# CLI-Willkommensbildschirm (zusätzliche Engine-Befehle & Etiketten) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'Der Engine beim Spiel gegen sich selbst zusehen' +cli.cmd_perft_desc: 'Zuggenerierung überprüfen (Perft-Zählungen)' +cli.cmd_uci_desc: 'Als UCI-Engine für Schach-GUIs ausführen' +cli.quickstart_watch: 'Eine Engine-Showcase-Partie ansehen' +cli.banner_tagline: 'Schach in deinem Terminal' +cli.version_label: 'Version:' +cli.locale_label: 'Sprache:' +cli.docs_label: 'Dokumentation:' +cli.nodes_suffix: 'Knoten' +cli.invalid_fen: 'Ungültige FEN: %{error}' + +# --------------------------------------------------------------------------- +# Play-Befehl (gegen Engine / gegen Mensch) +# --------------------------------------------------------------------------- +play.intro_engine: 'Du spielst gegen die Engine auf Stufe %{level} — du bist %{color}.' +play.intro_human: 'Lokales Spiel zu zweit — Weiß und Schwarz teilen sich dieses Terminal.' +play.intro_help_hint: 'Tippe %{help} für die Liste der Spielbefehle.' +play.engine_played: 'Engine zieht %{mv} (%{piece}) — Tiefe %{depth}, Bewertung %{score}, %{secs}s' +play.engine_resigns: 'Die Engine fand keinen Zug und gibt auf. Du gewinnst!' +play.hint_thinking: 'Tipp wird berechnet …' +play.hint_result: 'Tipp: %{mv} (Bewertung %{score})' +play.hint_none: 'In dieser Stellung ist kein Tipp verfügbar.' +play.cmd_hint: 'Die Engine um einen Zugvorschlag bitten' +play.cmd_undo: 'Den letzten vollen Zug zurücknehmen' +play.cannot_undo: 'Noch nichts zum Zurücknehmen.' +play.undo_done: 'Letzten Zug zurückgenommen.' +play.position_terminal: 'Diese Stellung hat keine erlaubten Züge — nichts zu spielen.' +play.result_title: 'SPIEL BEENDET' +play.result_winner_label: 'Sieger:' +play.result_reason_label: 'Grund:' +play.result_moves_label: 'Züge:' +play.result_duration_label: 'Dauer:' +play.winner_white: 'Weiß' +play.winner_black: 'Schwarz' +play.winner_draw: 'Remis — niemand' +play.game_unfinished: 'Spiel unvollendet' +play.piece_king: 'König' +play.piece_queen: 'Dame' +play.piece_rook: 'Turm' +play.piece_bishop: 'Läufer' +play.piece_knight: 'Springer' +play.piece_pawn: 'Bauer' + +# --------------------------------------------------------------------------- +# Watch-Befehl (Engine gegen Engine) +# --------------------------------------------------------------------------- +watch.intro: 'Engine-Showcase: Weiß (Stufe %{white}) vs. Schwarz (Stufe %{black})' +watch.thinking: '%{color} denkt nach (Stufe %{level}) …' +watch.move_ticker: 'Zug %{num} — %{color}: %{mv} (Bewertung %{score}, Tiefe %{depth}, %{nodes} Knoten)' +watch.move_cap_reached: 'Zuglimit von %{cap} erreicht — Spiel ohne Ergebnis beendet.' + +# --------------------------------------------------------------------------- +# Analyze-Befehl (zusätzliche Schlüssel) +# --------------------------------------------------------------------------- +analyze.need_input: 'Nichts zu analysieren: Übergib --fen und/oder --moves "e2e4 e7e5 ..."' +analyze.bad_move: "Zug '%{mv}' kann nicht gelesen werden — erwartet wird Koordinatennotation wie e2e4 oder e7e8q." +analyze.illegal_move: "Ungültiger Zug '%{mv}': %{error}" +analyze.position_header: 'Stellungsanalyse' +analyze.game_header: 'Partieanalyse' +analyze.progress_label: 'Züge werden analysiert' +analyze.verdict: 'Urteil: bester Zug %{mv}, Bewertung %{score} (Tiefe %{depth})' +analyze.better_was: 'besser: %{mv}' +analyze.summary: '%{total} Züge analysiert — %{best} stark, %{inaccuracies} Ungenauigkeiten, %{mistakes} Fehler, %{blunders} Patzer' +analyze.col_depth: 'Tiefe' +analyze.col_eval: 'Bewertung' +analyze.col_mate: 'Matt' +analyze.col_nodes: 'Knoten' +analyze.col_pv: 'Hauptvariante' +analyze.col_side: 'Seite' +analyze.col_move: 'Zug' +analyze.col_loss: 'Verlust' +analyze.col_quality: 'Qualität' +analyze.col_better: 'Alternative' +analyze.moves_after_game_over: "Das Spiel endete vor Zug '%{mv}' — nachfolgende Züge sind nicht erlaubt." + +# --------------------------------------------------------------------------- +# Bench-Befehl (zusätzliche Schlüssel) +# --------------------------------------------------------------------------- +bench.progress_label: 'Benchmark läuft' +bench.col_position: 'Stellung' +bench.col_time: 'Zeit' +bench.col_nps: 'K/s' +bench.totals: 'Gesamt: %{nodes} Knoten in %{secs}s — %{nps} Knoten/Sekunde' + +# --------------------------------------------------------------------------- +# Perft-Befehl +# --------------------------------------------------------------------------- +perft.header: 'Perft — Überprüfung der Zuggenerierung' +perft.col_nodes: 'Knoten' +perft.col_reference: 'Referenz' +perft.col_status: 'Status' +perft.ok: 'OK' +perft.fail: 'FEHLER' +perft.all_ok: 'Alle berechneten Knotenzahlen stimmen mit den Referenzwerten überein.' +perft.mismatch: 'ABWEICHUNG — die Zuggenerierung weicht von den Referenzzahlen ab!' +perft.divide_total: 'Gesamt: %{total} Knoten über %{moves} Wurzelzüge (%{ms} ms)' diff --git a/locales/en.yml b/locales/en.yml index 3f8fa41..1a7640f 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -51,6 +51,108 @@ cli.quickstart_serve: 'Start server on default port' cli.quickstart_play: 'Play a local game' cli.quickstart_help: 'Full help for any command' cli.run_help_hint: 'Run %{cmd} for detailed usage.' +cli.banner_tagline: 'Chess in your terminal' +cli.version_label: 'Version:' +cli.locale_label: 'Locale:' +cli.docs_label: 'Documentation:' +cli.nodes_suffix: 'nodes' +cli.invalid_fen: 'Invalid FEN: %{error}' +cli.cmd_watch_desc: 'Watch the engine play itself' +cli.cmd_analyze_desc: 'Analyze a position or a game with the engine' +cli.cmd_bench_desc: 'Run the engine benchmark suite' +cli.cmd_perft_desc: 'Verify move generation (perft counts)' +cli.cmd_uci_desc: 'Run as a UCI engine for chess GUIs' +cli.quickstart_watch: 'Watch an engine showcase game' + +# --------------------------------------------------------------------------- +# Play command (vs engine / vs human) +# --------------------------------------------------------------------------- +play.intro_engine: 'Playing against the engine at level %{level} — you are %{color}.' +play.intro_human: 'Local two-player game — White and Black share this terminal.' +play.intro_help_hint: 'Type %{help} for the list of in-game commands.' +play.engine_thinking: 'Engine is thinking (level %{level}) …' +play.engine_played: 'Engine plays %{mv} (%{piece}) — depth %{depth}, eval %{score}, %{secs}s' +play.engine_resigns: 'The engine found no move and resigns. You win!' +play.hint_thinking: 'Calculating a hint …' +play.hint_result: 'Hint: %{mv} (eval %{score})' +play.hint_none: 'No hint available in this position.' +play.cmd_hint: 'Ask the engine to suggest a move' +play.cmd_undo: 'Take back the last full move' +play.cannot_undo: 'Nothing to undo yet.' +play.undo_done: 'Took back the last move.' +play.position_terminal: 'This position has no legal moves — nothing to play.' +play.result_title: 'GAME OVER' +play.result_winner_label: 'Winner:' +play.result_reason_label: 'Reason:' +play.result_moves_label: 'Moves:' +play.result_duration_label: 'Duration:' +play.winner_white: 'White' +play.winner_black: 'Black' +play.winner_draw: 'Draw — nobody' +play.game_unfinished: 'Game unfinished' +play.piece_king: 'King' +play.piece_queen: 'Queen' +play.piece_rook: 'Rook' +play.piece_bishop: 'Bishop' +play.piece_knight: 'Knight' +play.piece_pawn: 'Pawn' + +# --------------------------------------------------------------------------- +# Watch command (engine vs engine) +# --------------------------------------------------------------------------- +watch.intro: 'Engine showcase: White (level %{white}) vs Black (level %{black})' +watch.thinking: '%{color} is thinking (level %{level}) …' +watch.move_ticker: 'Move %{num} — %{color}: %{mv} (eval %{score}, depth %{depth}, %{nodes} nodes)' +watch.move_cap_reached: 'Move cap of %{cap} reached — game stopped without a result.' + +# --------------------------------------------------------------------------- +# Analyze command +# --------------------------------------------------------------------------- +analyze.need_input: 'Nothing to analyze: pass --fen and/or --moves "e2e4 e7e5 ..."' +analyze.no_moves: 'The --moves list is empty.' +analyze.bad_move: "Cannot parse move '%{mv}' — expected coordinate notation like e2e4 or e7e8q." +analyze.illegal_move: "Illegal move '%{mv}': %{error}" +analyze.position_header: 'Position analysis' +analyze.game_header: 'Game analysis' +analyze.progress_label: 'Analyzing moves' +analyze.verdict: 'Verdict: best move %{mv}, eval %{score} (depth %{depth})' +analyze.no_best_move: 'No best move found — the position may be terminal.' +analyze.better_was: 'better: %{mv}' +analyze.summary: '%{total} moves analyzed — %{best} strong, %{inaccuracies} inaccuracies, %{mistakes} mistakes, %{blunders} blunders' +analyze.col_depth: 'depth' +analyze.col_eval: 'eval' +analyze.col_mate: 'mate' +analyze.col_nodes: 'nodes' +analyze.col_pv: 'principal variation' +analyze.col_side: 'side' +analyze.col_move: 'move' +analyze.col_loss: 'loss' +analyze.col_quality: 'quality' +analyze.col_better: 'alternative' +analyze.moves_after_game_over: "The game ended before move '%{mv}' — trailing moves are not allowed." + +# --------------------------------------------------------------------------- +# Bench command +# --------------------------------------------------------------------------- +bench.header: 'Engine benchmark' +bench.progress_label: 'Benchmarking' +bench.col_position: 'position' +bench.col_time: 'time' +bench.col_nps: 'nps' +bench.totals: 'Total: %{nodes} nodes in %{secs}s — %{nps} nodes/second' + +# --------------------------------------------------------------------------- +# Perft command +# --------------------------------------------------------------------------- +perft.header: 'Perft — move generation verification' +perft.col_nodes: 'nodes' +perft.col_reference: 'reference' +perft.col_status: 'status' +perft.ok: 'OK' +perft.fail: 'FAIL' +perft.all_ok: 'All computed node counts match the reference values.' +perft.mismatch: 'MISMATCH — move generation differs from the reference counts!' +perft.divide_total: 'Total: %{total} nodes across %{moves} root moves (%{ms} ms)' # --------------------------------------------------------------------------- # Update / version check diff --git a/locales/es.yml b/locales/es.yml index 032501d..4510553 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: 'No se pudo cargar la tablebase Syzygy: %{error}' analysis.engine_config: 'Motor de análisis: profundidad=%{depth}, TT=%{tt}MB' analysis.archive_load_failed: 'No se pudo cargar la partida archivada' analysis.archive_replay_failed: 'No se pudo reproducir la partida archivada' + +# --------------------------------------------------------------------------- +# Salida del motor (análisis y progreso de la búsqueda) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Comando analyze +# --------------------------------------------------------------------------- +analyze.no_moves: 'La lista --moves está vacía.' +analyze.no_best_move: 'No se encontró la mejor jugada — la posición puede ser terminal.' + +# --------------------------------------------------------------------------- +# Comando bench +# --------------------------------------------------------------------------- +bench.header: 'Prueba de rendimiento del motor' + +# --------------------------------------------------------------------------- +# Jugar contra el motor +# --------------------------------------------------------------------------- +play.engine_thinking: 'El motor está pensando (nivel %{level})…' + +# Descripciones de comandos CLI (comandos del motor) +cli.cmd_analyze_desc: 'Analizar una posición o una partida con el motor' +cli.cmd_bench_desc: 'Ejecutar la suite de pruebas de rendimiento del motor' + +# --------------------------------------------------------------------------- +# Pantalla de bienvenida CLI (comandos del motor y etiquetas adicionales) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'Ver al motor jugar contra sí mismo' +cli.cmd_perft_desc: 'Verificar la generación de jugadas (recuentos perft)' +cli.cmd_uci_desc: 'Ejecutar como motor UCI para interfaces de ajedrez' +cli.quickstart_watch: 'Ver una partida de demostración del motor' +cli.banner_tagline: 'Ajedrez en tu terminal' +cli.version_label: 'Versión:' +cli.locale_label: 'Idioma:' +cli.docs_label: 'Documentación:' +cli.nodes_suffix: 'nodos' +cli.invalid_fen: 'FEN inválido: %{error}' + +# --------------------------------------------------------------------------- +# Comando play (contra el motor / contra humano) +# --------------------------------------------------------------------------- +play.intro_engine: 'Juegas contra el motor en el nivel %{level} — eres las %{color}.' +play.intro_human: 'Partida local para dos — las blancas y las negras comparten esta terminal.' +play.intro_help_hint: 'Escribe %{help} para ver la lista de comandos del juego.' +play.engine_played: 'El motor juega %{mv} (%{piece}) — profundidad %{depth}, eval %{score}, %{secs}s' +play.engine_resigns: 'El motor no encontró ninguna jugada y se rinde. ¡Ganas tú!' +play.hint_thinking: 'Calculando una pista …' +play.hint_result: 'Pista: %{mv} (eval %{score})' +play.hint_none: 'No hay ninguna pista disponible en esta posición.' +play.cmd_hint: 'Pedir al motor que sugiera una jugada' +play.cmd_undo: 'Deshacer la última jugada completa' +play.cannot_undo: 'Nada que deshacer todavía.' +play.undo_done: 'Última jugada deshecha.' +play.position_terminal: 'Esta posición no tiene jugadas legales — nada que jugar.' +play.result_title: 'PARTIDA TERMINADA' +play.result_winner_label: 'Ganador:' +play.result_reason_label: 'Razón:' +play.result_moves_label: 'Jugadas:' +play.result_duration_label: 'Duración:' +play.winner_white: 'Blancas' +play.winner_black: 'Negras' +play.winner_draw: 'Tablas — nadie' +play.game_unfinished: 'Partida sin terminar' +play.piece_king: 'Rey' +play.piece_queen: 'Dama' +play.piece_rook: 'Torre' +play.piece_bishop: 'Alfil' +play.piece_knight: 'Caballo' +play.piece_pawn: 'Peón' + +# --------------------------------------------------------------------------- +# Comando watch (motor contra motor) +# --------------------------------------------------------------------------- +watch.intro: 'Demostración del motor: Blancas (nivel %{white}) vs Negras (nivel %{black})' +watch.thinking: '%{color} está pensando (nivel %{level}) …' +watch.move_ticker: 'Jugada %{num} — %{color}: %{mv} (eval %{score}, profundidad %{depth}, %{nodes} nodos)' +watch.move_cap_reached: 'Límite de %{cap} jugadas alcanzado — partida detenida sin resultado.' + +# --------------------------------------------------------------------------- +# Comando analyze (claves adicionales) +# --------------------------------------------------------------------------- +analyze.need_input: 'Nada que analizar: pasa --fen y/o --moves "e2e4 e7e5 ..."' +analyze.bad_move: "No se puede interpretar la jugada '%{mv}' — se esperaba notación de coordenadas como e2e4 o e7e8q." +analyze.illegal_move: "Jugada ilegal '%{mv}': %{error}" +analyze.position_header: 'Análisis de la posición' +analyze.game_header: 'Análisis de la partida' +analyze.progress_label: 'Analizando jugadas' +analyze.verdict: 'Veredicto: mejor jugada %{mv}, eval %{score} (profundidad %{depth})' +analyze.better_was: 'mejor: %{mv}' +analyze.summary: '%{total} jugadas analizadas — %{best} fuertes, %{inaccuracies} imprecisiones, %{mistakes} errores, %{blunders} errores graves' +analyze.col_depth: 'profundidad' +analyze.col_eval: 'eval' +analyze.col_mate: 'mate' +analyze.col_nodes: 'nodos' +analyze.col_pv: 'variante principal' +analyze.col_side: 'bando' +analyze.col_move: 'jugada' +analyze.col_loss: 'pérdida' +analyze.col_quality: 'calidad' +analyze.col_better: 'alternativa' +analyze.moves_after_game_over: "El juego terminó antes del movimiento '%{mv}' — los movimientos adicionales no están permitidos." + + +# --------------------------------------------------------------------------- +# Comando bench (claves adicionales) +# --------------------------------------------------------------------------- +bench.progress_label: 'Ejecutando benchmark' +bench.col_position: 'posición' +bench.col_time: 'tiempo' +bench.col_nps: 'n/s' +bench.totals: 'Total: %{nodes} nodos en %{secs}s — %{nps} nodos/segundo' + +# --------------------------------------------------------------------------- +# Comando perft +# --------------------------------------------------------------------------- +perft.header: 'Perft — verificación de la generación de jugadas' +perft.col_nodes: 'nodos' +perft.col_reference: 'referencia' +perft.col_status: 'estado' +perft.ok: 'OK' +perft.fail: 'FALLO' +perft.all_ok: 'Todos los recuentos de nodos calculados coinciden con los valores de referencia.' +perft.mismatch: 'DISCREPANCIA — ¡la generación de jugadas difiere de los recuentos de referencia!' +perft.divide_total: 'Total: %{total} nodos en %{moves} jugadas raíz (%{ms} ms)' diff --git a/locales/fr.yml b/locales/fr.yml index c8c4561..777cf4e 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: 'Échec du chargement de la tablebase Syzygy : %{erro analysis.engine_config: "Moteur d'analyse : profondeur=%{depth}, TT=%{tt}MB" analysis.archive_load_failed: 'Impossible de charger la partie archivée' analysis.archive_replay_failed: 'Impossible de rejouer la partie archivée' + +# --------------------------------------------------------------------------- +# Sortie du moteur (analyse et progression de la recherche) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Commande analyze +# --------------------------------------------------------------------------- +analyze.no_moves: 'La liste --moves est vide.' +analyze.no_best_move: 'Aucun meilleur coup trouvé — la position est peut-être terminale.' + +# --------------------------------------------------------------------------- +# Commande bench +# --------------------------------------------------------------------------- +bench.header: 'Test de performance du moteur' + +# --------------------------------------------------------------------------- +# Jouer contre le moteur +# --------------------------------------------------------------------------- +play.engine_thinking: 'Le moteur réfléchit (niveau %{level})…' + +# Descriptions des commandes CLI (commandes du moteur) +cli.cmd_analyze_desc: 'Analyser une position ou une partie avec le moteur' +cli.cmd_bench_desc: 'Exécuter la suite de tests de performance du moteur' + +# --------------------------------------------------------------------------- +# Écran d'accueil CLI (commandes moteur et étiquettes supplémentaires) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'Regarder le moteur jouer contre lui-même' +cli.cmd_perft_desc: 'Vérifier la génération des coups (comptes perft)' +cli.cmd_uci_desc: 'Fonctionner comme moteur UCI pour les interfaces graphiques' +cli.quickstart_watch: "Regarder une partie de démonstration du moteur" +cli.banner_tagline: 'Les échecs dans votre terminal' +cli.version_label: 'Version :' +cli.locale_label: 'Langue :' +cli.docs_label: 'Documentation :' +cli.nodes_suffix: 'nœuds' +cli.invalid_fen: 'FEN invalide : %{error}' + +# --------------------------------------------------------------------------- +# Commande play (contre le moteur / contre un humain) +# --------------------------------------------------------------------------- +play.intro_engine: 'Vous jouez contre le moteur au niveau %{level} — vous avez les %{color}.' +play.intro_human: 'Partie locale à deux joueurs — les blancs et les noirs partagent ce terminal.' +play.intro_help_hint: 'Tapez %{help} pour la liste des commandes en jeu.' +play.engine_played: 'Le moteur joue %{mv} (%{piece}) — profondeur %{depth}, éval %{score}, %{secs}s' +play.engine_resigns: "Le moteur n'a trouvé aucun coup et abandonne. Vous gagnez !" +play.hint_thinking: "Calcul d'un indice …" +play.hint_result: 'Indice : %{mv} (éval %{score})' +play.hint_none: 'Aucun indice disponible dans cette position.' +play.cmd_hint: 'Demander un coup au moteur' +play.cmd_undo: 'Annuler le dernier coup complet' +play.cannot_undo: 'Rien à annuler pour le moment.' +play.undo_done: 'Dernier coup annulé.' +play.position_terminal: "Cette position n'a aucun coup légal — rien à jouer." +play.result_title: 'PARTIE TERMINÉE' +play.result_winner_label: 'Vainqueur :' +play.result_reason_label: 'Raison :' +play.result_moves_label: 'Coups :' +play.result_duration_label: 'Durée :' +play.winner_white: 'Blancs' +play.winner_black: 'Noirs' +play.winner_draw: 'Nulle — personne' +play.game_unfinished: 'Partie inachevée' +play.piece_king: 'Roi' +play.piece_queen: 'Dame' +play.piece_rook: 'Tour' +play.piece_bishop: 'Fou' +play.piece_knight: 'Cavalier' +play.piece_pawn: 'Pion' + +# --------------------------------------------------------------------------- +# Commande watch (moteur contre moteur) +# --------------------------------------------------------------------------- +watch.intro: 'Démonstration du moteur : Blancs (niveau %{white}) vs Noirs (niveau %{black})' +watch.thinking: '%{color} réfléchit (niveau %{level}) …' +watch.move_ticker: 'Coup %{num} — %{color} : %{mv} (éval %{score}, profondeur %{depth}, %{nodes} nœuds)' +watch.move_cap_reached: 'Limite de %{cap} coups atteinte — partie arrêtée sans résultat.' + +# --------------------------------------------------------------------------- +# Commande analyze (clés supplémentaires) +# --------------------------------------------------------------------------- +analyze.need_input: 'Rien à analyser : fournissez --fen et/ou --moves "e2e4 e7e5 ..."' +analyze.bad_move: "Impossible de lire le coup '%{mv}' — notation de coordonnées attendue comme e2e4 ou e7e8q." +analyze.illegal_move: "Coup illégal '%{mv}' : %{error}" +analyze.position_header: 'Analyse de la position' +analyze.game_header: 'Analyse de la partie' +analyze.progress_label: 'Analyse des coups' +analyze.verdict: 'Verdict : meilleur coup %{mv}, éval %{score} (profondeur %{depth})' +analyze.better_was: 'meilleur : %{mv}' +analyze.summary: '%{total} coups analysés — %{best} forts, %{inaccuracies} imprécisions, %{mistakes} erreurs, %{blunders} gaffes' +analyze.col_depth: 'profondeur' +analyze.col_eval: 'éval' +analyze.col_mate: 'mat' +analyze.col_nodes: 'nœuds' +analyze.col_pv: 'variante principale' +analyze.col_side: 'camp' +analyze.col_move: 'coup' +analyze.col_loss: 'perte' +analyze.col_quality: 'qualité' +analyze.col_better: 'alternative' +analyze.moves_after_game_over: "La partie s'est terminée avant le coup '%{mv}' — les coups supplémentaires ne sont pas autorisés." + + +# --------------------------------------------------------------------------- +# Commande bench (clés supplémentaires) +# --------------------------------------------------------------------------- +bench.progress_label: 'Test de performance en cours' +bench.col_position: 'position' +bench.col_time: 'temps' +bench.col_nps: 'n/s' +bench.totals: 'Total : %{nodes} nœuds en %{secs}s — %{nps} nœuds/seconde' + +# --------------------------------------------------------------------------- +# Commande perft +# --------------------------------------------------------------------------- +perft.header: 'Perft — vérification de la génération des coups' +perft.col_nodes: 'nœuds' +perft.col_reference: 'référence' +perft.col_status: 'état' +perft.ok: 'OK' +perft.fail: 'ÉCHEC' +perft.all_ok: 'Tous les nombres de nœuds calculés correspondent aux valeurs de référence.' +perft.mismatch: 'DIVERGENCE — la génération des coups diffère des nombres de référence !' +perft.divide_total: 'Total : %{total} nœuds sur %{moves} coups racine (%{ms} ms)' diff --git a/locales/ja.yml b/locales/ja.yml index fed5962..b7b35ae 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: 'Syzygy テーブルベースの読み込みに失敗 analysis.engine_config: '分析エンジン:深さ=%{depth}、TT=%{tt}MB' analysis.archive_load_failed: 'アーカイブ済みゲームの読み込みに失敗' analysis.archive_replay_failed: 'アーカイブ済みゲームのリプレイに失敗' + +# --------------------------------------------------------------------------- +# エンジン出力(分析・ライブ探索の進捗) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# analyze コマンド +# --------------------------------------------------------------------------- +analyze.no_moves: '--moves リストが空です。' +analyze.no_best_move: '最善手が見つかりません — 局面は終局の可能性があります。' + +# --------------------------------------------------------------------------- +# bench コマンド +# --------------------------------------------------------------------------- +bench.header: 'エンジンベンチマーク' + +# --------------------------------------------------------------------------- +# エンジンとの対局 +# --------------------------------------------------------------------------- +play.engine_thinking: 'エンジンが考えています(レベル %{level})…' + +# CLI コマンドの説明(エンジンコマンド) +cli.cmd_analyze_desc: 'エンジンで局面または対局を分析' +cli.cmd_bench_desc: 'エンジンのベンチマークスイートを実行' + +# --------------------------------------------------------------------------- +# CLI ウェルカム画面(追加のエンジンコマンドとラベル) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'エンジンの自己対局を観戦' +cli.cmd_perft_desc: '手の生成を検証(perft カウント)' +cli.cmd_uci_desc: 'チェス GUI 用の UCI エンジンとして実行' +cli.quickstart_watch: 'エンジンのデモ対局を観戦' +cli.banner_tagline: 'ターミナルでチェスを' +cli.version_label: 'バージョン:' +cli.locale_label: '言語:' +cli.docs_label: 'ドキュメント:' +cli.nodes_suffix: 'ノード' +cli.invalid_fen: '無効な FEN:%{error}' + +# --------------------------------------------------------------------------- +# play コマンド(エンジン対戦 / 対人対戦) +# --------------------------------------------------------------------------- +play.intro_engine: 'レベル %{level} のエンジンと対戦します — あなたは %{color} です。' +play.intro_human: 'ローカル二人対局 — 白と黒がこのターミナルを共有します。' +play.intro_help_hint: '対局中コマンドの一覧は %{help} で表示できます。' +play.engine_played: 'エンジンの手:%{mv}(%{piece})— 深さ %{depth}、評価 %{score}、%{secs}秒' +play.engine_resigns: 'エンジンは指す手が見つからず投了しました。あなたの勝ちです!' +play.hint_thinking: 'ヒントを計算中 …' +play.hint_result: 'ヒント:%{mv}(評価 %{score})' +play.hint_none: 'この局面では利用できるヒントがありません。' +play.cmd_hint: 'エンジンに手を提案してもらう' +play.cmd_undo: '直前の1手(両者の指し手)を取り消す' +play.cannot_undo: 'まだ取り消せる手がありません。' +play.undo_done: '直前の手を取り消しました。' +play.position_terminal: 'この局面には合法手がありません — 指す手がありません。' +play.result_title: '対局終了' +play.result_winner_label: '勝者:' +play.result_reason_label: '理由:' +play.result_moves_label: '手数:' +play.result_duration_label: '所要時間:' +play.winner_white: '白' +play.winner_black: '黒' +play.winner_draw: '引き分け — 勝者なし' +play.game_unfinished: '未完了の対局' +play.piece_king: 'キング' +play.piece_queen: 'クイーン' +play.piece_rook: 'ルーク' +play.piece_bishop: 'ビショップ' +play.piece_knight: 'ナイト' +play.piece_pawn: 'ポーン' + +# --------------------------------------------------------------------------- +# watch コマンド(エンジン対エンジン) +# --------------------------------------------------------------------------- +watch.intro: 'エンジンデモ:白(レベル %{white})対 黒(レベル %{black})' +watch.thinking: '%{color}が考えています(レベル %{level})…' +watch.move_ticker: '第 %{num} 手 — %{color}:%{mv}(評価 %{score}、深さ %{depth}、%{nodes} ノード)' +watch.move_cap_reached: '手数上限 %{cap} に到達 — 対局は結果なしで終了しました。' + +# --------------------------------------------------------------------------- +# analyze コマンド(追加キー) +# --------------------------------------------------------------------------- +analyze.need_input: '分析する対象がありません:--fen や --moves "e2e4 e7e5 ..." を指定してください' +analyze.bad_move: "手 '%{mv}' を解析できません — e2e4 や e7e8q のような座標記法が必要です。" +analyze.illegal_move: "不正な手 '%{mv}':%{error}" +analyze.position_header: '局面分析' +analyze.game_header: '対局分析' +analyze.progress_label: '手を分析中' +analyze.verdict: '判定:最善手 %{mv}、評価 %{score}(深さ %{depth})' +analyze.better_was: 'より良い手:%{mv}' +analyze.summary: '%{total} 手を分析 — 好手 %{best}、不正確 %{inaccuracies}、ミス %{mistakes}、大悪手 %{blunders}' +analyze.col_depth: '深さ' +analyze.col_eval: '評価' +analyze.col_mate: '詰み' +analyze.col_nodes: 'ノード' +analyze.col_pv: '読み筋' +analyze.col_side: '手番' +analyze.col_move: '手' +analyze.col_loss: '損失' +analyze.col_quality: '品質' +analyze.col_better: '代替手' +analyze.moves_after_game_over: "ゲームはムーブ '%{mv}' の前に終了しました — 追加のムーブは許可されていません。" + + +# --------------------------------------------------------------------------- +# bench コマンド(追加キー) +# --------------------------------------------------------------------------- +bench.progress_label: 'ベンチマーク実行中' +bench.col_position: '局面' +bench.col_time: '時間' +bench.col_nps: 'ノード/秒' +bench.totals: '合計:%{secs}秒で %{nodes} ノード — 毎秒 %{nps} ノード' + +# --------------------------------------------------------------------------- +# perft コマンド +# --------------------------------------------------------------------------- +perft.header: 'Perft — 手の生成の検証' +perft.col_nodes: 'ノード' +perft.col_reference: '基準値' +perft.col_status: '状態' +perft.ok: 'OK' +perft.fail: '失敗' +perft.all_ok: '計算されたすべてのノード数が基準値と一致しています。' +perft.mismatch: '不一致 — 手の生成が基準値と異なります!' +perft.divide_total: '合計:%{moves} 個のルート手で %{total} ノード(%{ms} ミリ秒)' diff --git a/locales/pt.yml b/locales/pt.yml index ee477a2..b551fe1 100644 --- a/locales/pt.yml +++ b/locales/pt.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: 'Falha ao carregar tablebase Syzygy: %{error}' analysis.engine_config: 'Motor de análise: profundidade=%{depth}, TT=%{tt}MB' analysis.archive_load_failed: 'Falha ao carregar partida arquivada' analysis.archive_replay_failed: 'Falha ao reproduzir partida arquivada' + +# --------------------------------------------------------------------------- +# Saída do motor (análise e progresso da busca) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Comando analyze +# --------------------------------------------------------------------------- +analyze.no_moves: 'A lista --moves está vazia.' +analyze.no_best_move: 'Nenhum melhor lance encontrado — a posição pode ser terminal.' + +# --------------------------------------------------------------------------- +# Comando bench +# --------------------------------------------------------------------------- +bench.header: 'Benchmark do motor' + +# --------------------------------------------------------------------------- +# Jogar contra o motor +# --------------------------------------------------------------------------- +play.engine_thinking: 'O motor está pensando (nível %{level})…' + +# Descrições dos comandos CLI (comandos do motor) +cli.cmd_analyze_desc: 'Analisar uma posição ou uma partida com o motor' +cli.cmd_bench_desc: 'Executar a suíte de benchmark do motor' + +# --------------------------------------------------------------------------- +# Tela de boas-vindas CLI (comandos do motor e rótulos adicionais) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'Assistir o motor jogar contra si mesmo' +cli.cmd_perft_desc: 'Verificar a geração de lances (contagens perft)' +cli.cmd_uci_desc: 'Executar como motor UCI para interfaces de xadrez' +cli.quickstart_watch: 'Assistir a uma partida de demonstração do motor' +cli.banner_tagline: 'Xadrez no seu terminal' +cli.version_label: 'Versão:' +cli.locale_label: 'Idioma:' +cli.docs_label: 'Documentação:' +cli.nodes_suffix: 'nós' +cli.invalid_fen: 'FEN inválida: %{error}' + +# --------------------------------------------------------------------------- +# Comando play (contra o motor / contra humano) +# --------------------------------------------------------------------------- +play.intro_engine: 'Você joga contra o motor no nível %{level} — você é %{color}.' +play.intro_human: 'Partida local para dois — brancas e pretas compartilham este terminal.' +play.intro_help_hint: 'Digite %{help} para a lista de comandos do jogo.' +play.engine_played: 'O motor joga %{mv} (%{piece}) — profundidade %{depth}, aval %{score}, %{secs}s' +play.engine_resigns: 'O motor não encontrou nenhum lance e desiste. Você venceu!' +play.hint_thinking: 'Calculando uma dica …' +play.hint_result: 'Dica: %{mv} (aval %{score})' +play.hint_none: 'Nenhuma dica disponível nesta posição.' +play.cmd_hint: 'Pedir ao motor para sugerir um lance' +play.cmd_undo: 'Desfazer o último lance completo' +play.cannot_undo: 'Nada para desfazer ainda.' +play.undo_done: 'Último lance desfeito.' +play.position_terminal: 'Esta posição não tem lances legais — nada para jogar.' +play.result_title: 'FIM DE JOGO' +play.result_winner_label: 'Vencedor:' +play.result_reason_label: 'Razão:' +play.result_moves_label: 'Lances:' +play.result_duration_label: 'Duração:' +play.winner_white: 'Brancas' +play.winner_black: 'Pretas' +play.winner_draw: 'Empate — ninguém' +play.game_unfinished: 'Partida inacabada' +play.piece_king: 'Rei' +play.piece_queen: 'Dama' +play.piece_rook: 'Torre' +play.piece_bishop: 'Bispo' +play.piece_knight: 'Cavalo' +play.piece_pawn: 'Peão' + +# --------------------------------------------------------------------------- +# Comando watch (motor contra motor) +# --------------------------------------------------------------------------- +watch.intro: 'Demonstração do motor: Brancas (nível %{white}) vs Pretas (nível %{black})' +watch.thinking: '%{color} está pensando (nível %{level}) …' +watch.move_ticker: 'Lance %{num} — %{color}: %{mv} (aval %{score}, profundidade %{depth}, %{nodes} nós)' +watch.move_cap_reached: 'Limite de %{cap} lances atingido — partida interrompida sem resultado.' + +# --------------------------------------------------------------------------- +# Comando analyze (chaves adicionais) +# --------------------------------------------------------------------------- +analyze.need_input: 'Nada para analisar: passe --fen e/ou --moves "e2e4 e7e5 ..."' +analyze.bad_move: "Não foi possível interpretar o lance '%{mv}' — esperada notação de coordenadas como e2e4 ou e7e8q." +analyze.illegal_move: "Lance ilegal '%{mv}': %{error}" +analyze.position_header: 'Análise da posição' +analyze.game_header: 'Análise da partida' +analyze.progress_label: 'Analisando lances' +analyze.verdict: 'Veredicto: melhor lance %{mv}, aval %{score} (profundidade %{depth})' +analyze.better_was: 'melhor: %{mv}' +analyze.summary: '%{total} lances analisados — %{best} fortes, %{inaccuracies} imprecisões, %{mistakes} erros, %{blunders} erros graves' +analyze.col_depth: 'profundidade' +analyze.col_eval: 'aval' +analyze.col_mate: 'mate' +analyze.col_nodes: 'nós' +analyze.col_pv: 'variante principal' +analyze.col_side: 'lado' +analyze.col_move: 'lance' +analyze.col_loss: 'perda' +analyze.col_quality: 'qualidade' +analyze.col_better: 'alternativa' +analyze.moves_after_game_over: "O jogo terminou antes do movimento '%{mv}' — movimentos adicionais não são permitidos." + + +# --------------------------------------------------------------------------- +# Comando bench (chaves adicionais) +# --------------------------------------------------------------------------- +bench.progress_label: 'Executando benchmark' +bench.col_position: 'posição' +bench.col_time: 'tempo' +bench.col_nps: 'n/s' +bench.totals: 'Total: %{nodes} nós em %{secs}s — %{nps} nós/segundo' + +# --------------------------------------------------------------------------- +# Comando perft +# --------------------------------------------------------------------------- +perft.header: 'Perft — verificação da geração de lances' +perft.col_nodes: 'nós' +perft.col_reference: 'referência' +perft.col_status: 'estado' +perft.ok: 'OK' +perft.fail: 'FALHA' +perft.all_ok: 'Todas as contagens de nós calculadas correspondem aos valores de referência.' +perft.mismatch: 'DIVERGÊNCIA — a geração de lances difere das contagens de referência!' +perft.divide_total: 'Total: %{total} nós em %{moves} lances raiz (%{ms} ms)' diff --git a/locales/ru.yml b/locales/ru.yml index db0982e..906fe4d 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: 'Не удалось загрузить таблиц analysis.engine_config: 'Движок анализа: глубина=%{depth}, TT=%{tt}MB' analysis.archive_load_failed: 'Не удалось загрузить архивную партию' analysis.archive_replay_failed: 'Не удалось воспроизвести архивную партию' + +# --------------------------------------------------------------------------- +# Вывод движка (анализ и ход поиска) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Команда analyze +# --------------------------------------------------------------------------- +analyze.no_moves: 'Список --moves пуст.' +analyze.no_best_move: 'Лучший ход не найден — возможно, позиция конечная.' + +# --------------------------------------------------------------------------- +# Команда bench +# --------------------------------------------------------------------------- +bench.header: 'Бенчмарк движка' + +# --------------------------------------------------------------------------- +# Игра против движка +# --------------------------------------------------------------------------- +play.engine_thinking: 'Движок думает (уровень %{level})…' + +# Описания команд CLI (команды движка) +cli.cmd_analyze_desc: 'Анализ позиции или партии движком' +cli.cmd_bench_desc: 'Запустить набор тестов производительности движка' + +# --------------------------------------------------------------------------- +# Экран приветствия CLI (дополнительные команды движка и метки) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: 'Наблюдать, как движок играет сам с собой' +cli.cmd_perft_desc: 'Проверить генерацию ходов (счётчики perft)' +cli.cmd_uci_desc: 'Работать как UCI-движок для шахматных интерфейсов' +cli.quickstart_watch: 'Посмотреть показательную партию движка' +cli.banner_tagline: 'Шахматы в вашем терминале' +cli.version_label: 'Версия:' +cli.locale_label: 'Язык:' +cli.docs_label: 'Документация:' +cli.nodes_suffix: 'узлов' +cli.invalid_fen: 'Недопустимая FEN: %{error}' + +# --------------------------------------------------------------------------- +# Команда play (против движка / против человека) +# --------------------------------------------------------------------------- +play.intro_engine: 'Вы играете против движка на уровне %{level} — вы играете за %{color}.' +play.intro_human: 'Локальная партия на двоих — белые и чёрные используют один терминал.' +play.intro_help_hint: 'Введите %{help}, чтобы увидеть список игровых команд.' +play.engine_played: 'Движок играет %{mv} (%{piece}) — глубина %{depth}, оценка %{score}, %{secs}с' +play.engine_resigns: 'Движок не нашёл хода и сдаётся. Вы победили!' +play.hint_thinking: 'Вычисление подсказки …' +play.hint_result: 'Подсказка: %{mv} (оценка %{score})' +play.hint_none: 'В этой позиции подсказка недоступна.' +play.cmd_hint: 'Попросить движок предложить ход' +play.cmd_undo: 'Отменить последний полный ход' +play.cannot_undo: 'Пока нечего отменять.' +play.undo_done: 'Последний ход отменён.' +play.position_terminal: 'В этой позиции нет допустимых ходов — играть нечем.' +play.result_title: 'ПАРТИЯ ОКОНЧЕНА' +play.result_winner_label: 'Победитель:' +play.result_reason_label: 'Причина:' +play.result_moves_label: 'Ходов:' +play.result_duration_label: 'Длительность:' +play.winner_white: 'Белые' +play.winner_black: 'Чёрные' +play.winner_draw: 'Ничья — никто' +play.game_unfinished: 'Партия не завершена' +play.piece_king: 'Король' +play.piece_queen: 'Ферзь' +play.piece_rook: 'Ладья' +play.piece_bishop: 'Слон' +play.piece_knight: 'Конь' +play.piece_pawn: 'Пешка' + +# --------------------------------------------------------------------------- +# Команда watch (движок против движка) +# --------------------------------------------------------------------------- +watch.intro: 'Показательная игра движка: Белые (уровень %{white}) против Чёрных (уровень %{black})' +watch.thinking: '%{color} думают (уровень %{level}) …' +watch.move_ticker: 'Ход %{num} — %{color}: %{mv} (оценка %{score}, глубина %{depth}, %{nodes} узлов)' +watch.move_cap_reached: 'Достигнут лимит в %{cap} ходов — партия остановлена без результата.' + +# --------------------------------------------------------------------------- +# Команда analyze (дополнительные ключи) +# --------------------------------------------------------------------------- +analyze.need_input: 'Нечего анализировать: укажите --fen и/или --moves "e2e4 e7e5 ..."' +analyze.bad_move: "Не удалось разобрать ход '%{mv}' — ожидается координатная нотация вида e2e4 или e7e8q." +analyze.illegal_move: "Недопустимый ход '%{mv}': %{error}" +analyze.position_header: 'Анализ позиции' +analyze.game_header: 'Анализ партии' +analyze.progress_label: 'Анализ ходов' +analyze.verdict: 'Вердикт: лучший ход %{mv}, оценка %{score} (глубина %{depth})' +analyze.better_was: 'лучше: %{mv}' +analyze.summary: 'Проанализировано %{total} ходов — %{best} сильных, %{inaccuracies} неточностей, %{mistakes} ошибок, %{blunders} грубых ошибок' +analyze.col_depth: 'глубина' +analyze.col_eval: 'оценка' +analyze.col_mate: 'мат' +analyze.col_nodes: 'узлы' +analyze.col_pv: 'главный вариант' +analyze.col_side: 'сторона' +analyze.col_move: 'ход' +analyze.col_loss: 'потеря' +analyze.col_quality: 'качество' +analyze.col_better: 'альтернатива' +analyze.moves_after_game_over: "Игра завершилась до хода '%{mv}' — последующие ходы не допускаются." + + +# --------------------------------------------------------------------------- +# Команда bench (дополнительные ключи) +# --------------------------------------------------------------------------- +bench.progress_label: 'Выполнение бенчмарка' +bench.col_position: 'позиция' +bench.col_time: 'время' +bench.col_nps: 'у/с' +bench.totals: 'Итого: %{nodes} узлов за %{secs}с — %{nps} узлов/секунду' + +# --------------------------------------------------------------------------- +# Команда perft +# --------------------------------------------------------------------------- +perft.header: 'Perft — проверка генерации ходов' +perft.col_nodes: 'узлы' +perft.col_reference: 'эталон' +perft.col_status: 'статус' +perft.ok: 'OK' +perft.fail: 'ОШИБКА' +perft.all_ok: 'Все вычисленные количества узлов совпадают с эталонными значениями.' +perft.mismatch: 'НЕСОВПАДЕНИЕ — генерация ходов отличается от эталонных значений!' +perft.divide_total: 'Итого: %{total} узлов на %{moves} корневых ходов (%{ms} мс)' diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index c813222..14066dc 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -212,3 +212,128 @@ analysis.tablebase_failed: '加载 Syzygy 残局库失败:%{error}' analysis.engine_config: '分析引擎:深度=%{depth},TT=%{tt}MB' analysis.archive_load_failed: '加载已归档对局失败' analysis.archive_replay_failed: '重放已归档对局失败' + +# --------------------------------------------------------------------------- +# 引擎输出(分析与实时搜索进度) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# analyze 命令 +# --------------------------------------------------------------------------- +analyze.no_moves: '--moves 列表为空。' +analyze.no_best_move: '未找到最佳着法 —— 局面可能已是终局。' + +# --------------------------------------------------------------------------- +# bench 命令 +# --------------------------------------------------------------------------- +bench.header: '引擎基准测试' + +# --------------------------------------------------------------------------- +# 对战引擎 +# --------------------------------------------------------------------------- +play.engine_thinking: '引擎思考中(等级 %{level})…' + +# CLI 命令说明(引擎命令) +cli.cmd_analyze_desc: '用引擎分析局面或对局' +cli.cmd_bench_desc: '运行引擎基准测试套件' + +# --------------------------------------------------------------------------- +# CLI 欢迎界面(额外的引擎命令与标签) +# --------------------------------------------------------------------------- +cli.cmd_watch_desc: '观看引擎自我对弈' +cli.cmd_perft_desc: '验证走法生成(perft 计数)' +cli.cmd_uci_desc: '作为 UCI 引擎运行,供国际象棋 GUI 使用' +cli.quickstart_watch: '观看一局引擎演示对局' +cli.banner_tagline: '在终端里下国际象棋' +cli.version_label: '版本:' +cli.locale_label: '语言:' +cli.docs_label: '文档:' +cli.nodes_suffix: '节点' +cli.invalid_fen: '无效的 FEN:%{error}' + +# --------------------------------------------------------------------------- +# play 命令(对战引擎 / 对战真人) +# --------------------------------------------------------------------------- +play.intro_engine: '你正在与等级 %{level} 的引擎对战 —— 你执%{color}。' +play.intro_human: '本地双人对局 —— 白方和黑方共用此终端。' +play.intro_help_hint: '输入 %{help} 查看对局内命令列表。' +play.engine_played: '引擎走 %{mv}(%{piece})—— 深度 %{depth},评分 %{score},%{secs} 秒' +play.engine_resigns: '引擎未找到走法并认输。你赢了!' +play.hint_thinking: '正在计算提示 …' +play.hint_result: '提示:%{mv}(评分 %{score})' +play.hint_none: '此局面没有可用提示。' +play.cmd_hint: '请引擎推荐一步走法' +play.cmd_undo: '悔回上一个完整回合' +play.cannot_undo: '暂时没有可悔的棋。' +play.undo_done: '已悔上一步。' +play.position_terminal: '此局面没有合法走法 —— 无棋可下。' +play.result_title: '对局结束' +play.result_winner_label: '胜者:' +play.result_reason_label: '原因:' +play.result_moves_label: '回合数:' +play.result_duration_label: '用时:' +play.winner_white: '白方' +play.winner_black: '黑方' +play.winner_draw: '和棋 —— 无人胜' +play.game_unfinished: '对局未结束' +play.piece_king: '王' +play.piece_queen: '后' +play.piece_rook: '车' +play.piece_bishop: '象' +play.piece_knight: '马' +play.piece_pawn: '兵' + +# --------------------------------------------------------------------------- +# watch 命令(引擎对战引擎) +# --------------------------------------------------------------------------- +watch.intro: '引擎演示:白方(等级 %{white})对 黑方(等级 %{black})' +watch.thinking: '%{color}思考中(等级 %{level})…' +watch.move_ticker: '第 %{num} 回合 —— %{color}:%{mv}(评分 %{score},深度 %{depth},%{nodes} 节点)' +watch.move_cap_reached: '已达到 %{cap} 回合上限 —— 对局无结果而终止。' + +# --------------------------------------------------------------------------- +# analyze 命令(额外键) +# --------------------------------------------------------------------------- +analyze.need_input: '没有可分析的内容:请传入 --fen 和/或 --moves "e2e4 e7e5 ..."' +analyze.bad_move: "无法解析走法 '%{mv}' —— 期望坐标记法,如 e2e4 或 e7e8q。" +analyze.illegal_move: "非法走法 '%{mv}':%{error}" +analyze.position_header: '局面分析' +analyze.game_header: '对局分析' +analyze.progress_label: '正在分析走法' +analyze.verdict: '结论:最佳走法 %{mv},评分 %{score}(深度 %{depth})' +analyze.better_was: '更优:%{mv}' +analyze.summary: '已分析 %{total} 步 —— %{best} 步出色,%{inaccuracies} 步不精确,%{mistakes} 步失误,%{blunders} 步严重失误' +analyze.col_depth: '深度' +analyze.col_eval: '评分' +analyze.col_mate: '将杀' +analyze.col_nodes: '节点' +analyze.col_pv: '主变' +analyze.col_side: '执方' +analyze.col_move: '走法' +analyze.col_loss: '损失' +analyze.col_quality: '质量' +analyze.col_better: '替代' +analyze.moves_after_game_over: "游戏在走法 '%{mv}' 之前结束了 — 不允许额外的走法。" + + +# --------------------------------------------------------------------------- +# bench 命令(额外键) +# --------------------------------------------------------------------------- +bench.progress_label: '正在进行基准测试' +bench.col_position: '局面' +bench.col_time: '用时' +bench.col_nps: '节点/秒' +bench.totals: '总计:%{nodes} 个节点,用时 %{secs} 秒 —— 每秒 %{nps} 个节点' + +# --------------------------------------------------------------------------- +# perft 命令 +# --------------------------------------------------------------------------- +perft.header: 'Perft —— 走法生成验证' +perft.col_nodes: '节点' +perft.col_reference: '参考值' +perft.col_status: '状态' +perft.ok: '通过' +perft.fail: '失败' +perft.all_ok: '所有计算得出的节点数均与参考值一致。' +perft.mismatch: '不一致 —— 走法生成与参考节点数不符!' +perft.divide_total: '总计:%{moves} 个根走法共 %{total} 个节点(%{ms} 毫秒)' diff --git a/npm/README.md b/npm/README.md index 00c4974..9fc614e 100644 --- a/npm/README.md +++ b/npm/README.md @@ -2,7 +2,7 @@ **CheckAI chess engine compiled to WebAssembly** — use as a Node.js CLI tool or JavaScript library. -Current package release: **0.7.0**. +Current package release: **0.8.0**. Implements complete FIDE 2023 chess rules with move generation, position evaluation, deep search, full game management, and export (PGN/JSON/text). diff --git a/npm/package.json b/npm/package.json index b30a496..d2ca85c 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@josunlp/checkai", - "version": "0.7.0", + "version": "0.8.0", "packageManager": "bun@1.3.10", "description": "CheckAI chess engine — FIDE 2023 rules, move generation, evaluation, search, game management, and export via WebAssembly", "license": "MIT", diff --git a/src/api.rs b/src/api.rs index f22ff50..c6a3084 100644 --- a/src/api.rs +++ b/src/api.rs @@ -41,7 +41,7 @@ pub struct AppState { #[openapi( info( title = "CheckAI — Chess API for AI Agents", - version = "0.7.0", + version = "0.8.0", description = "A REST API that allows AI agents to play chess against each other. \ Follows FIDE 2023 Laws of Chess. Agents communicate using JSON \ game states and move objects as defined in the AGENT.md protocol.", diff --git a/src/cli/analyze.rs b/src/cli/analyze.rs new file mode 100644 index 0000000..7275a3a --- /dev/null +++ b/src/cli/analyze.rs @@ -0,0 +1,373 @@ +//! `checkai analyze` — engine analysis of a position or a whole game. +//! +//! Two modes: +//! +//! 1. **Position dive** (`--fen `): iterative-deepening table with +//! one row per depth (eval, mate distance, nodes, PV) and a final +//! verdict line with the best move. +//! 2. **Game analysis** (`--moves "e2e4 e7e5 ..."`): replays a list of +//! long-algebraic moves (optionally starting from `--fen`), searches +//! each position before and after the played move, and annotates each +//! move with its centipawn loss, a `!`/`?!`/`?`/`??` marker and the +//! better alternative when the played move loses more than 50 cp. +//! +//! There is no PGN importer in CheckAI yet, so game input is a plain +//! space-separated move list in coordinate notation (`e2e4`, `e7e8q`). + +use clap::Args; +use colored::Colorize; + +use super::fen; +use super::progress::bar; +use super::score::format_score; +use super::{CliCommand, CliContext, CliResult, cli_error}; +use crate::analysis::MoveQuality; +use crate::search::{MAX_DEPTH, SearchEngine, SearchLimits}; +use crate::terminal::parse_move_input; +use crate::types::Color; + +/// Default per-move thinking time for game analysis (ms). +const DEFAULT_MOVE_ANALYSIS_MS: u64 = 1_000; +/// Default thinking time for a single-position dive (ms). +const DEFAULT_POSITION_ANALYSIS_MS: u64 = 5_000; +/// Centipawn loss above which the better alternative is displayed. +const SHOW_ALTERNATIVE_THRESHOLD: i32 = 50; +/// Transposition table size for analysis engines (MB). +const ANALYSIS_TT_MB: usize = 128; + +/// Arguments for `checkai analyze`. +#[derive(Args, Debug)] +#[command(after_help = "\ +Examples:\n\ + checkai analyze --fen \"r1bqkbnr/...\" Deep-dive one position\n\ + checkai analyze --fen \"\" --depth 16 Fixed-depth dive\n\ + checkai analyze --moves \"e2e4 e7e5 g1f3\" Annotate a game\n\ + checkai analyze --fen \"\" --moves \"...\" Game from a custom start\n\ + checkai analyze --moves \"...\" --movetime 250 Faster, shallower verdicts\n\ +\n\ +Note: PGN import is not supported yet — pass moves in coordinate\n\ +notation (e2e4, e7e8q), space-separated.")] +pub struct AnalyzeArgs { + /// Position to analyze (or the starting position for --moves). + #[arg(long)] + pub fen: Option, + + /// Space-separated long-algebraic move list to annotate. + #[arg(long)] + pub moves: Option, + + /// Fixed search depth (replaces the default time budget). + #[arg(long)] + pub depth: Option, + + /// Thinking time in milliseconds (per move for --moves). + #[arg(long)] + pub movetime: Option, +} + +impl CliCommand for AnalyzeArgs { + fn run(self, ctx: &CliContext) -> CliResult { + match (&self.moves, &self.fen) { + (Some(moves), _) => analyze_game(ctx, &self, moves.clone()), + (None, Some(_)) => analyze_position(&self), + (None, None) => Err(cli_error(t!("analyze.need_input").to_string())), + } + } +} + +/// Builds the search limits for this invocation. +fn limits_for(args: &AnalyzeArgs, default_ms: u64) -> SearchLimits { + match (args.depth, args.movetime) { + (Some(depth), None) => SearchLimits::depth(depth.clamp(1, MAX_DEPTH)), + (depth, movetime) => SearchLimits { + max_depth: depth.unwrap_or(MAX_DEPTH).clamp(1, MAX_DEPTH), + move_time_ms: Some(movetime.unwrap_or(default_ms)), + max_nodes: None, + }, + } +} + +/// Maps a centipawn loss to a chess annotation marker. +/// +/// `!` best/near-best, empty for solid moves, `?!` inaccuracy, +/// `?` mistake, `??` blunder. +pub fn annotation_marker(cp_loss: i32) -> &'static str { + match cp_loss { + i32::MIN..=10 => "!", + 11..=50 => "", + 51..=100 => "?!", + 101..=300 => "?", + _ => "??", + } +} + +/// Returns the localized description of a move-quality class. +fn quality_label(quality: MoveQuality) -> String { + match quality { + MoveQuality::Best => t!("analysis.quality.best").to_string(), + MoveQuality::Excellent => t!("analysis.quality.excellent").to_string(), + MoveQuality::Good => t!("analysis.quality.good").to_string(), + MoveQuality::Inaccuracy => t!("analysis.quality.inaccuracy").to_string(), + MoveQuality::Mistake => t!("analysis.quality.mistake").to_string(), + MoveQuality::Blunder => t!("analysis.quality.blunder").to_string(), + MoveQuality::Book => t!("analysis.quality.book").to_string(), + } +} + +/// Single-position deep dive: prints one table row per completed depth +/// and a final verdict. +fn analyze_position(args: &AnalyzeArgs) -> CliResult { + let fen_str = args.fen.as_deref().unwrap_or(fen::START_FEN); + let game = fen::game_from_fen(fen_str) + .map_err(|e| cli_error(t!("cli.invalid_fen", error = e).to_string()))?; + let pos = fen::search_position(&game); + let limits = limits_for(args, DEFAULT_POSITION_ANALYSIS_MS); + + println!(); + println!( + "{}", + t!("analyze.position_header").to_string().yellow().bold() + ); + println!(" {}", fen::game_to_fen(&game).dimmed()); + println!(); + println!( + " {:>5} {:>7} {:>6} {:>10} {}", + t!("analyze.col_depth"), + t!("analyze.col_eval"), + t!("analyze.col_mate"), + t!("analyze.col_nodes"), + t!("analyze.col_pv") + ); + + let mut engine = SearchEngine::new(ANALYSIS_TT_MB); + let mut on_iteration = |info: &crate::search::IterationInfo| { + // The mate distance lives in its own column, so the eval column shows + // a dash for mates instead of repeating `#N`. + let (eval_cell, mate) = match info.mate_in { + Some(m) => ("—".to_string(), format!("#{m}")), + None => (format_score(info.score_cp), String::new()), + }; + let pv: Vec<&str> = info.pv.iter().take(8).map(String::as_str).collect(); + println!( + " {:>5} {:>7} {:>6} {:>10} {}", + info.depth, + eval_cell, + mate, + info.nodes, + pv.join(" ").cyan() + ); + }; + let result = engine.search_limited(&pos, &limits, Some(&mut on_iteration)); + + println!(); + match result.best_move { + Some(mv) => println!( + "{}", + t!( + "analyze.verdict", + mv = mv.to_string().green().bold(), + score = format_score(result.score).bold(), + depth = result.depth + ) + ), + None => println!("{}", t!("analyze.no_best_move")), + } + println!(); + Ok(()) +} + +/// Per-move annotation produced by the game analyzer. +struct MoveReport { + number: u32, + side: Color, + played: String, + eval_after: i32, + cp_loss: i32, + quality: MoveQuality, + best_alternative: Option, +} + +/// Replays and annotates a list of coordinate moves. +fn analyze_game(ctx: &CliContext, args: &AnalyzeArgs, moves: String) -> CliResult { + let start_fen = args.fen.as_deref().unwrap_or(fen::START_FEN); + let mut game = fen::game_from_fen(start_fen) + .map_err(|e| cli_error(t!("cli.invalid_fen", error = e).to_string()))?; + + let tokens: Vec<&str> = moves.split_whitespace().collect(); + if tokens.is_empty() { + return Err(cli_error(t!("analyze.no_moves").to_string())); + } + + let limits = limits_for(args, DEFAULT_MOVE_ANALYSIS_MS); + let mut engine = SearchEngine::new(ANALYSIS_TT_MB); + let pb = bar( + &ctx.theme, + tokens.len() as u64, + t!("analyze.progress_label").to_string(), + ); + + let mut reports: Vec = Vec::with_capacity(tokens.len()); + for (i, token) in tokens.iter().enumerate() { + let move_json = parse_move_input(token) + .ok_or_else(|| cli_error(t!("analyze.bad_move", mv = *token).to_string()))?; + + let side = game.turn; + let number = game.fullmove_number; + let pos = fen::search_position(&game); + + // Engine's best move and evaluation before the played move. + engine.set_game_history(&fen::history_hashes(&game)); + let best_result = engine.search_limited(&pos, &limits, None); + + // Apply the played move (validates legality). + game.make_move(&move_json).map_err(|e| { + cli_error(t!("analyze.illegal_move", mv = *token, error = e).to_string()) + })?; + + // Evaluation after the played move, from the mover's perspective. + let played_pos = fen::search_position(&game); + engine.set_game_history(&fen::history_hashes(&game)); + let played_result = engine.search_limited(&played_pos, &limits, None); + let eval_after = -played_result.score; + + let cp_loss = (best_result.score - eval_after).max(0); + let best_alternative = best_result.best_move.and_then(|mv| { + let uci = mv.to_string(); + if cp_loss > SHOW_ALTERNATIVE_THRESHOLD && uci != *token { + Some(uci) + } else { + None + } + }); + + reports.push(MoveReport { + number, + side, + played: (*token).to_string(), + eval_after, + cp_loss, + quality: MoveQuality::from_cp_loss(cp_loss), + best_alternative, + }); + pb.inc(1); + + if game.is_over() { + if i + 1 < tokens.len() { + pb.finish_and_clear(); + return Err(cli_error( + t!("analyze.moves_after_game_over", mv = tokens[i + 1]).to_string(), + )); + } + break; + } + } + pb.finish_and_clear(); + + print_game_report(&reports); + Ok(()) +} + +/// Prints the annotated move table plus a quality summary. +fn print_game_report(reports: &[MoveReport]) { + println!(); + println!("{}", t!("analyze.game_header").to_string().yellow().bold()); + println!(); + println!( + " {:>4} {:<6} {:<8} {:>7} {:>6} {:<12} {}", + "#", + t!("analyze.col_side"), + t!("analyze.col_move"), + t!("analyze.col_eval"), + t!("analyze.col_loss"), + t!("analyze.col_quality"), + t!("analyze.col_better") + ); + + for report in reports { + let marker = annotation_marker(report.cp_loss); + let move_label = format!("{}{}", report.played, marker); + let styled_move = match marker { + "??" => move_label.red().bold().to_string(), + "?" => move_label.red().to_string(), + "?!" => move_label.yellow().to_string(), + "!" => move_label.green().to_string(), + _ => move_label, + }; + let alternative = report + .best_alternative + .as_ref() + .map(|alt| t!("analyze.better_was", mv = alt).to_string()) + .unwrap_or_default(); + println!( + " {:>4} {:<6} {:<8} {:>7} {:>6} {:<12} {}", + report.number, + super::play::color_name(report.side), + styled_move, + format_score(super::score::white_pov(report.eval_after, report.side)), + super::score::format_cp_loss(report.cp_loss), + quality_label(report.quality), + alternative.dimmed() + ); + } + + // Summary: count per quality bucket. + println!(); + let count = |q: MoveQuality| reports.iter().filter(|r| r.quality == q).count(); + println!( + "{}", + t!( + "analyze.summary", + total = reports.len(), + best = count(MoveQuality::Best) + count(MoveQuality::Excellent), + inaccuracies = count(MoveQuality::Inaccuracy), + mistakes = count(MoveQuality::Mistake), + blunders = count(MoveQuality::Blunder) + ) + ); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_annotation_marker_thresholds() { + assert_eq!(annotation_marker(0), "!"); + assert_eq!(annotation_marker(10), "!"); + assert_eq!(annotation_marker(11), ""); + assert_eq!(annotation_marker(50), ""); + assert_eq!(annotation_marker(51), "?!"); + assert_eq!(annotation_marker(100), "?!"); + assert_eq!(annotation_marker(101), "?"); + assert_eq!(annotation_marker(300), "?"); + assert_eq!(annotation_marker(301), "??"); + assert_eq!(annotation_marker(10_000), "??"); + } + + #[test] + fn test_limits_depth_only() { + let args = AnalyzeArgs { + fen: None, + moves: None, + depth: Some(12), + movetime: None, + }; + let limits = limits_for(&args, 1000); + assert_eq!(limits.max_depth, 12); + assert_eq!(limits.move_time_ms, None); + } + + #[test] + fn test_limits_default_movetime() { + let args = AnalyzeArgs { + fen: None, + moves: None, + depth: None, + movetime: None, + }; + let limits = limits_for(&args, 1234); + assert_eq!(limits.move_time_ms, Some(1234)); + assert_eq!(limits.max_depth, MAX_DEPTH); + } +} diff --git a/src/cli/bench.rs b/src/cli/bench.rs new file mode 100644 index 0000000..fd82314 --- /dev/null +++ b/src/cli/bench.rs @@ -0,0 +1,152 @@ +//! `checkai bench` — quick engine strength/regression probe. +//! +//! Runs the search over a fixed suite of six positions (opening, +//! middlegame, tactics, endgame) at a fixed depth or time budget and +//! reports nodes, time and NPS per position plus totals. Because the +//! suite never changes, total node counts are directly comparable +//! between engine versions. + +use clap::Args; +use colored::Colorize; + +use super::fen; +use super::progress::bar; +use super::score::{format_score, humanize_count}; +use super::{CliCommand, CliContext, CliResult, cli_error}; +use crate::search::{MAX_DEPTH, SearchEngine, SearchLimits}; + +/// Transposition table size used for every benchmark run (MB). +const BENCH_TT_MB: usize = 64; + +/// The fixed benchmark suite: `(name, FEN)`. +const BENCH_SUITE: [(&str, &str); 6] = [ + ("startpos", fen::START_FEN), + ( + "kiwipete", + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + ), + ("rook-endgame", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"), + ( + "promotion-tactics", + "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", + ), + ( + "sharp-middlegame", + "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", + ), + ( + "quiet-middlegame", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + ), +]; + +/// Arguments for `checkai bench`. +#[derive(Args, Debug)] +#[command(after_help = "\ +Examples:\n\ + checkai bench Run the suite at depth 12\n\ + checkai bench --depth 8 Faster, shallower run\n\ + checkai bench --movetime 1000 1 second per position instead")] +pub struct BenchArgs { + /// Fixed search depth per position. + #[arg(long, default_value_t = 12)] + pub depth: i32, + + /// Time budget per position in milliseconds (replaces --depth). + #[arg(long)] + pub movetime: Option, +} + +impl CliCommand for BenchArgs { + fn run(self, ctx: &CliContext) -> CliResult { + let limits = match self.movetime { + Some(ms) => SearchLimits::move_time(ms), + None => SearchLimits::depth(self.depth.clamp(1, MAX_DEPTH)), + }; + + println!(); + println!("{}", t!("bench.header").to_string().yellow().bold()); + println!(); + println!( + " {:<20} {:>6} {:>8} {:>12} {:>9} {:>10}", + t!("bench.col_position"), + t!("analyze.col_depth"), + t!("analyze.col_eval"), + t!("analyze.col_nodes"), + t!("bench.col_time"), + t!("bench.col_nps") + ); + + let pb = bar( + &ctx.theme, + BENCH_SUITE.len() as u64, + t!("bench.progress_label").to_string(), + ); + + let mut total_nodes: u64 = 0; + let mut total_ms: u64 = 0; + for (name, fen_str) in BENCH_SUITE { + let game = fen::game_from_fen(fen_str) + .map_err(|e| cli_error(t!("cli.invalid_fen", error = e).to_string()))?; + let pos = fen::search_position(&game); + + // Fresh engine per position: identical conditions every run. + let mut engine = SearchEngine::new(BENCH_TT_MB); + let result = engine.search_limited(&pos, &limits, None); + + let nodes = result.stats.nodes; + // `.max(1)` treats a sub-millisecond search as 1 ms, avoiding a + // division-by-zero without a separate branch. + let nps = nodes * 1000 / result.time_ms.max(1); + total_nodes += nodes; + total_ms += result.time_ms; + + pb.suspend(|| { + println!( + " {:<20} {:>6} {:>8} {:>12} {:>8}ms {:>10}", + name.cyan(), + result.depth, + format_score(result.score), + nodes, + result.time_ms, + humanize_count(nps) + ); + }); + pb.inc(1); + } + pb.finish_and_clear(); + + let total_nps = total_nodes * 1000 / total_ms.max(1); + println!(); + println!( + "{}", + t!( + "bench.totals", + nodes = total_nodes, + secs = format!("{:.2}", total_ms as f64 / 1000.0), + nps = humanize_count(total_nps) + ) + .to_string() + .bold() + ); + println!(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bench_suite_fens_are_valid() { + for (name, fen_str) in BENCH_SUITE { + let game = fen::game_from_fen(fen_str); + assert!(game.is_ok(), "bench position '{name}' must parse"); + assert!( + !game.unwrap().legal_moves().is_empty(), + "bench position '{name}' must have legal moves" + ); + } + } +} diff --git a/src/cli/board_renderer.rs b/src/cli/board_renderer.rs new file mode 100644 index 0000000..3bd2039 --- /dev/null +++ b/src/cli/board_renderer.rs @@ -0,0 +1,252 @@ +//! The single board renderer used by every CLI command. +//! +//! Renders a [`Board`] to a `String` with: +//! +//! - Unicode chess glyphs or plain ASCII letters (`--ascii`), +//! - last-move highlighting (from/to squares), +//! - check highlighting (king square), +//! - optional coordinates, +//! - optional flipped view for playing Black (`--flip`). +//! +//! Colors degrade to plain text automatically through the global +//! `colored` override managed by [`super::theme::Theme`]. + +use colored::Colorize; + +use crate::game::Game; +use crate::movegen; +use crate::types::{Board, Color, Piece, PieceKind, Square}; + +/// Squares to visually emphasize when rendering a board. +#[derive(Debug, Clone, Copy, Default)] +pub struct BoardHighlights { + /// From/to squares of the most recent move. + pub last_move: Option<(Square, Square)>, + /// Square of a king currently in check. + pub check: Option, +} + +impl BoardHighlights { + /// Derives highlights from a live game: last move played and the + /// side-to-move's king square when in check. + pub fn for_game(game: &Game) -> Self { + let last_move = game.move_history.last().and_then(|record| { + let from = Square::from_algebraic(&record.move_json.from)?; + let to = Square::from_algebraic(&record.move_json.to)?; + Some((from, to)) + }); + let check = if movegen::is_in_check(&game.board, game.turn) { + game.board.find_king(game.turn) + } else { + None + }; + Self { last_move, check } + } +} + +/// Stateless board renderer with presentation options. +#[derive(Debug, Clone, Copy)] +pub struct BoardRenderer { + /// Use ASCII piece letters instead of Unicode glyphs. + pub ascii: bool, + /// Render from Black's perspective (rank 1 at the top). + pub flipped: bool, + /// Print file/rank coordinates around the board. + pub coords: bool, +} + +impl Default for BoardRenderer { + fn default() -> Self { + Self { + ascii: false, + flipped: false, + coords: true, + } + } +} + +impl BoardRenderer { + /// Creates a renderer with the given glyph mode and orientation. + pub fn new(ascii: bool, flipped: bool) -> Self { + Self { + ascii, + flipped, + coords: true, + } + } + + /// Renders the board to a multi-line string. + pub fn render(&self, board: &Board, highlights: &BoardHighlights) -> String { + let mut out = String::new(); + let separator = " +---+---+---+---+---+---+---+---+\n"; + + out.push('\n'); + out.push_str(separator); + + for display_rank in 0..8u8 { + let rank = if self.flipped { + display_rank + } else { + 7 - display_rank + }; + if self.coords { + out.push_str(&format!("{} ", rank + 1)); + } else { + out.push_str(" "); + } + for display_file in 0..8u8 { + let file = if self.flipped { + 7 - display_file + } else { + display_file + }; + let sq = Square::new(file, rank); + out.push('|'); + out.push_str(&self.render_cell(board, sq, highlights)); + } + out.push_str("|\n"); + out.push_str(separator); + } + + if self.coords { + let files: String = (0..8u8) + .map(|display_file| { + let file = if self.flipped { + 7 - display_file + } else { + display_file + }; + format!(" {} ", (b'a' + file) as char) + }) + .collect(); + out.push_str(&format!(" {files}\n")); + } + out + } + + /// Renders one 3-character cell (` X `), applying highlights. + fn render_cell(&self, board: &Board, sq: Square, highlights: &BoardHighlights) -> String { + let is_dark = (sq.file + sq.rank).is_multiple_of(2); + let glyph = match board.get(sq) { + Some(piece) => { + let symbol = self.piece_symbol(piece); + if piece.color == Color::White { + symbol.white().bold().to_string() + } else { + symbol.blue().bold().to_string() + } + } + None if is_dark && !self.ascii => "·".dimmed().to_string(), + None if is_dark => ".".to_string(), + None => " ".to_string(), + }; + + let cell = format!(" {glyph} "); + if highlights.check == Some(sq) { + cell.on_red().to_string() + } else if highlights + .last_move + .is_some_and(|(from, to)| from == sq || to == sq) + { + cell.on_yellow().black().to_string() + } else { + cell + } + } + + /// Returns the display symbol for a piece in the configured glyph mode. + fn piece_symbol(&self, piece: Piece) -> &'static str { + if self.ascii { + ascii_symbol(piece) + } else { + unicode_symbol(piece) + } + } +} + +/// ASCII (FEN-letter) symbol for a piece. +fn ascii_symbol(piece: Piece) -> &'static str { + match (piece.color, piece.kind) { + (Color::White, PieceKind::King) => "K", + (Color::White, PieceKind::Queen) => "Q", + (Color::White, PieceKind::Rook) => "R", + (Color::White, PieceKind::Bishop) => "B", + (Color::White, PieceKind::Knight) => "N", + (Color::White, PieceKind::Pawn) => "P", + (Color::Black, PieceKind::King) => "k", + (Color::Black, PieceKind::Queen) => "q", + (Color::Black, PieceKind::Rook) => "r", + (Color::Black, PieceKind::Bishop) => "b", + (Color::Black, PieceKind::Knight) => "n", + (Color::Black, PieceKind::Pawn) => "p", + } +} + +/// Unicode chess glyph for a piece. White uses outline glyphs, Black the +/// filled set; sides are additionally distinguished by color. +fn unicode_symbol(piece: Piece) -> &'static str { + match (piece.color, piece.kind) { + (Color::White, PieceKind::King) => "♔", + (Color::White, PieceKind::Queen) => "♕", + (Color::White, PieceKind::Rook) => "♖", + (Color::White, PieceKind::Bishop) => "♗", + (Color::White, PieceKind::Knight) => "♘", + (Color::White, PieceKind::Pawn) => "♙", + (Color::Black, PieceKind::King) => "♚", + (Color::Black, PieceKind::Queen) => "♛", + (Color::Black, PieceKind::Rook) => "♜", + (Color::Black, PieceKind::Bishop) => "♝", + (Color::Black, PieceKind::Knight) => "♞", + (Color::Black, PieceKind::Pawn) => "♟", + } +} + +/// Returns the localized human-readable name of a piece kind. +pub fn piece_name(kind: PieceKind) -> String { + match kind { + PieceKind::King => t!("play.piece_king").to_string(), + PieceKind::Queen => t!("play.piece_queen").to_string(), + PieceKind::Rook => t!("play.piece_rook").to_string(), + PieceKind::Bishop => t!("play.piece_bishop").to_string(), + PieceKind::Knight => t!("play.piece_knight").to_string(), + PieceKind::Pawn => t!("play.piece_pawn").to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain_render(renderer: BoardRenderer) -> String { + colored::control::set_override(false); + renderer.render(&Board::starting_position(), &BoardHighlights::default()) + } + + #[test] + fn test_ascii_render_contains_pieces_and_coords() { + let out = plain_render(BoardRenderer::new(true, false)); + assert!(out.contains("K"), "white king letter expected"); + assert!(out.contains("k"), "black king letter expected"); + assert!(out.contains("a b c"), "file coordinates expected"); + // White perspective: rank 8 (black pieces) is rendered first. + let r8 = out.find("8 ").expect("rank 8 label"); + let r1 = out.find("1 ").expect("rank 1 label"); + assert!(r8 < r1, "rank 8 must precede rank 1 in white view"); + } + + #[test] + fn test_flipped_render_reverses_ranks() { + let out = plain_render(BoardRenderer::new(true, true)); + let r8 = out.find("8 ").expect("rank 8 label"); + let r1 = out.find("1 ").expect("rank 1 label"); + assert!(r1 < r8, "rank 1 must precede rank 8 in flipped view"); + assert!(out.contains("h g f"), "files must be reversed"); + } + + #[test] + fn test_unicode_render_uses_glyphs() { + let out = plain_render(BoardRenderer::new(false, false)); + assert!(out.contains('♔')); + assert!(out.contains('♚')); + } +} diff --git a/src/cli/fen.rs b/src/cli/fen.rs new file mode 100644 index 0000000..31d29eb --- /dev/null +++ b/src/cli/fen.rs @@ -0,0 +1,223 @@ +//! FEN import/export helpers for CLI commands. +//! +//! This module provides a strict 4–6 field FEN parser (it rejects unknown +//! castling characters and rank overflow with explicit errors, which is +//! desirable for interactive CLI input), the reverse direction (full +//! 6-field FEN output), the canonical `Game` → `SearchPosition` conversion +//! used by every engine-facing command, and the position-history hashes +//! that feed the engine's repetition detection. + +use crate::game::Game; +use crate::search::SearchPosition; +use crate::types::{Board, CastlingRights, Color, Piece, SideCastlingRights, Square}; +use crate::zobrist; + +/// FEN of the standard starting position. +pub const START_FEN: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + +/// Parses a 4–6 field FEN string into a fresh [`Game`]. +/// +/// The halfmove clock and fullmove number are optional (default `0` / `1`). +/// Position history starts at the imported position, so repetition +/// counting only sees moves played after the import. +pub fn game_from_fen(fen: &str) -> Result { + let parts: Vec<&str> = fen.split_whitespace().collect(); + if parts.len() < 4 { + return Err("FEN must have at least 4 fields".to_string()); + } + + // Piece placement. + let mut board = Board::default(); + let rows: Vec<&str> = parts[0].split('/').collect(); + if rows.len() != 8 { + return Err("FEN piece placement must have exactly 8 ranks".to_string()); + } + for (row_idx, row) in rows.iter().enumerate() { + let rank = 7 - row_idx as u8; + let mut file: u8 = 0; + for ch in row.chars() { + if let Some(skip) = ch.to_digit(10) { + if skip == 0 || skip > 8 { + return Err(format!( + "Invalid empty-square count '{ch}' in FEN — must be 1–8" + )); + } + file += skip as u8; + } else { + if file >= 8 { + return Err(format!("Too many pieces on rank {}", rank + 1)); + } + let piece = + Piece::from_fen_char(ch).ok_or_else(|| format!("Invalid piece '{ch}'"))?; + board.set(Square::new(file, rank), Some(piece)); + file += 1; + } + } + if file != 8 { + return Err(format!("Rank {} has {} files, expected 8", rank + 1, file)); + } + } + + // Side to move. + let turn = match parts[1] { + "w" => Color::White, + "b" => Color::Black, + other => return Err(format!("Invalid turn field: '{other}'")), + }; + + // Castling rights. + let mut castling = CastlingRights { + white: SideCastlingRights { + kingside: false, + queenside: false, + }, + black: SideCastlingRights { + kingside: false, + queenside: false, + }, + }; + if parts[2] != "-" { + for ch in parts[2].chars() { + match ch { + 'K' => castling.white.kingside = true, + 'Q' => castling.white.queenside = true, + 'k' => castling.black.kingside = true, + 'q' => castling.black.queenside = true, + other => return Err(format!("Invalid castling character: '{other}'")), + } + } + } + + // En passant target square. + let en_passant = if parts[3] == "-" { + None + } else { + Some( + Square::from_algebraic(parts[3]) + .ok_or_else(|| format!("Invalid en passant square: '{}'", parts[3]))?, + ) + }; + + // Optional clocks. + let halfmove_clock = match parts.get(4) { + Some(v) => v + .parse::() + .map_err(|_| format!("Invalid halfmove clock: '{v}'"))?, + None => 0, + }; + let fullmove_number = match parts.get(5) { + Some(v) => v + .parse::() + .map_err(|_| format!("Invalid fullmove number: '{v}'"))?, + None => 1, + }; + + // Sanity check: both kings must be present for a playable game. + if board.find_king(Color::White).is_none() || board.find_king(Color::Black).is_none() { + return Err("Both kings must be present".to_string()); + } + + let initial_fen_str = board.to_position_fen(turn, &castling, en_passant); + + Ok(Game { + id: uuid::Uuid::new_v4(), + board, + turn, + castling, + en_passant, + halfmove_clock, + fullmove_number, + position_history: vec![initial_fen_str], + move_history: Vec::new(), + result: None, + end_reason: None, + draw_offered_by: None, + start_timestamp: crate::storage::unix_timestamp(), + end_timestamp: 0, + }) +} + +/// Builds the full 6-field FEN string for a live game. +pub fn game_to_fen(game: &Game) -> String { + format!( + "{} {} {}", + game.board + .to_position_fen(game.turn, &game.castling, game.en_passant), + game.halfmove_clock, + game.fullmove_number + ) +} + +/// Converts a [`Game`] into the engine's [`SearchPosition`] snapshot +/// (the canonical recipe used by the analysis subsystem). +pub fn search_position(game: &Game) -> SearchPosition { + SearchPosition::new( + game.board.clone(), + game.turn, + game.castling, + game.en_passant, + game.halfmove_clock, + ) +} + +/// Zobrist hashes of every position that preceded the current one, in game +/// order — the input to [`crate::search::SearchEngine::set_game_history`]. +/// +/// `Game` stores its history as position-only FEN strings; we re-hash each so +/// the engine can score a search line that returns to an earlier game position +/// as a draw by repetition. The current position is excluded (the search root +/// is tracked separately, inside the tree). +pub fn history_hashes(game: &Game) -> Vec { + let history = &game.position_history; + let preceding = history.len().saturating_sub(1); + history[..preceding] + .iter() + .filter_map(|fen| match game_from_fen(fen) { + Ok(g) => Some(g), + Err(e) => { + eprintln!("warning: skipping invalid history FEN ({e}): {fen}"); + None + } + }) + .map(|g| zobrist::hash_position(&g.board, g.turn, &g.castling, g.en_passant)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_startpos_round_trip() { + let game = game_from_fen(START_FEN).expect("startpos must parse"); + assert_eq!(game_to_fen(&game), START_FEN); + assert_eq!(game.legal_moves().len(), 20); + } + + #[test] + fn test_kiwipete_parses() { + let fen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; + let game = game_from_fen(fen).expect("kiwipete must parse"); + assert_eq!(game.legal_moves().len(), 48); + assert_eq!(game_to_fen(&game), fen); + } + + #[test] + fn test_invalid_fens_rejected() { + assert!(game_from_fen("").is_err()); + assert!(game_from_fen("8/8/8/8 w - -").is_err()); // 4 ranks only + assert!(game_from_fen("9/8/8/8/8/8/8/8 w - -").is_err()); // bad digit + assert!(game_from_fen("8/8/8/8/8/8/8/8 w - -").is_err()); // no kings + let no_black_king = "4K3/8/8/8/8/8/8/8 w - - 0 1"; + assert!(game_from_fen(no_black_king).is_err()); + } + + #[test] + fn test_search_position_matches_game() { + let game = game_from_fen(START_FEN).unwrap(); + let pos = search_position(&game); + assert_eq!(pos.turn, game.turn); + assert_eq!(pos.halfmove_clock, game.halfmove_clock); + assert_eq!(pos.legal_moves().len(), 20); + } +} diff --git a/src/cli/level.rs b/src/cli/level.rs new file mode 100644 index 0000000..58a6cb6 --- /dev/null +++ b/src/cli/level.rs @@ -0,0 +1,122 @@ +//! The difficulty ladder: maps a 1–10 level to engine search limits. +//! +//! | Level | Max depth | Move time | TT size | +//! |-------|-----------|-----------|---------| +//! | 1 | 1 | 60 ms | 4 MB | +//! | 2 | 2 | 120 ms | 8 MB | +//! | 3 | 4 | 250 ms | 16 MB | +//! | 4 | 6 | 500 ms | 32 MB | +//! | 5 | 10 | 1000 ms | 64 MB | +//! | 6 | 14 | 2000 ms | 64 MB | +//! | 7 | MAX | 3000 ms | 128 MB | +//! | 8 | MAX | 5000 ms | 128 MB | +//! | 9 | MAX | 7500 ms | 256 MB | +//! | 10 | MAX | 10000 ms | 256 MB | +//! +//! Low levels are weakened twice over: a hard depth cap *and* a tiny +//! time budget (whichever hits first), plus a small transposition table. +//! High levels run at full depth and are paced purely by move time. + +use crate::search::{MAX_DEPTH, SearchLimits}; + +/// Lowest selectable difficulty level. +pub const MIN_LEVEL: u8 = 1; +/// Highest selectable difficulty level. +pub const MAX_LEVEL: u8 = 10; +/// Default difficulty level. +pub const DEFAULT_LEVEL: u8 = 5; + +/// Ladder rows: `(max_depth, move_time_ms, tt_size_mb)` for levels 1–10. +const LADDER: [(i32, u64, usize); 10] = [ + (1, 60, 4), + (2, 120, 8), + (4, 250, 16), + (6, 500, 32), + (10, 1000, 64), + (14, 2000, 64), + (MAX_DEPTH, 3000, 128), + (MAX_DEPTH, 5000, 128), + (MAX_DEPTH, 7500, 256), + (MAX_DEPTH, 10000, 256), +]; + +/// Engine configuration derived from a difficulty level. +#[derive(Debug, Clone)] +pub struct LevelSettings { + /// The (clamped) level this configuration was derived from. + pub level: u8, + /// Search limits to pass to `SearchEngine::search_limited`. + pub limits: SearchLimits, + /// Transposition table size in MB for the engine instance. + pub tt_size_mb: usize, +} + +impl LevelSettings { + /// Builds the settings for a level (clamped to `1..=10`). + /// + /// `movetime_override` / `depth_override` replace the ladder values + /// when the user passes explicit `--movetime` / `--depth` flags. + pub fn for_level( + level: u8, + movetime_override: Option, + depth_override: Option, + ) -> Self { + let level = level.clamp(MIN_LEVEL, MAX_LEVEL); + let (depth, movetime, tt) = LADDER[(level - 1) as usize]; + let limits = SearchLimits { + max_depth: depth_override.unwrap_or(depth).clamp(1, MAX_DEPTH), + move_time_ms: Some(movetime_override.unwrap_or(movetime)), + max_nodes: None, + }; + Self { + level, + limits, + tt_size_mb: tt, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_level_1_is_shallow_and_fast() { + let s = LevelSettings::for_level(1, None, None); + assert_eq!(s.limits.max_depth, 1); + assert_eq!(s.limits.move_time_ms, Some(60)); + assert_eq!(s.tt_size_mb, 4); + } + + #[test] + fn test_level_10_is_full_depth_long_time() { + let s = LevelSettings::for_level(10, None, None); + assert_eq!(s.limits.max_depth, MAX_DEPTH); + assert_eq!(s.limits.move_time_ms, Some(10_000)); + assert_eq!(s.tt_size_mb, 256); + } + + #[test] + fn test_levels_are_monotonic_in_time() { + let mut prev = 0u64; + for level in MIN_LEVEL..=MAX_LEVEL { + let s = LevelSettings::for_level(level, None, None); + let mt = s.limits.move_time_ms.unwrap(); + assert!(mt > prev, "level {level} must be slower than {}", level - 1); + prev = mt; + } + } + + #[test] + fn test_out_of_range_levels_clamp() { + assert_eq!(LevelSettings::for_level(0, None, None).level, MIN_LEVEL); + assert_eq!(LevelSettings::for_level(99, None, None).level, MAX_LEVEL); + } + + #[test] + fn test_overrides_replace_ladder_values() { + let s = LevelSettings::for_level(5, Some(42), Some(7)); + assert_eq!(s.limits.move_time_ms, Some(42)); + assert_eq!(s.limits.max_depth, 7); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..3a60e1f --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,70 @@ +//! Command-line interface for CheckAI. +//! +//! This module hosts every interactive CLI command (one submodule per +//! command) plus the shared infrastructure they build on: +//! +//! - [`theme`] — color/TTY detection (`--no-color`, `NO_COLOR`) and styling. +//! - [`panel`] — data-driven box-drawing helpers (banners, result panels). +//! - [`board_renderer`] — the single board renderer used everywhere +//! (Unicode/ASCII, highlights, flipped view, coordinates). +//! - [`score`] — score formatting (`+1.23`, `#3`) and the eval bar. +//! - [`level`] — the difficulty ladder mapping levels to search limits. +//! - [`progress`] — TTY-gated spinners and progress bars (indicatif). +//! - [`fen`] — FEN import/export for [`crate::game::Game`]. +//! +//! Commands implement the [`CliCommand`] trait and are dispatched from +//! `main.rs`, which stays a thin parser + locale setup layer. + +pub mod analyze; +pub mod bench; +pub mod board_renderer; +pub mod fen; +pub mod level; +pub mod panel; +pub mod perft; +pub mod play; +pub mod progress; +pub mod score; +pub mod theme; +pub mod uci; +pub mod watch; +pub mod welcome; + +use theme::Theme; + +/// Result type shared by all CLI commands. +/// +/// Uses standard error boxing — commands bubble up any error and +/// `main.rs` converts it into a non-zero exit code. +pub type CliResult = Result<(), Box>; + +/// Shared context passed to every CLI command. +/// +/// Carries the detected [`Theme`] (colors / interactivity) so commands +/// never have to probe the terminal themselves. +pub struct CliContext { + /// Detected terminal theme (colors enabled, TTY or not). + pub theme: Theme, +} + +impl CliContext { + /// Creates a context from CLI flags, applying global color overrides. + pub fn new(no_color: bool) -> Self { + Self { + theme: Theme::detect(no_color), + } + } +} + +/// Command pattern trait implemented by each CLI subcommand's argument +/// struct. `main.rs` parses clap arguments, builds a [`CliContext`] and +/// dispatches via this trait. +pub trait CliCommand { + /// Executes the command, consuming its parsed arguments. + fn run(self, ctx: &CliContext) -> CliResult; +} + +/// Convenience constructor for a string-based CLI error. +pub fn cli_error(message: impl Into) -> Box { + Box::::from(message.into()) +} diff --git a/src/cli/panel.rs b/src/cli/panel.rs new file mode 100644 index 0000000..a7a08bd --- /dev/null +++ b/src/cli/panel.rs @@ -0,0 +1,204 @@ +//! Data-driven box-drawing helpers shared by the welcome screen, +//! in-game help tables, and end-of-game result panels. +//! +//! All functions return plain `String`s (color applied by the caller or +//! via `colored`, which respects the global theme override), so output +//! stays clean when piped to a file or another process. + +use colored::Colorize; +use std::time::Duration; + +use crate::game::Game; +use crate::types::{Color, GameResult}; + +/// A single row of a two-column command table (`name [alias] description`). +pub struct TableRow { + /// Command / item name (left column). + pub name: String, + /// Optional short alias shown dimmed next to the name. + pub alias: Option, + /// Description (right column). + pub desc: String, +} + +impl TableRow { + /// Creates a table row from name, optional alias and description. + pub fn new(name: impl Into, alias: Option<&str>, desc: impl Into) -> Self { + Self { + name: name.into(), + alias: alias.map(str::to_string), + desc: desc.into(), + } + } +} + +/// Renders a two-column table with aligned columns. +/// +/// The left column (name + alias) is padded to the widest entry so the +/// descriptions line up; colors degrade to plain text automatically. +pub fn render_table(rows: &[TableRow], indent: usize) -> String { + use super::theme::display_width; + let left_width = rows + .iter() + .map(|r| display_width(&r.name) + r.alias.as_ref().map_or(0, |a| display_width(a) + 3)) + .max() + .unwrap_or(0); + + let pad = " ".repeat(indent); + let mut out = String::new(); + for row in rows { + let raw_left = match &row.alias { + Some(alias) => format!("{} [{}]", row.name, alias), + None => row.name.clone(), + }; + let fill = " ".repeat(left_width.saturating_sub(display_width(&raw_left))); + let styled_left = match &row.alias { + Some(alias) => format!( + "{} {}", + row.name.green().bold(), + format!("[{}]", alias).dimmed() + ), + None => row.name.green().bold().to_string(), + }; + out.push_str(&format!("{pad}{styled_left}{fill} {}\n", row.desc)); + } + out +} + +/// Renders a box-drawn panel with a centered title and content lines. +/// +/// `width` is the inner width in characters; lines longer than the inner +/// width are truncated. Returns one string per output row. +pub fn boxed_panel(title: &str, lines: &[String], width: usize) -> Vec { + let horizontal = "═".repeat(width); + let mut out = Vec::with_capacity(lines.len() + 4); + out.push(format!("╔{horizontal}╗")); + out.push(format!("║{}║", center(title, width))); + out.push(format!("╟{}╢", "─".repeat(width))); + for line in lines { + out.push(format!("║{}║", pad_line(line, width))); + } + out.push(format!("╚{horizontal}╝")); + out +} + +/// Centers `text` within `width` display columns (CJK-aware). +fn center(text: &str, width: usize) -> String { + let len = super::theme::display_width(text); + if len >= width { + return super::theme::truncate_chars(text, width); + } + let left = (width - len) / 2; + let right = width - len - left; + format!("{}{}{}", " ".repeat(left), text, " ".repeat(right)) +} + +/// Left-aligns `text` within `width` display columns with a 1-char margin. +fn pad_line(text: &str, width: usize) -> String { + let body = super::theme::truncate_chars(text, width.saturating_sub(2)); + let len = super::theme::display_width(&body); + format!(" {}{}", body, " ".repeat(width.saturating_sub(len + 1))) +} + +/// Formats a [`Duration`] as `MM:SS` (or `H:MM:SS` past one hour). +pub fn format_duration(d: Duration) -> String { + let total = d.as_secs(); + let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m:02}:{s:02}") + } +} + +/// Renders the stylish end-of-game result panel: winner, reason, +/// move count and wall-clock duration. +pub fn result_panel(game: &Game, duration: Duration) -> String { + let winner = match game.result { + Some(GameResult::WhiteWins) => t!("play.winner_white").to_string(), + Some(GameResult::BlackWins) => t!("play.winner_black").to_string(), + Some(GameResult::Draw) => t!("play.winner_draw").to_string(), + None => t!("play.game_unfinished").to_string(), + }; + let reason = game + .end_reason + .as_ref() + .map(|r| r.to_string()) + .unwrap_or_default(); + let full_moves = game + .move_history + .iter() + .filter(|r| r.side == Color::White) + .count() + .max(game.fullmove_number.saturating_sub(1) as usize); + + let lines = vec![ + format!("{} {}", t!("play.result_winner_label"), winner), + format!("{} {}", t!("play.result_reason_label"), reason), + format!("{} {}", t!("play.result_moves_label"), full_moves), + format!( + "{} {}", + t!("play.result_duration_label"), + format_duration(duration) + ), + ]; + + let width = lines + .iter() + .map(|l| super::theme::display_width(l) + 2) + .chain(std::iter::once( + super::theme::display_width(&t!("play.result_title")) + 4, + )) + .max() + .unwrap_or(40) + .max(36); + + boxed_panel(&t!("play.result_title"), &lines, width) + .into_iter() + .map(|l| l.yellow().bold().to_string()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(Duration::from_secs(75)), "01:15"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(Duration::from_secs(3725)), "1:02:05"); + } + + #[test] + fn test_boxed_panel_dimensions() { + let lines = vec!["hello".to_string()]; + let panel = boxed_panel("T", &lines, 20); + assert_eq!(panel.len(), 5); + for row in &panel { + assert_eq!(row.chars().count(), 22, "row width must be uniform"); + } + } + + #[test] + fn test_render_table_aligns_columns() { + colored::control::set_override(false); + let rows = vec![ + TableRow::new("a", Some("x"), "first"), + TableRow::new("longer", None, "second"), + ]; + let out = render_table(&rows, 2); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("a [x]")); + assert!(lines[1].contains("longer")); + // Both descriptions must start at the same column. + let col0 = lines[0].find("first").unwrap(); + let col1 = lines[1].find("second").unwrap(); + assert_eq!(col0, col1); + } +} diff --git a/src/cli/perft.rs b/src/cli/perft.rs new file mode 100644 index 0000000..1bce6b1 --- /dev/null +++ b/src/cli/perft.rs @@ -0,0 +1,222 @@ +//! `checkai perft` — move-generation verification. +//! +//! Counts leaf nodes of the legal-move tree to the requested depth. +//! For the standard starting position the well-known reference totals +//! (20, 400, 8 902, …) are printed next to the computed values with +//! OK/FAIL markers, making this a one-command movegen sanity check. +//! `--divide` prints per-root-move subtotals, the standard tool for +//! pinpointing movegen bugs. + +use std::time::Instant; + +use clap::Args; +use colored::Colorize; + +use super::fen; +use super::score::humanize_count; +use super::{CliCommand, CliContext, CliResult, cli_error}; +use crate::search::SearchPosition; +use crate::types::ChessMove; + +/// Known perft totals for the standard starting position, depths 1–6. +pub const STARTPOS_REFERENCE: [u64; 6] = [20, 400, 8_902, 197_281, 4_865_609, 119_060_324]; + +/// Arguments for `checkai perft`. +#[derive(Args, Debug)] +#[command(after_help = "\ +Examples:\n\ + checkai perft Verify startpos depths 1-5 vs references\n\ + checkai perft 6 Up to depth 6 (slow but exact)\n\ + checkai perft 4 --divide Per-root-move counts at depth 4\n\ + checkai perft 5 --fen \"\" Count nodes for a custom position")] +pub struct PerftArgs { + /// Search depth in plies (1-7). + #[arg(default_value_t = 5, value_parser = clap::value_parser!(u32).range(1..=7))] + pub depth: u32, + + /// Position to count from (default: standard starting position). + #[arg(long)] + pub fen: Option, + + /// Print per-root-move counts at the target depth. + #[arg(long)] + pub divide: bool, +} + +impl CliCommand for PerftArgs { + fn run(self, _ctx: &CliContext) -> CliResult { + let is_startpos = self.fen.is_none(); + let fen_str = self.fen.as_deref().unwrap_or(fen::START_FEN); + let game = fen::game_from_fen(fen_str) + .map_err(|e| cli_error(t!("cli.invalid_fen", error = e).to_string()))?; + let pos = fen::search_position(&game); + + println!(); + println!("{}", t!("perft.header").to_string().yellow().bold()); + println!(" {}", fen::game_to_fen(&game).dimmed()); + println!(); + + if self.divide { + run_divide(&pos, self.depth); + return Ok(()); + } + + println!( + " {:>5} {:>14} {:>14} {:>9} {:>10} {}", + t!("analyze.col_depth"), + t!("perft.col_nodes"), + t!("perft.col_reference"), + t!("bench.col_time"), + t!("bench.col_nps"), + t!("perft.col_status") + ); + + let mut all_ok = true; + for depth in 1..=self.depth { + let start = Instant::now(); + let nodes = perft(&pos, depth); + let elapsed = start.elapsed(); + let ms = elapsed.as_millis() as u64; + // `.max(1)` treats a sub-millisecond run as 1 ms (no div-by-zero). + let nps = nodes * 1000 / ms.max(1); + + let reference = if is_startpos { + STARTPOS_REFERENCE.get((depth - 1) as usize).copied() + } else { + None + }; + let (ref_str, status) = match reference { + Some(expected) if expected == nodes => ( + expected.to_string(), + t!("perft.ok").to_string().green().bold(), + ), + Some(expected) => { + all_ok = false; + ( + expected.to_string(), + t!("perft.fail").to_string().red().bold(), + ) + } + None => (String::from("—"), "".normal()), + }; + println!( + " {:>5} {:>14} {:>14} {:>8}ms {:>10} {}", + depth, + nodes, + ref_str, + ms, + humanize_count(nps), + status + ); + } + + println!(); + if is_startpos { + if all_ok { + println!("{}", t!("perft.all_ok").to_string().green().bold()); + } else { + println!("{}", t!("perft.mismatch").to_string().red().bold()); + } + println!(); + } + Ok(()) + } +} + +/// Counts leaf nodes of the legal-move tree at the given depth. +pub fn perft(pos: &SearchPosition, depth: u32) -> u64 { + if depth == 0 { + return 1; + } + let moves = pos.legal_moves(); + if depth == 1 { + return moves.len() as u64; + } + let mut total = 0u64; + for mv in moves { + total += perft(&pos.make_move(&mv), depth - 1); + } + total +} + +/// Computes per-root-move subtotals at the given depth. +pub fn perft_divide(pos: &SearchPosition, depth: u32) -> Vec<(ChessMove, u64)> { + pos.legal_moves() + .into_iter() + .map(|mv| { + let count = if depth <= 1 { + 1 + } else { + perft(&pos.make_move(&mv), depth - 1) + }; + (mv, count) + }) + .collect() +} + +/// Prints the divide table plus the grand total. +fn run_divide(pos: &SearchPosition, depth: u32) { + let start = Instant::now(); + let entries = perft_divide(pos, depth); + let total: u64 = entries.iter().map(|(_, n)| n).sum(); + let ms = start.elapsed().as_millis() as u64; + + for (mv, count) in &entries { + println!(" {:<8} {:>14}", mv.to_string().green(), count); + } + println!(); + println!( + "{}", + t!( + "perft.divide_total", + total = total, + moves = entries.len(), + ms = ms + ) + .to_string() + .bold() + ); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn startpos() -> SearchPosition { + let game = fen::game_from_fen(fen::START_FEN).unwrap(); + fen::search_position(&game) + } + + #[test] + fn test_perft_startpos_matches_references() { + let pos = startpos(); + // Depths 1-3 only — fast enough for the default test run. + for depth in 1..=3u32 { + assert_eq!( + perft(&pos, depth), + STARTPOS_REFERENCE[(depth - 1) as usize], + "perft({depth}) mismatch" + ); + } + } + + #[test] + fn test_perft_divide_sums_to_perft() { + let pos = startpos(); + let entries = perft_divide(&pos, 3); + assert_eq!(entries.len(), 20); + let total: u64 = entries.iter().map(|(_, n)| n).sum(); + assert_eq!(total, perft(&pos, 3)); + } + + #[test] + fn test_perft_kiwipete_depth_2() { + let game = fen::game_from_fen( + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + ) + .unwrap(); + let pos = fen::search_position(&game); + assert_eq!(perft(&pos, 2), 2_039); + } +} diff --git a/src/cli/play.rs b/src/cli/play.rs new file mode 100644 index 0000000..89207cb --- /dev/null +++ b/src/cli/play.rs @@ -0,0 +1,628 @@ +//! `checkai play` — play chess in the terminal, against the built-in +//! engine (default) or a second human. +//! +//! The engine opponent is configured through the difficulty ladder in +//! [`super::level`] (see its table for the exact depth/time/TT mapping) +//! and can be fine-tuned with `--movetime` / `--depth` overrides. +//! While the engine thinks, a live spinner shows depth, score, node +//! counts and the principal variation; finished moves are announced and +//! rendered with from/to highlighting plus a one-line evaluation bar. + +use std::time::Instant; + +use clap::{Args, ValueEnum}; +use colored::Colorize; + +use super::board_renderer::{BoardHighlights, BoardRenderer, piece_name}; +use super::fen; +use super::level::{DEFAULT_LEVEL, LevelSettings, MAX_LEVEL, MIN_LEVEL}; +use super::panel::{TableRow, render_table, result_panel}; +use super::progress::{iteration_message, spinner}; +use super::score::{eval_bar_line, format_score, white_pov}; +use super::{CliCommand, CliContext, CliResult, cli_error}; +use crate::game::Game; +use crate::search::{IterationInfo, SearchEngine, SearchResult}; +use crate::terminal::{GameCommand, read_input_line}; +use crate::types::{ActionJson, ChessMove, Color}; + +/// Who the human plays against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Opponent { + /// Play against the built-in engine. + Engine, + /// Two humans sharing the terminal. + Human, +} + +/// Which side the human takes against the engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SideChoice { + /// Play the white pieces (engine answers as Black). + White, + /// Play the black pieces (engine opens as White). + Black, + /// Flip a coin. + Random, +} + +/// Arguments for `checkai play`. +#[derive(Args, Debug)] +#[command(after_help = "\ +Examples:\n\ + checkai play Play White vs the engine (level 5)\n\ + checkai play --level 9 A much stronger opponent\n\ + checkai play --color black Play Black; the engine opens\n\ + checkai play --color random --flip Random side, flipped board\n\ + checkai play --movetime 500 Cap engine thinking at 500 ms\n\ + checkai play --fen \"\" Start from a custom position\n\ + checkai play --vs human --ascii Two players, ASCII board\n\ +\n\ +In-game commands: e2e4, moves, board, history, fen, json, hint, undo,\n\ +resign, draw, help, quit (single-letter aliases shown via `help`).")] +pub struct PlayArgs { + /// Opponent type: the built-in engine or a second human. + #[arg(long, value_enum, default_value_t = Opponent::Engine)] + pub vs: Opponent, + + /// Your color against the engine: white, black, or random. + #[arg(long, value_enum, default_value_t = SideChoice::White)] + pub color: SideChoice, + + /// Engine difficulty level (1 = beginner … 10 = strongest). + #[arg(long, default_value_t = DEFAULT_LEVEL, + value_parser = clap::value_parser!(u8).range(MIN_LEVEL as i64..=MAX_LEVEL as i64))] + pub level: u8, + + /// Override the engine's thinking time per move, in milliseconds. + #[arg(long)] + pub movetime: Option, + + /// Override the engine's maximum search depth. + #[arg(long)] + pub depth: Option, + + /// Start from a custom position (4–6 field FEN string). + #[arg(long)] + pub fen: Option, + + /// Use ASCII piece letters instead of Unicode glyphs. + #[arg(long)] + pub ascii: bool, + + /// Render the board from Black's perspective. + #[arg(long)] + pub flip: bool, +} + +impl CliCommand for PlayArgs { + fn run(self, ctx: &CliContext) -> CliResult { + let initial_fen = self + .fen + .clone() + .unwrap_or_else(|| fen::START_FEN.to_string()); + let game = fen::game_from_fen(&initial_fen) + .map_err(|e| cli_error(t!("cli.invalid_fen", error = e).to_string()))?; + + let human_color = match self.vs { + Opponent::Human => None, + Opponent::Engine => Some(resolve_side(self.color)), + }; + let settings = LevelSettings::for_level(self.level, self.movetime, self.depth); + let flipped = self.flip || human_color == Some(Color::Black); + + let mut session = PlaySession { + ctx, + game, + initial_fen, + engine: SearchEngine::new(settings.tt_size_mb), + engine_color: human_color.map(|c| c.opponent()), + settings, + renderer: BoardRenderer::new(self.ascii, flipped), + started: Instant::now(), + }; + session.run() + } +} + +/// Resolves the `--color` choice (coin flip for `random`). +fn resolve_side(choice: SideChoice) -> Color { + match choice { + SideChoice::White => Color::White, + SideChoice::Black => Color::Black, + SideChoice::Random => { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + if nanos.is_multiple_of(2) { + Color::White + } else { + Color::Black + } + } + } +} + +/// Counts how often the current position occurred (threefold detection). +pub fn repetition_count(game: &Game) -> usize { + match game.position_history.last() { + Some(last) => game.position_history.iter().filter(|p| *p == last).count(), + None => 0, + } +} + +/// Claims a threefold/fifty-move draw when eligible. +/// Returns `Ok(true)` if the game ended through the claim, `Ok(false)` if no +/// draw was available, or `Err` if the claim action failed unexpectedly. +pub fn claim_available_draw(game: &mut Game) -> Result { + let reason = if repetition_count(game) >= 3 { + Some("threefold_repetition") + } else if game.halfmove_clock >= 100 { + Some("fifty_move_rule") + } else { + None + }; + if let Some(reason) = reason { + let action = ActionJson { + action: "claim_draw".to_string(), + reason: Some(reason.to_string()), + }; + game.process_action(&action).map_err(|e| e.to_string())?; + } + Ok(game.is_over()) +} + +/// Returns the localized display name of a color. +pub fn color_name(color: Color) -> String { + match color { + Color::White => t!("types.white").to_string(), + Color::Black => t!("types.black").to_string(), + } +} + +/// State of one interactive play session. +struct PlaySession<'a> { + ctx: &'a CliContext, + game: Game, + initial_fen: String, + engine: SearchEngine, + /// `Some(color)` when the engine plays that side; `None` for + /// human-vs-human (the engine is then only used for hints). + engine_color: Option, + settings: LevelSettings, + renderer: BoardRenderer, + started: Instant, +} + +impl PlaySession<'_> { + /// Runs the interactive REPL until the game ends or the user quits. + fn run(&mut self) -> CliResult { + self.print_intro(); + + if self.game.legal_moves().is_empty() && !self.game.is_over() { + println!("{}", t!("play.position_terminal").to_string().red()); + self.render_board(); + return Ok(()); + } + + self.render_board(); + self.print_status(); + + loop { + if self.game.is_over() { + self.print_result(); + break; + } + + if self.engine_color == Some(self.game.turn) { + self.engine_turn(); + continue; + } + + let prompt = format!("{} > ", side_label(self.game.turn)); + let Some(input) = read_input_line(&prompt) else { + println!("{}", t!("terminal.goodbye")); + break; + }; + if input.is_empty() { + continue; + } + + match GameCommand::parse(&input) { + Some(GameCommand::Quit) => { + println!("{}", t!("terminal.goodbye")); + break; + } + Some(cmd) => self.execute(cmd), + None => println!( + "{}", + t!( + "terminal.unknown_cmd_hint", + cmd = &input, + help = "help".green() + ) + ), + } + } + Ok(()) + } + + /// Executes a parsed in-game command (everything except `quit`). + fn execute(&mut self, cmd: GameCommand) { + match cmd { + GameCommand::Move(mj) => match self.game.make_move(&mj) { + Ok(()) => { + self.render_board(); + self.print_status(); + } + Err(e) => println!( + "{}: {}", + t!("terminal.illegal_move").to_string().red().bold(), + e + ), + }, + GameCommand::Moves => self.print_moves(), + GameCommand::Board => { + self.render_board(); + self.print_status(); + } + GameCommand::History => self.print_history(), + GameCommand::Fen => { + println!(" {}", fen::game_to_fen(&self.game).green()); + println!(); + } + GameCommand::Json => { + let state = self.game.to_game_state_json(); + match serde_json::to_string_pretty(&state) { + Ok(json) => println!("{json}"), + Err(e) => println!( + "{}: {}", + t!("terminal.error_label").to_string().red().bold(), + e + ), + } + println!(); + } + GameCommand::Hint => self.hint(), + GameCommand::Undo => self.undo(), + GameCommand::Resign => { + let action = ActionJson { + action: "resign".to_string(), + reason: None, + }; + match self.game.process_action(&action) { + Ok(()) => self.print_result(), + Err(e) => println!( + "{}: {}", + t!("terminal.error_label").to_string().red().bold(), + e + ), + } + } + GameCommand::Draw => self.claim_draw(), + GameCommand::Help => println!("{}", help_table()), + GameCommand::Quit => unreachable!("quit is handled by the REPL loop"), + } + } + + /// Prints the session intro: mode, level, side and help hint. + fn print_intro(&self) { + println!(); + match self.engine_color { + Some(engine_color) => println!( + "{}", + t!( + "play.intro_engine", + level = self.settings.level, + color = color_name(engine_color.opponent()).bold() + ) + ), + None => println!("{}", t!("play.intro_human")), + } + println!("{}", t!("play.intro_help_hint", help = "help".green())); + } + + /// Lets the engine search and play its move, with a live spinner. + fn engine_turn(&mut self) { + let side = self.game.turn; + let pos = fen::search_position(&self.game); + self.engine + .set_game_history(&fen::history_hashes(&self.game)); + let label = t!("play.engine_thinking", level = self.settings.level).to_string(); + let pb = spinner(&self.ctx.theme, label.clone()); + + self.engine.reset_abort(); + let mut on_iteration = |info: &IterationInfo| { + pb.set_message(iteration_message(&label, info)); + }; + let result = + self.engine + .search_limited(&pos, &self.settings.limits, Some(&mut on_iteration)); + pb.finish_and_clear(); + + let Some(best) = result.best_move else { + // No move found despite legal moves existing — concede. + let action = ActionJson { + action: "resign".to_string(), + reason: None, + }; + let _ = self.game.process_action(&action); + println!("{}", t!("play.engine_resigns").to_string().yellow().bold()); + return; + }; + + self.announce_engine_move(&best, &result); + if let Err(e) = self.game.make_move(&best.to_json()) { + println!( + "{}: {}", + t!("terminal.error_label").to_string().red().bold(), + e + ); + return; + } + + self.render_board(); + println!(" {}", eval_bar_line(white_pov(result.score, side))); + println!(); + self.print_status(); + } + + /// Announces the engine's chosen move with search statistics. + fn announce_engine_move(&self, mv: &ChessMove, result: &SearchResult) { + let piece = self + .game + .board + .get(mv.from) + .map(|p| piece_name(p.kind)) + .unwrap_or_default(); + println!( + "{}", + t!( + "play.engine_played", + mv = mv.to_string().green().bold(), + piece = piece, + depth = result.depth, + score = format_score(result.score), + secs = format!("{:.1}", result.time_ms as f64 / 1000.0) + ) + ); + } + + /// Runs an engine search for the current side and suggests a move. + fn hint(&mut self) { + let pos = fen::search_position(&self.game); + self.engine + .set_game_history(&fen::history_hashes(&self.game)); + let label = t!("play.hint_thinking").to_string(); + let pb = spinner(&self.ctx.theme, label.clone()); + self.engine.reset_abort(); + let mut on_iteration = |info: &IterationInfo| { + pb.set_message(iteration_message(&label, info)); + }; + let result = + self.engine + .search_limited(&pos, &self.settings.limits, Some(&mut on_iteration)); + pb.finish_and_clear(); + + match result.best_move { + Some(mv) => println!( + "{}", + t!( + "play.hint_result", + mv = mv.to_string().green().bold(), + score = format_score(result.score) + ) + ), + None => println!("{}", t!("play.hint_none")), + } + println!(); + } + + /// Takes back the last full move by replaying from the start position. + fn undo(&mut self) { + // Against the engine, remove the engine's reply plus the human + // move; in human-vs-human mode, remove a single half-move. + let plies = if self.engine_color.is_some() { 2 } else { 1 }; + if self.game.move_history.len() < plies { + println!("{}", t!("play.cannot_undo")); + return; + } + let target = self.game.move_history.len() - plies; + match self.rebuild_to(target) { + Ok(rebuilt) => { + self.game = rebuilt; + println!("{}", t!("play.undo_done")); + self.render_board(); + self.print_status(); + } + Err(e) => println!( + "{}: {}", + t!("terminal.error_label").to_string().red().bold(), + e + ), + } + } + + /// Replays the game from its initial FEN up to `target` half-moves. + fn rebuild_to(&self, target: usize) -> Result { + let mut rebuilt = fen::game_from_fen(&self.initial_fen)?; + for record in self.game.move_history.iter().take(target) { + rebuilt.make_move(&record.move_json)?; + } + Ok(rebuilt) + } + + /// Claims a draw when eligible, otherwise explains why not. + fn claim_draw(&mut self) { + match claim_available_draw(&mut self.game) { + Err(e) => eprintln!("error: draw claim failed: {e}"), + Ok(true) => self.print_result(), + Ok(false) => println!( + "{}", + t!( + "terminal.no_draw_available", + clock = self.game.halfmove_clock, + reps = repetition_count(&self.game) + ) + ), + } + } + + /// Renders the board with last-move and check highlights. + fn render_board(&self) { + let highlights = BoardHighlights::for_game(&self.game); + print!("{}", self.renderer.render(&self.game.board, &highlights)); + println!(); + } + + /// Prints the compact status line (move number, side to move, check). + fn print_status(&self) { + print!( + "{}", + t!( + "terminal.move_status", + num = self.game.fullmove_number, + color = side_label(self.game.turn) + ) + ); + if crate::movegen::is_in_check(&self.game.board, self.game.turn) { + print!(" {}", t!("terminal.check").to_string().red().bold()); + } + println!(); + println!(); + } + + /// Lists all legal moves in a compact grid. + fn print_moves(&self) { + let moves = self.game.legal_moves(); + println!( + "{} {}", + t!("terminal.legal_moves_header") + .to_string() + .yellow() + .bold(), + t!("terminal.moves_count", count = moves.len()) + ); + for (i, mv) in moves.iter().enumerate() { + if i > 0 && i % 8 == 0 { + println!(); + } + print!(" {}", mv.to_string().green()); + } + println!(); + println!(); + } + + /// Prints the move history as a numbered two-column table. + fn print_history(&self) { + if self.game.move_history.is_empty() { + println!("{}", t!("terminal.no_moves_yet")); + println!(); + return; + } + println!( + "{}", + t!("terminal.move_history_label") + .to_string() + .yellow() + .bold() + ); + println!( + " {:>4} {:<12} {:<12}", + "#", + t!("export.white_label"), + t!("export.black_label") + ); + let mut iter = self.game.move_history.iter().peekable(); + while let Some(record) = iter.next() { + let white = if record.side == Color::White { + record.notation.clone() + } else { + "…".to_string() + }; + let black = if record.side == Color::White { + match iter.peek() { + Some(next) if next.side == Color::Black => { + let n = next.notation.clone(); + iter.next(); + n + } + _ => String::new(), + } + } else { + record.notation.clone() + }; + println!(" {:>3}. {white:<12} {black:<12}", record.move_number); + } + println!(); + } + + /// Prints the end-of-game result panel. + fn print_result(&self) { + self.render_board(); + println!("{}", result_panel(&self.game, self.started.elapsed())); + println!(); + } +} + +/// Returns the colored display label for a side. +pub fn side_label(color: Color) -> colored::ColoredString { + match color { + Color::White => color_name(Color::White).white().bold(), + Color::Black => color_name(Color::Black).blue().bold(), + } +} + +/// Builds the styled, data-driven in-game help table. +fn help_table() -> String { + let rows = vec![ + TableRow::new("e2e4", None, t!("terminal.cmd_move").to_string()), + TableRow::new("moves", Some("m"), t!("terminal.cmd_moves").to_string()), + TableRow::new("board", Some("b"), t!("terminal.cmd_board").to_string()), + TableRow::new( + "history", + Some("hist"), + t!("terminal.cmd_history").to_string(), + ), + TableRow::new("fen", Some("f"), t!("terminal.cmd_fen").to_string()), + TableRow::new("json", Some("j"), t!("terminal.cmd_json").to_string()), + TableRow::new("hint", Some("i"), t!("play.cmd_hint").to_string()), + TableRow::new("undo", Some("u"), t!("play.cmd_undo").to_string()), + TableRow::new("resign", Some("r"), t!("terminal.cmd_resign").to_string()), + TableRow::new("draw", Some("d"), t!("terminal.cmd_draw").to_string()), + TableRow::new("help", Some("h"), t!("terminal.cmd_help").to_string()), + TableRow::new("quit", Some("q"), t!("terminal.cmd_quit").to_string()), + ]; + format!( + "{}\n{}", + t!("terminal.cmd_header").to_string().yellow().bold(), + render_table(&rows, 2) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_repetition_count_initial_position() { + let game = Game::new(); + assert_eq!(repetition_count(&game), 1); + } + + #[test] + fn test_claim_draw_not_available_at_start() { + let mut game = Game::new(); + assert!(!claim_available_draw(&mut game).expect("draw claim should not fail")); + assert!(!game.is_over()); + } + + #[test] + fn test_resolve_side_fixed_choices() { + assert_eq!(resolve_side(SideChoice::White), Color::White); + assert_eq!(resolve_side(SideChoice::Black), Color::Black); + // Random must return one of the two without panicking. + let c = resolve_side(SideChoice::Random); + assert!(c == Color::White || c == Color::Black); + } +} diff --git a/src/cli/progress.rs b/src/cli/progress.rs new file mode 100644 index 0000000..f951b38 --- /dev/null +++ b/src/cli/progress.rs @@ -0,0 +1,137 @@ +//! TTY-gated spinners and progress bars built on `indicatif`. +//! +//! Every animation in the CLI goes through this module so the rule +//! "animations only on interactive terminals" is enforced in one place: +//! when stdout is not a TTY the helpers return hidden progress bars, +//! whose method calls are no-ops, keeping piped output perfectly clean. + +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; + +use super::score::{format_score, humanize_count}; +use super::theme::{Theme, term_width, truncate_chars}; +use crate::search::IterationInfo; + +/// Tick interval for spinners. +const SPINNER_TICK: Duration = Duration::from_millis(80); + +/// Maximum number of PV moves shown on the thinking line. +const PV_PREVIEW_MOVES: usize = 6; + +/// Creates a "thinking" spinner with the given message. +/// +/// Returns a hidden (no-op) progress bar on non-interactive terminals. +pub fn spinner(theme: &Theme, message: String) -> ProgressBar { + if !theme.interactive { + return ProgressBar::hidden(); + } + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("static spinner template must parse") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]), + ); + pb.set_message(message); + pb.enable_steady_tick(SPINNER_TICK); + pb +} + +/// Creates a determinate progress bar over `len` steps. +/// +/// Returns a hidden (no-op) progress bar on non-interactive terminals. +pub fn bar(theme: &Theme, len: u64, message: String) -> ProgressBar { + if !theme.interactive { + return ProgressBar::hidden(); + } + let pb = ProgressBar::new(len); + pb.set_style( + ProgressStyle::with_template("{msg} [{bar:30.cyan/blue}] {pos}/{len} ({eta})") + .expect("static bar template must parse") + .progress_chars("█▓░"), + ); + pb.set_message(message); + pb +} + +/// Formats one iterative-deepening progress snapshot as a single line: +/// depth, score, nodes, nps and a truncated PV preview. +/// +/// `prefix` is prepended (e.g. a localized "thinking" label); the result +/// is truncated to the current terminal width. +pub fn iteration_message(prefix: &str, info: &IterationInfo) -> String { + let score = match info.mate_in { + Some(mate) => format!("#{mate}"), + None => format_score(info.score_cp), + }; + let pv: Vec<&str> = info + .pv + .iter() + .take(PV_PREVIEW_MOVES) + .map(String::as_str) + .collect(); + let line = format!( + "{prefix} d{} {} {} {} {}n/s | {}", + info.depth, + score, + humanize_count(info.nodes), + t!("cli.nodes_suffix"), + humanize_count(info.nps), + pv.join(" "), + ); + truncate_chars(&line, term_width().saturating_sub(3)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_info() -> IterationInfo { + IterationInfo { + depth: 9, + score_cp: 35, + mate_in: None, + nodes: 1_200_000, + elapsed_ms: 1500, + nps: 800_000, + pv: vec![ + "e2e4".into(), + "e7e5".into(), + "g1f3".into(), + "b8c6".into(), + "f1b5".into(), + "a7a6".into(), + "b5a4".into(), + ], + } + } + + #[test] + fn test_iteration_message_contents() { + let msg = iteration_message("thinking", &sample_info()); + assert!(msg.contains("d9")); + assert!(msg.contains("+0.35")); + assert!(msg.contains("1.2M")); + assert!(msg.contains("e2e4")); + // The 7th PV move must be cut off. + assert!(!msg.contains("b5a4")); + } + + #[test] + fn test_iteration_message_mate_notation() { + let mut info = sample_info(); + info.mate_in = Some(3); + let msg = iteration_message("", &info); + assert!(msg.contains("#3")); + } + + #[test] + fn test_hidden_bar_for_non_tty() { + let theme = Theme { + colors: false, + interactive: false, + }; + assert!(spinner(&theme, "x".into()).is_hidden()); + assert!(bar(&theme, 10, "x".into()).is_hidden()); + } +} diff --git a/src/cli/score.rs b/src/cli/score.rs new file mode 100644 index 0000000..aad0066 --- /dev/null +++ b/src/cli/score.rs @@ -0,0 +1,144 @@ +//! Score formatting and the one-line evaluation bar. +//! +//! All functions here are pure (no I/O, no color) so they are easy to +//! unit-test; callers apply styling on top. + +use colored::Colorize; + +use crate::search::score_to_mate_in; +use crate::types::Color; + +/// Default width (in cells) of the evaluation bar. +pub const EVAL_BAR_WIDTH: usize = 21; + +/// Converts a side-to-move-relative centipawn score to White's +/// perspective (positive = White is better). +pub fn white_pov(score_cp: i32, side_to_move: Color) -> i32 { + match side_to_move { + Color::White => score_cp, + Color::Black => -score_cp, + } +} + +/// Formats a centipawn score as pawns (`+1.23`) or mate distance (`#3`, +/// `#-2`). The score is interpreted from the perspective it was produced +/// in; mate signs follow that perspective. +pub fn format_score(score_cp: i32) -> String { + match score_to_mate_in(score_cp) { + Some(mate) => format!("#{mate}"), + None => format!("{:+.2}", f64::from(score_cp) / 100.0), + } +} + +/// Formats a centipawn *loss* (evaluation drop) for the annotation table. +/// +/// A loss derived from mate-range scores is not a meaningful pawn count +/// (it would print as a five-digit number next to a `#N` eval), so it +/// renders as an em dash instead. +pub fn format_cp_loss(loss_cp: i32) -> String { + if score_to_mate_in(loss_cp).is_some() { + "—".to_string() + } else { + loss_cp.to_string() + } +} + +/// Renders a pure-text evaluation bar of `width` cells. +/// +/// The filled (`█`) portion is White's winning expectancy derived from +/// the centipawn score (White's perspective) through the standard +/// logistic curve `1 / (1 + 10^(-cp/400))`; the rest is `░`. +pub fn eval_bar(score_cp_white: i32, width: usize) -> String { + let cp = f64::from(score_cp_white.clamp(-3000, 3000)); + let white_share = 1.0 / (1.0 + 10f64.powf(-cp / 400.0)); + let filled = ((white_share * width as f64).round() as usize).min(width); + format!("{}{}", "█".repeat(filled), "░".repeat(width - filled)) +} + +/// Renders the full eval-bar line: labeled ends plus the formatted score. +/// +/// Example: `W ███████████░░░░░░░░░░ B +0.35` +pub fn eval_bar_line(score_cp_white: i32) -> String { + format!( + "{} {} {} {}", + "W".white().bold(), + eval_bar(score_cp_white, EVAL_BAR_WIDTH), + "B".blue().bold(), + format_score(score_cp_white).cyan().bold() + ) +} + +/// Formats a node/nps count in compact human form (`950`, `8.5k`, `1.2M`). +pub fn humanize_count(n: u64) -> String { + match n { + 0..=999 => n.to_string(), + 1_000..=999_999 => format!("{:.1}k", n as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", n as f64 / 1_000_000.0), + _ => format!("{:.2}G", n as f64 / 1_000_000_000.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::MATE_SCORE; + + #[test] + fn test_format_score_pawns() { + assert_eq!(format_score(123), "+1.23"); + assert_eq!(format_score(-50), "-0.50"); + assert_eq!(format_score(0), "+0.00"); + } + + #[test] + fn test_format_score_mate() { + // Mate in 3 plies => 2 full moves. + assert_eq!(format_score(MATE_SCORE - 3), "#2"); + assert_eq!(format_score(-(MATE_SCORE - 4)), "#-2"); + } + + #[test] + fn test_white_pov_negates_for_black() { + assert_eq!(white_pov(80, Color::White), 80); + assert_eq!(white_pov(80, Color::Black), -80); + } + + #[test] + fn test_eval_bar_balanced() { + let bar = eval_bar(0, EVAL_BAR_WIDTH); + assert_eq!(bar.chars().count(), EVAL_BAR_WIDTH); + let filled = bar.chars().filter(|&c| c == '█').count(); + // A dead-equal score must fill roughly half the bar. + assert!((10..=11).contains(&filled), "got {filled} filled cells"); + } + + #[test] + fn test_eval_bar_extremes() { + let winning = eval_bar(MATE_SCORE, EVAL_BAR_WIDTH); + assert_eq!(winning.chars().filter(|&c| c == '█').count(), 21); + let losing = eval_bar(-MATE_SCORE, EVAL_BAR_WIDTH); + assert_eq!(losing.chars().filter(|&c| c == '█').count(), 0); + } + + #[test] + fn test_eval_bar_monotonic() { + let f = |cp| eval_bar(cp, EVAL_BAR_WIDTH).matches('█').count(); + assert!(f(-300) < f(0)); + assert!(f(0) < f(300)); + } + + #[test] + fn test_format_cp_loss_dashes_mates() { + assert_eq!(format_cp_loss(150), "150"); + assert_eq!(format_cp_loss(0), "0"); + // A loss in mate range is not a meaningful pawn count. + assert_eq!(format_cp_loss(MATE_SCORE - 3), "—"); + } + + #[test] + fn test_humanize_count() { + assert_eq!(humanize_count(950), "950"); + assert_eq!(humanize_count(8_500), "8.5k"); + assert_eq!(humanize_count(1_200_000), "1.2M"); + } +} diff --git a/src/cli/theme.rs b/src/cli/theme.rs new file mode 100644 index 0000000..a518240 --- /dev/null +++ b/src/cli/theme.rs @@ -0,0 +1,119 @@ +//! Terminal theme detection and color control. +//! +//! Centralizes every decision about *how* the CLI is allowed to draw: +//! +//! - Colors are enabled only when stdout is a TTY, `--no-color` was not +//! passed, and the `NO_COLOR` environment variable is unset/empty. +//! - Animations (spinners, progress bars, reveal effects) additionally +//! require an interactive terminal — piped output always stays plain. +//! +//! When colors are disabled this module flips the global `colored` +//! override so every `.green()` / `.bold()` call in the codebase +//! degrades to plain text automatically. + +use std::io::IsTerminal; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Fallback terminal width when the real width cannot be determined. +const DEFAULT_TERM_WIDTH: usize = 80; + +/// Detected terminal capabilities for the current process. +#[derive(Debug, Clone, Copy)] +pub struct Theme { + /// `true` when ANSI colors may be emitted. + pub colors: bool, + /// `true` when stdout is an interactive terminal (TTY). + /// Animations and live redraws must be gated on this flag. + pub interactive: bool, +} + +impl Theme { + /// Detects terminal capabilities and applies the global color override. + /// + /// `no_color` is the value of the global `--no-color` CLI flag; the + /// `NO_COLOR` environment variable (any non-empty value) is honored + /// as well, per . + pub fn detect(no_color: bool) -> Self { + let interactive = std::io::stdout().is_terminal(); + let env_no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); + let colors = interactive && !no_color && !env_no_color; + if !colors { + colored::control::set_override(false); + } + Self { + colors, + interactive, + } + } + + /// Returns a theme with all capabilities disabled (plain output). + /// Used by machine-facing modes such as UCI. + pub fn plain() -> Self { + colored::control::set_override(false); + Self { + colors: false, + interactive: false, + } + } +} + +/// Returns the current terminal width in columns (fallback: 80). +pub fn term_width() -> usize { + crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(DEFAULT_TERM_WIDTH) +} + +/// Display width of `text` in terminal columns (East-Asian wide glyphs +/// count as two). Use this — never `chars().count()` — for any box-drawing +/// or column-alignment math so CJK text lines up correctly. +pub fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +/// Truncates `text` to at most `max` display columns, appending `…` when cut. +/// +/// Operates on `char` boundaries (so multi-byte input can never panic) and +/// accounts for wide glyphs, so the result never exceeds `max` columns. +pub fn truncate_chars(text: &str, max: usize) -> String { + if display_width(text) <= max { + return text.to_string(); + } + let budget = max.saturating_sub(1); // reserve one column for the '…' + let mut out = String::new(); + let mut width = 0; + for ch in text.chars() { + let cw = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + cw > budget { + break; + } + out.push(ch); + width += cw; + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_short_text_unchanged() { + assert_eq!(truncate_chars("hello", 10), "hello"); + } + + #[test] + fn test_truncate_long_text() { + let out = truncate_chars("abcdefghij", 5); + assert_eq!(out, "abcd…"); + assert_eq!(out.chars().count(), 5); + } + + #[test] + fn test_truncate_multibyte_safe() { + let out = truncate_chars("♔♕♖♗♘♙♚♛", 4); + assert_eq!(out.chars().count(), 4); + assert!(out.ends_with('…')); + } +} diff --git a/src/cli/uci.rs b/src/cli/uci.rs new file mode 100644 index 0000000..0ee1c47 --- /dev/null +++ b/src/cli/uci.rs @@ -0,0 +1,630 @@ +//! `checkai uci` — the Universal Chess Interface protocol. +//! +//! Speaks plain UCI on stdin/stdout so CheckAI can be plugged into any +//! chess GUI or match runner (cutechess-cli, fastchess, Arena, …). +//! +//! Supported commands: `uci`, `isready`, `setoption name Hash value N`, +//! `ucinewgame`, `position [startpos | fen ] [moves ...]`, +//! `go [depth N | movetime MS | nodes N | wtime/btime/winc/binc/movestogo +//! | infinite]`, `stop`, `quit`. +//! +//! Searches run on a dedicated `std::thread` with the engine's abort +//! token wired to `stop`, emitting `info` lines from the iterative- +//! deepening callback and a final `bestmove`. +//! +//! This module is machine-facing: it deliberately bypasses i18n and +//! colors — output is pure protocol text. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; + +use clap::Args; + +use super::fen; +use super::{CliCommand, CliContext, CliResult}; +use crate::game::Game; +use crate::search::{IterationInfo, MAX_DEPTH, SearchEngine, SearchLimits}; +use crate::types::{ChessMove, Color, MoveJson}; + +/// Default transposition table size (MB), matching the advertised option. +const DEFAULT_HASH_MB: usize = 64; +/// Minimum accepted Hash size (MB). +const MIN_HASH_MB: usize = 1; +/// Maximum accepted Hash size (MB). +const MAX_HASH_MB: usize = 4096; +/// Fraction of the remaining clock budgeted per move (1/25th). +const CLOCK_DIVISOR: u64 = 25; +/// Minimum time budget per move (ms) when playing on a clock. +const MIN_BUDGET_MS: u64 = 10; + +/// Arguments for `checkai uci` (none — pure stdio protocol). +#[derive(Args, Debug)] +#[command(after_help = "\ +Example session:\n\ + $ checkai uci\n\ + uci\n\ + position startpos moves e2e4\n\ + go movetime 1000\n\ + quit")] +pub struct UciArgs {} + +impl CliCommand for UciArgs { + fn run(self, _ctx: &CliContext) -> CliResult { + run_uci_loop(); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Command parsing (pure functions — unit tested) +// --------------------------------------------------------------------------- + +/// `go` sub-parameters. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GoParams { + /// Fixed search depth in plies. + pub depth: Option, + /// Fixed time per move in milliseconds. + pub movetime: Option, + /// Node budget. + pub nodes: Option, + /// White's remaining clock time (ms). + pub wtime: Option, + /// Black's remaining clock time (ms). + pub btime: Option, + /// White's increment per move (ms). + pub winc: Option, + /// Black's increment per move (ms). + pub binc: Option, + /// Moves until the next time control. + pub movestogo: Option, + /// Search until `stop`. + pub infinite: bool, +} + +/// A parsed UCI command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UciCommand { + /// `uci` — identify the engine. + Uci, + /// `isready` — handshake. + IsReady, + /// `setoption name [value ]`. + SetOption { + /// Option name (verbatim, case preserved). + name: String, + /// Option value, if present. + value: Option, + }, + /// `ucinewgame` — reset state between games. + UciNewGame, + /// `position [startpos | fen ] [moves ...]`. + Position { + /// FEN string, or `None` for the starting position. + fen: Option, + /// Long-algebraic moves to apply after the base position. + moves: Vec, + }, + /// `go [...]` — start searching. + Go(GoParams), + /// `stop` — abort the current search. + Stop, + /// `quit` — terminate. + Quit, + /// Anything unrecognized (ignored, per UCI convention). + Unknown(String), +} + +/// Parses one line of UCI input into a [`UciCommand`]. +pub fn parse_command(line: &str) -> UciCommand { + let mut tokens = line.split_whitespace(); + match tokens.next() { + Some("uci") => UciCommand::Uci, + Some("isready") => UciCommand::IsReady, + Some("ucinewgame") => UciCommand::UciNewGame, + Some("stop") => UciCommand::Stop, + Some("quit") => UciCommand::Quit, + Some("setoption") => parse_setoption(&tokens.collect::>()), + Some("position") => parse_position(&tokens.collect::>()), + Some("go") => UciCommand::Go(parse_go(&tokens.collect::>())), + Some(other) => UciCommand::Unknown(other.to_string()), + None => UciCommand::Unknown(String::new()), + } +} + +/// Parses `setoption name [value ]`. +fn parse_setoption(tokens: &[&str]) -> UciCommand { + let mut name_parts: Vec<&str> = Vec::new(); + let mut value_parts: Vec<&str> = Vec::new(); + let mut target: Option<&mut Vec<&str>> = None; + for &token in tokens { + match token { + "name" => target = Some(&mut name_parts), + "value" => target = Some(&mut value_parts), + other => { + if let Some(t) = target.as_mut() { + t.push(other); + } + } + } + } + UciCommand::SetOption { + name: name_parts.join(" "), + value: if value_parts.is_empty() { + None + } else { + Some(value_parts.join(" ")) + }, + } +} + +/// Parses `position [startpos | fen <6 fields>] [moves ...]`. +fn parse_position(tokens: &[&str]) -> UciCommand { + let mut fen: Option = None; + let mut moves: Vec = Vec::new(); + let mut i = 0; + while i < tokens.len() { + match tokens[i] { + "startpos" => i += 1, + "fen" => { + let mut fields: Vec<&str> = Vec::new(); + i += 1; + while i < tokens.len() && tokens[i] != "moves" { + fields.push(tokens[i]); + i += 1; + } + fen = Some(fields.join(" ")); + } + "moves" => { + moves = tokens[i + 1..].iter().map(|s| s.to_string()).collect(); + break; + } + _ => i += 1, + } + } + UciCommand::Position { fen, moves } +} + +/// Parses the parameters of a `go` command. +fn parse_go(tokens: &[&str]) -> GoParams { + let mut params = GoParams::default(); + let mut i = 0; + while i < tokens.len() { + let value = tokens.get(i + 1); + let parse_u64 = || value.and_then(|v| v.parse::().ok()); + match tokens[i] { + "depth" => { + params.depth = value.and_then(|v| v.parse::().ok()); + i += 2; + } + "movetime" => { + params.movetime = parse_u64(); + i += 2; + } + "nodes" => { + params.nodes = parse_u64(); + i += 2; + } + "wtime" => { + params.wtime = parse_u64(); + i += 2; + } + "btime" => { + params.btime = parse_u64(); + i += 2; + } + "winc" => { + params.winc = parse_u64(); + i += 2; + } + "binc" => { + params.binc = parse_u64(); + i += 2; + } + "movestogo" => { + params.movestogo = parse_u64(); + i += 2; + } + "infinite" => { + params.infinite = true; + i += 1; + } + _ => i += 1, + } + } + params +} + +/// Converts `go` parameters into [`SearchLimits`] for the given side. +/// +/// Clock allocation: `remaining / movestogo.clamp(2, 25) + increment / 2`, +/// capped so at least 50 ms stays on the clock, with a 10 ms floor. +pub fn limits_from_go(params: &GoParams, side: Color) -> SearchLimits { + let mut limits = SearchLimits { + max_depth: params.depth.unwrap_or(MAX_DEPTH).clamp(1, MAX_DEPTH), + move_time_ms: params.movetime, + max_nodes: params.nodes, + }; + + if limits.move_time_ms.is_none() && !params.infinite { + let (time, inc) = match side { + Color::White => (params.wtime, params.winc), + Color::Black => (params.btime, params.binc), + }; + if let Some(remaining) = time { + let divisor = params + .movestogo + .unwrap_or(CLOCK_DIVISOR) + .clamp(2, CLOCK_DIVISOR); + let budget = remaining / divisor + inc.unwrap_or(0) / 2; + let capped = budget.min(remaining.saturating_sub(50)).max(MIN_BUDGET_MS); + limits.move_time_ms = Some(capped); + } + } + limits +} + +/// Formats a move in pure UCI notation (`e2e4`, `e7e8q`). +pub fn move_to_uci(mv: &ChessMove) -> String { + let mut out = format!("{}{}", mv.from.to_algebraic(), mv.to.to_algebraic()); + if let Some(promo) = mv.promotion { + out.push(match promo { + crate::types::PieceKind::Queen => 'q', + crate::types::PieceKind::Rook => 'r', + crate::types::PieceKind::Bishop => 'b', + crate::types::PieceKind::Knight => 'n', + _ => 'q', + }); + } + out +} + +/// Converts an internal move display string (`e7e8=Q`) to UCI (`e7e8q`). +fn display_to_uci(display: &str) -> String { + display.replace('=', "").to_lowercase() +} + +/// Parses a UCI move string (`e2e4`, `e7e8q`) into a [`MoveJson`]. +pub fn uci_to_move_json(uci: &str) -> Option { + crate::terminal::parse_move_input(uci) +} + +/// Formats one `info` line from an iteration snapshot. +fn format_info_line(info: &IterationInfo) -> String { + let score = match info.mate_in { + Some(mate) => format!("score mate {mate}"), + None => format!("score cp {}", info.score_cp), + }; + let pv: Vec = info.pv.iter().map(|m| display_to_uci(m)).collect(); + let mut line = format!( + "info depth {} {} nodes {} nps {} time {}", + info.depth, score, info.nodes, info.nps, info.elapsed_ms + ); + if !pv.is_empty() { + line.push_str(" pv "); + line.push_str(&pv.join(" ")); + } + line +} + +// --------------------------------------------------------------------------- +// Protocol loop +// --------------------------------------------------------------------------- + +/// Engine state owned by the UCI loop. +struct UciState { + /// Engine instance; `None` while a search thread borrows it. + engine: Option, + /// Shared abort token wired into the engine (set by `stop`). + abort: Arc, + /// Running search thread, returning the engine when joined. + search_thread: Option>, + /// Current position (game state including clocks and history). + game: Game, + /// Configured hash size in MB. + hash_mb: usize, +} + +impl UciState { + fn new() -> Self { + let abort = Arc::new(AtomicBool::new(false)); + let mut engine = SearchEngine::new(DEFAULT_HASH_MB); + engine.set_abort_token(Arc::clone(&abort)); + Self { + engine: Some(engine), + abort, + search_thread: None, + game: Game::new(), + hash_mb: DEFAULT_HASH_MB, + } + } + + /// Stops any running search and reclaims the engine instance. + fn stop_search(&mut self) { + self.abort.store(true, Ordering::Relaxed); + if let Some(handle) = self.search_thread.take() + && let Ok(engine) = handle.join() + { + self.engine = Some(engine); + } + } + + /// Applies `setoption` (only `Hash` is supported). + fn set_option(&mut self, name: &str, value: Option<&str>) { + if name.eq_ignore_ascii_case("hash") + && let Some(mb) = value.and_then(|v| v.parse::().ok()) + { + self.stop_search(); + self.hash_mb = mb.clamp(MIN_HASH_MB, MAX_HASH_MB); + let mut engine = SearchEngine::new(self.hash_mb); + engine.set_abort_token(Arc::clone(&self.abort)); + self.engine = Some(engine); + } + } + + /// Rebuilds the current game from a `position` command. + fn set_position(&mut self, fen_opt: Option<&str>, moves: &[String]) { + let mut game = match fen_opt { + Some(fen_str) => match fen::game_from_fen(fen_str) { + Ok(g) => g, + Err(_) => return, // ignore malformed positions, per UCI robustness + }, + None => Game::new(), + }; + for mv in moves { + let Some(move_json) = uci_to_move_json(mv) else { + break; + }; + if game.make_move(&move_json).is_err() { + break; + } + } + self.game = game; + } + + /// Starts a search thread for the current position. + fn go(&mut self, params: GoParams) { + self.stop_search(); + let Some(mut engine) = self.engine.take() else { + return; + }; + engine.reset_abort(); + // Feed the moves already played so the search scores a line that + // repeats an earlier game position as a draw (finds/avoids perpetuals). + engine.set_game_history(&fen::history_hashes(&self.game)); + + let limits = limits_from_go(¶ms, self.game.turn); + let pos = fen::search_position(&self.game); + + // Robustness for match play: if the search is aborted (`stop`/`quit`) + // before it completes even one iteration, we must still answer with a + // *legal* move — many match runners treat `bestmove 0000` as an illegal + // move and forfeit the game. Pre-compute a safe fallback here. + let fallback = self + .game + .legal_moves() + .first() + .map(move_to_uci) + .unwrap_or_else(|| "0000".to_string()); + + self.search_thread = Some(std::thread::spawn(move || { + let mut on_iteration = |info: &IterationInfo| { + println!("{}", format_info_line(info)); + }; + let result = engine.search_limited(&pos, &limits, Some(&mut on_iteration)); + let best = result + .best_move + .map(|mv| move_to_uci(&mv)) + .unwrap_or(fallback); + println!("bestmove {best}"); + engine + })); + } + + /// Clears search state between games. + fn new_game(&mut self) { + self.stop_search(); + if let Some(engine) = self.engine.as_mut() { + engine.tt.clear(); + } + self.game = Game::new(); + } +} + +/// Runs the blocking UCI read-eval loop until `quit` or EOF. +fn run_uci_loop() { + use std::io::BufRead; + + let mut state = UciState::new(); + let stdin = std::io::stdin(); + + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + match parse_command(&line) { + UciCommand::Uci => { + println!("id name CheckAI {}", crate::update::version()); + println!("id author JosunLP and contributors"); + println!( + "option name Hash type spin default {DEFAULT_HASH_MB} min {MIN_HASH_MB} max {MAX_HASH_MB}" + ); + println!("uciok"); + } + UciCommand::IsReady => println!("readyok"), + UciCommand::SetOption { name, value } => { + state.set_option(&name, value.as_deref()); + } + UciCommand::UciNewGame => state.new_game(), + UciCommand::Position { fen, moves } => { + state.set_position(fen.as_deref(), &moves); + } + UciCommand::Go(params) => state.go(params), + UciCommand::Stop => state.stop_search(), + UciCommand::Quit => break, + UciCommand::Unknown(_) => {} // silently ignore, per UCI convention + } + } + state.stop_search(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_basic_commands() { + assert_eq!(parse_command("uci"), UciCommand::Uci); + assert_eq!(parse_command("isready"), UciCommand::IsReady); + assert_eq!(parse_command("ucinewgame"), UciCommand::UciNewGame); + assert_eq!(parse_command("stop"), UciCommand::Stop); + assert_eq!(parse_command("quit"), UciCommand::Quit); + assert!(matches!(parse_command("banana"), UciCommand::Unknown(_))); + } + + #[test] + fn test_parse_position_startpos_with_moves() { + let cmd = parse_command("position startpos moves e2e4 e7e5"); + assert_eq!( + cmd, + UciCommand::Position { + fen: None, + moves: vec!["e2e4".to_string(), "e7e5".to_string()], + } + ); + } + + #[test] + fn test_parse_position_fen() { + let cmd = + parse_command("position fen 8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1 moves b4b1"); + assert_eq!( + cmd, + UciCommand::Position { + fen: Some("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1".to_string()), + moves: vec!["b4b1".to_string()], + } + ); + } + + #[test] + fn test_parse_setoption_hash() { + let cmd = parse_command("setoption name Hash value 128"); + assert_eq!( + cmd, + UciCommand::SetOption { + name: "Hash".to_string(), + value: Some("128".to_string()), + } + ); + } + + #[test] + fn test_parse_go_parameters() { + let params = match parse_command("go depth 9 nodes 5000") { + UciCommand::Go(p) => p, + other => panic!("expected go, got {other:?}"), + }; + assert_eq!(params.depth, Some(9)); + assert_eq!(params.nodes, Some(5000)); + assert!(!params.infinite); + + let params = match parse_command("go wtime 60000 btime 50000 winc 1000 binc 900") { + UciCommand::Go(p) => p, + other => panic!("expected go, got {other:?}"), + }; + assert_eq!(params.wtime, Some(60_000)); + assert_eq!(params.binc, Some(900)); + } + + #[test] + fn test_limits_from_clock() { + let params = GoParams { + wtime: Some(60_000), + winc: Some(1_000), + ..GoParams::default() + }; + let limits = limits_from_go(¶ms, Color::White); + // 60000/25 + 1000/2 = 2900 ms + assert_eq!(limits.move_time_ms, Some(2_900)); + assert_eq!(limits.max_depth, MAX_DEPTH); + + // Black uses btime/binc. + let params = GoParams { + wtime: Some(60_000), + btime: Some(10_000), + binc: Some(500), + ..GoParams::default() + }; + let limits = limits_from_go(¶ms, Color::Black); + assert_eq!(limits.move_time_ms, Some(10_000 / 25 + 250)); + } + + #[test] + fn test_limits_movetime_takes_priority() { + let params = GoParams { + movetime: Some(750), + wtime: Some(60_000), + ..GoParams::default() + }; + let limits = limits_from_go(¶ms, Color::White); + assert_eq!(limits.move_time_ms, Some(750)); + } + + #[test] + fn test_limits_infinite_has_no_time() { + let params = GoParams { + infinite: true, + wtime: Some(60_000), + ..GoParams::default() + }; + let limits = limits_from_go(¶ms, Color::White); + assert_eq!(limits.move_time_ms, None); + assert_eq!(limits.max_depth, MAX_DEPTH); + } + + #[test] + fn test_move_to_uci_promotion() { + use crate::types::{PieceKind, Square}; + let mv = ChessMove { + from: Square::from_algebraic("e7").unwrap(), + to: Square::from_algebraic("e8").unwrap(), + promotion: Some(PieceKind::Queen), + is_castling: false, + is_en_passant: false, + }; + assert_eq!(move_to_uci(&mv), "e7e8q"); + assert_eq!(display_to_uci("e7e8=Q"), "e7e8q"); + } + + #[test] + fn test_uci_to_move_json() { + let mj = uci_to_move_json("e7e8q").unwrap(); + assert_eq!(mj.from, "e7"); + assert_eq!(mj.to, "e8"); + assert_eq!(mj.promotion, Some("Q".to_string())); + assert!(uci_to_move_json("nonsense").is_none()); + } + + #[test] + fn test_format_info_line() { + let info = IterationInfo { + depth: 7, + score_cp: -42, + mate_in: None, + nodes: 123_456, + elapsed_ms: 250, + nps: 493_824, + pv: vec!["e2e4".to_string(), "e7e8=Q".to_string()], + }; + assert_eq!( + format_info_line(&info), + "info depth 7 score cp -42 nodes 123456 nps 493824 time 250 pv e2e4 e7e8q" + ); + + let mate = IterationInfo { + mate_in: Some(-2), + ..info + }; + assert!(format_info_line(&mate).contains("score mate -2")); + } +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..4e85322 --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,189 @@ +//! `checkai watch` — sit back and watch the engine play itself. +//! +//! Two independent engine instances (each with its own transposition +//! table sized by its level) alternate moves. Every move is announced +//! on a ticker line, the board is re-rendered with from/to highlights, +//! and a running evaluation bar tracks the game. Threefold/fifty-move +//! draws are claimed automatically so games always terminate; a +//! configurable move cap guards against endless shuffling. + +use std::time::Instant; + +use clap::Args; +use colored::Colorize; + +use super::board_renderer::{BoardHighlights, BoardRenderer}; +use super::fen; +use super::level::{DEFAULT_LEVEL, LevelSettings, MAX_LEVEL, MIN_LEVEL}; +use super::panel::result_panel; +use super::play::{claim_available_draw, color_name, side_label}; +use super::progress::{iteration_message, spinner}; +use super::score::{eval_bar_line, format_score, white_pov}; +use super::{CliCommand, CliContext, CliResult}; +use crate::game::Game; +use crate::search::{IterationInfo, SearchEngine}; +use crate::types::Color; + +/// Arguments for `checkai watch`. +#[derive(Args, Debug)] +#[command(after_help = "\ +Examples:\n\ + checkai watch Level 5 vs level 5 showcase\n\ + checkai watch --level 8 Both sides at level 8\n\ + checkai watch --level-white 9 --level-black 3 An uneven match\n\ + checkai watch --movetime 200 --delay 0 Fast-forward game\n\ + checkai watch --max-moves 60 Stop after 60 full moves")] +pub struct WatchArgs { + /// Difficulty level for both engines (overrides the per-side flags). + #[arg(long, value_parser = clap::value_parser!(u8).range(MIN_LEVEL as i64..=MAX_LEVEL as i64))] + pub level: Option, + + /// Difficulty level for the white engine. + #[arg(long, default_value_t = DEFAULT_LEVEL, + value_parser = clap::value_parser!(u8).range(MIN_LEVEL as i64..=MAX_LEVEL as i64))] + pub level_white: u8, + + /// Difficulty level for the black engine. + #[arg(long, default_value_t = DEFAULT_LEVEL, + value_parser = clap::value_parser!(u8).range(MIN_LEVEL as i64..=MAX_LEVEL as i64))] + pub level_black: u8, + + /// Thinking time per move in milliseconds (overrides the ladder). + #[arg(long)] + pub movetime: Option, + + /// Safety cap: stop after this many full moves. + #[arg(long, default_value_t = 200)] + pub max_moves: u32, + + /// Pause between moves in milliseconds (0 = as fast as possible). + #[arg(long, default_value_t = 800)] + pub delay: u64, + + /// Use ASCII piece letters instead of Unicode glyphs. + #[arg(long)] + pub ascii: bool, +} + +impl CliCommand for WatchArgs { + fn run(self, ctx: &CliContext) -> CliResult { + let white_level = self.level.unwrap_or(self.level_white); + let black_level = self.level.unwrap_or(self.level_black); + let white_settings = LevelSettings::for_level(white_level, self.movetime, None); + let black_settings = LevelSettings::for_level(black_level, self.movetime, None); + + println!(); + println!( + "{}", + t!( + "watch.intro", + white = white_settings.level, + black = black_settings.level + ) + .to_string() + .yellow() + .bold() + ); + println!(); + + let mut white_engine = SearchEngine::new(white_settings.tt_size_mb); + let mut black_engine = SearchEngine::new(black_settings.tt_size_mb); + let renderer = BoardRenderer::new(self.ascii, false); + let mut game = Game::new(); + let started = Instant::now(); + + print!( + "{}", + renderer.render(&game.board, &BoardHighlights::default()) + ); + println!(); + + while !game.is_over() && game.fullmove_number <= self.max_moves { + let side = game.turn; + let (engine, settings) = match side { + Color::White => (&mut white_engine, &white_settings), + Color::Black => (&mut black_engine, &black_settings), + }; + + let label = t!( + "watch.thinking", + color = color_name(side), + level = settings.level + ) + .to_string(); + let pb = spinner(&ctx.theme, label.clone()); + engine.reset_abort(); + let pos = fen::search_position(&game); + engine.set_game_history(&fen::history_hashes(&game)); + let mut on_iteration = |info: &IterationInfo| { + pb.set_message(iteration_message(&label, info)); + }; + let result = engine.search_limited(&pos, &settings.limits, Some(&mut on_iteration)); + pb.finish_and_clear(); + + let Some(best) = result.best_move else { + // No move available — should be unreachable for a live game. + break; + }; + + // Ticker line: move number, side, move, eval, depth, nodes. + println!( + "{}", + t!( + "watch.move_ticker", + num = game.fullmove_number, + color = side_label(side), + mv = best.to_string().green().bold(), + score = format_score(result.score), + depth = result.depth, + nodes = super::score::humanize_count(result.stats.nodes) + ) + ); + + if let Err(e) = game.make_move(&best.to_json()) { + println!( + "{}: {}", + t!("terminal.error_label").to_string().red().bold(), + e + ); + break; + } + + print!( + "{}", + renderer.render(&game.board, &BoardHighlights::for_game(&game)) + ); + println!(); + println!(" {}", eval_bar_line(white_pov(result.score, side))); + println!(); + + // End shuffled games: claim any available draw automatically. + if !game.is_over() { + match claim_available_draw(&mut game) { + Ok(true) => break, + Ok(false) => {} + Err(e) => eprintln!("error: draw claim failed: {e}"), + } + } + + // Pacing between moves — interactive terminals only. + if ctx.theme.interactive && self.delay > 0 && !game.is_over() { + std::thread::sleep(std::time::Duration::from_millis(self.delay)); + } + } + + if game.is_over() { + println!("{}", result_panel(&game, started.elapsed())); + } else { + println!( + "{}", + t!("watch.move_cap_reached", cap = self.max_moves) + .to_string() + .yellow() + .bold() + ); + } + println!(); + Ok(()) + } +} diff --git a/src/cli/welcome.rs b/src/cli/welcome.rs new file mode 100644 index 0000000..2378815 --- /dev/null +++ b/src/cli/welcome.rs @@ -0,0 +1,141 @@ +//! The no-subcommand welcome screen. +//! +//! A box-drawn banner, version/locale line, data-driven command table, +//! quick-start section and docs link. On interactive terminals the +//! screen is revealed line by line (a subtle < 400 ms animation, no +//! flicker); piped output prints instantly and plainly. + +use std::time::Duration; + +use colored::Colorize; + +use super::panel::{TableRow, boxed_panel, render_table}; +use super::theme::Theme; + +/// Per-line delay of the reveal animation. With ~18 lines this stays +/// well under the 400 ms budget. +const REVEAL_DELAY: Duration = Duration::from_millis(16); + +/// One welcome-screen command entry: `(name, i18n description key)`. +const COMMANDS: [(&str, &str); 10] = [ + ("serve", "cli.cmd_serve_desc"), + ("play", "cli.cmd_play_desc"), + ("watch", "cli.cmd_watch_desc"), + ("analyze", "cli.cmd_analyze_desc"), + ("bench", "cli.cmd_bench_desc"), + ("perft", "cli.cmd_perft_desc"), + ("uci", "cli.cmd_uci_desc"), + ("export", "cli.cmd_export_desc"), + ("update", "cli.cmd_update_desc"), + ("version", "cli.cmd_version_desc"), +]; + +/// Quick-start entries: `(shell line, i18n description key)`. +const QUICKSTART: [(&str, &str); 4] = [ + ("$ checkai play", "cli.quickstart_play"), + ("$ checkai watch", "cli.quickstart_watch"), + ("$ checkai serve", "cli.quickstart_serve"), + ("$ checkai --help", "cli.quickstart_help"), +]; + +/// Prints the welcome screen, animated on interactive terminals. +pub fn print_welcome(theme: &Theme) { + let lines = build_lines(); + if theme.interactive { + for line in lines { + println!("{line}"); + std::thread::sleep(REVEAL_DELAY); + } + } else { + println!("{}", lines.join("\n")); + } +} + +/// Builds every output line of the welcome screen. +fn build_lines() -> Vec { + let version = crate::update::version(); + let locale = rust_i18n::locale().to_string(); + + let mut lines: Vec = vec![String::new()]; + + // Banner. + let banner_lines = vec![format!( + "{} v{} · {}", + t!("cli.banner_tagline"), + version, + t!("terminal.banner_subtitle") + )]; + for row in boxed_panel(&t!("cli.welcome_header"), &banner_lines, 53) { + lines.push(row.cyan().to_string()); + } + lines.push(String::new()); + + // Version / locale line. + lines.push(format!( + " {} {} {} {}", + t!("cli.version_label").to_string().bold(), + version, + t!("cli.locale_label").to_string().bold(), + locale + )); + lines.push(String::new()); + + // Command table (data-driven). + lines.push( + t!("cli.commands_header") + .to_string() + .yellow() + .bold() + .to_string(), + ); + let rows: Vec = COMMANDS + .iter() + .map(|(name, key)| TableRow::new(*name, None, t!(*key).to_string())) + .collect(); + lines.extend(render_table(&rows, 2).lines().map(str::to_string)); + lines.push(String::new()); + + // Quick start. + lines.push( + t!("cli.quickstart_header") + .to_string() + .yellow() + .bold() + .to_string(), + ); + let quick_rows: Vec = QUICKSTART + .iter() + .map(|(cmd, key)| TableRow { + name: (*cmd).to_string(), + alias: None, + desc: t!(*key).to_string(), + }) + .collect(); + let quick_width = quick_rows + .iter() + .map(|r| super::theme::display_width(&r.name)) + .max() + .unwrap_or(0); + for row in &quick_rows { + lines.push(format!( + " {}{} {}", + row.name.dimmed(), + " ".repeat(quick_width - super::theme::display_width(&row.name)), + row.desc + )); + } + lines.push(String::new()); + + // Help hint + docs link. + lines.push(format!( + " {}", + t!("cli.run_help_hint", cmd = "--help".green()) + )); + lines.push(format!( + " {} {}", + t!("cli.docs_label"), + "https://github.com/JosunLP/checkai".cyan().underline() + )); + lines.push(String::new()); + lines +} diff --git a/src/game.rs b/src/game.rs index dabc16c..b49f05f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -123,6 +123,68 @@ impl Game { game } + /// Builds a game from a full FEN string, used as the starting position. + /// + /// Accepts the standard six-field FEN; the halfmove clock and fullmove + /// number default to `0` and `1` respectively when omitted. The resulting + /// game has an empty move history seeded with the parsed position. + /// + /// Returns an error for malformed FEN, an invalid side-to-move or en + /// passant square, or a position missing either king. + pub fn from_fen(fen: &str) -> Result { + let parts: Vec<&str> = fen.split_whitespace().collect(); + if parts.len() < 4 { + return Err(format!( + "FEN must have at least 4 fields, found {}", + parts.len() + )); + } + + let board = Board::from_piece_placement(parts[0])?; + let turn = match parts[1] { + "w" | "W" => Color::White, + "b" | "B" => Color::Black, + other => return Err(format!("Invalid side-to-move '{}'", other)), + }; + let castling = CastlingRights::from_fen(parts[2]); + let en_passant = match parts[3] { + "-" => None, + sq => Some( + Square::from_algebraic(sq) + .ok_or_else(|| format!("Invalid en passant square '{}'", sq))?, + ), + }; + let halfmove_clock = parts.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); + let fullmove_number = parts + .get(5) + .and_then(|s| s.parse().ok()) + .unwrap_or(1) + .max(1); + + if board.find_king(Color::White).is_none() || board.find_king(Color::Black).is_none() { + return Err("FEN position must contain both kings".to_string()); + } + + let initial_fen = board.to_position_fen(turn, &castling, en_passant); + + Ok(Self { + id: Uuid::new_v4(), + board, + turn, + castling, + en_passant, + halfmove_clock, + fullmove_number, + position_history: vec![initial_fen], + move_history: Vec::new(), + result: None, + end_reason: None, + draw_offered_by: None, + start_timestamp: storage::unix_timestamp(), + end_timestamp: 0, + }) + } + /// Returns `true` if the game has ended (has a result). pub fn is_over(&self) -> bool { self.result.is_some() @@ -320,7 +382,7 @@ impl Game { } /// Counts how many times the current position has occurred. - fn count_position_repetitions(&self) -> usize { + pub fn count_position_repetitions(&self) -> usize { if let Some(current) = self.position_history.last() { self.position_history .iter() @@ -331,6 +393,22 @@ impl Game { } } + /// Returns the draw-claim reason currently available to the side to move, + /// if any: `"threefold_repetition"` (position seen three or more times) or + /// `"fifty_move_rule"` (halfmove clock at 100+). Threefold takes priority. + /// + /// The returned string is the reason accepted by [`Game::process_action`] + /// with action `"claim_draw"`. + pub fn available_draw_claim(&self) -> Option<&'static str> { + if self.count_position_repetitions() >= 3 { + Some("threefold_repetition") + } else if self.halfmove_clock >= 100 { + Some("fifty_move_rule") + } else { + None + } + } + /// Processes a special action (draw claim, draw offer, resignation). /// /// Returns `Ok(())` on success, or `Err(String)` if the action is invalid. @@ -671,6 +749,73 @@ mod tests { } } + // ------------------------------------------------------------------- + // FEN parsing tests + // ------------------------------------------------------------------- + + #[test] + fn test_from_fen_starting_position_matches_new() { + let from_fen = + Game::from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").unwrap(); + let fresh = Game::new(); + assert_eq!(from_fen.board, fresh.board); + assert_eq!(from_fen.turn, fresh.turn); + assert_eq!(from_fen.castling, fresh.castling); + assert_eq!(from_fen.en_passant, fresh.en_passant); + assert_eq!(from_fen.fullmove_number, fresh.fullmove_number); + } + + #[test] + fn test_from_fen_roundtrips_through_position_fen() { + let fen = "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 2 3"; + let game = Game::from_fen(fen).unwrap(); + // The position FEN (placement + turn + castling + ep) must round-trip. + let position_fen = game + .board + .to_position_fen(game.turn, &game.castling, game.en_passant); + assert_eq!( + position_fen, + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq -" + ); + assert_eq!(game.halfmove_clock, 2); + assert_eq!(game.fullmove_number, 3); + } + + #[test] + fn test_from_fen_parses_en_passant_and_side() { + let game = + Game::from_fen("rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3").unwrap(); + assert_eq!(game.turn, Color::White); + assert_eq!(game.en_passant, Square::from_algebraic("d6")); + } + + #[test] + fn test_from_fen_partial_fields_default_clocks() { + // Only the mandatory four fields; clocks should default. + let game = Game::from_fen("8/8/8/8/8/8/4k3/4K3 w - -").unwrap(); + assert_eq!(game.halfmove_clock, 0); + assert_eq!(game.fullmove_number, 1); + } + + #[test] + fn test_from_fen_rejects_malformed_input() { + assert!(Game::from_fen("not a fen").is_err()); + assert!(Game::from_fen("8/8/8/8/8/8/8/8 w - - 0 1").is_err()); // no kings + assert!( + Game::from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").is_err() + ); + } + + #[test] + fn test_from_fen_position_is_playable() { + // A parsed position must produce legal moves and accept them. + let mut game = + Game::from_fen("r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 2 3") + .unwrap(); + assert!(!game.legal_moves().is_empty()); + assert!(game.make_move(&mv("g8", "f6")).is_ok()); + } + // ------------------------------------------------------------------- // Draw offer persistence tests (Bug Fix) // ------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 1214c55..107cfd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,33 @@ //! - **Swagger/OpenAPI Documentation**: Auto-generated API docs //! available at `/swagger-ui/`. //! -//! - **Terminal Interface**: Colored board display with interactive -//! move input for local two-player games. +//! - **Analysis Engine**: An alpha-beta / PVS search (iterative +//! deepening, transposition table, null-move pruning, LMR, SEE, +//! futility/razoring, killer/history/counter-move ordering, and +//! quiescence search) paired with a PeSTO-style evaluation. Used by +//! the analysis API and the terminal commands below. +//! +//! - **Animated Terminal CLI**: Play against the built-in engine or a +//! second human, watch engine-vs-engine games, analyze positions and +//! games, benchmark the engine, run perft, or speak UCI for chess +//! GUIs. Animated boards and search progress (via `crossterm` and +//! `indicatif`) render on a TTY and degrade to plain text otherwise +//! (also honoring `--no-color` / `NO_COLOR`). +//! +//! ## Commands +//! +//! | Command | Description | +//! |-----------|--------------------------------------------------------| +//! | `serve` | Start the REST + WebSocket API server with Swagger UI | +//! | `play` | Play in the terminal (vs the engine by default) | +//! | `watch` | Watch the engine play itself (engine-vs-engine) | +//! | `analyze` | Analyze a position (`--fen`) or a game (`--moves`) | +//! | `bench` | Run the fixed engine benchmark suite (nodes, NPS) | +//! | `perft` | Verify move generation with perft node counts | +//! | `uci` | Run as a UCI engine on stdin/stdout (for chess GUIs) | +//! | `export` | Export archived games as text, PGN, or JSON | +//! | `update` | Update CheckAI to the latest version from GitHub | +//! | `version` | Print the current version | //! //! ## Usage //! @@ -35,8 +60,11 @@ //! # Start the API server on a custom port //! checkai serve --port 3000 //! -//! # Play a local terminal game +//! # Play a terminal game against the built-in engine //! checkai play +//! +//! # Run as a UCI engine for a chess GUI / match runner +//! checkai uci //! ``` //! //! ## API Endpoints @@ -57,6 +85,7 @@ pub mod analysis; pub mod analysis_api; pub mod api; +pub mod cli; pub mod eval; pub mod export; pub mod game; @@ -94,6 +123,7 @@ use utoipa_swagger_ui::SwaggerUi; use crate::analysis::{AnalysisConfig, AnalysisManager}; use crate::api::{ApiDoc, AppState}; +use crate::cli::{CliCommand, CliContext}; use crate::game::GameManager; use crate::ws::GameBroadcaster; @@ -170,9 +200,14 @@ Features:\n\ Examples:\n\ checkai serve Start the API server on port 8080\n\ checkai serve --port 3000 Start on a custom port\n\ - checkai play Play a local terminal game\n\ + checkai play Play vs the built-in engine (level 5)\n\ + checkai play --vs human Local two-player game\n\ + checkai watch Watch an engine-vs-engine showcase\n\ + checkai analyze --fen ... Deep-dive a position with the engine\n\ + checkai bench Run the engine benchmark suite\n\ + checkai perft 5 Verify move generation vs references\n\ + checkai uci Speak UCI for chess GUIs/match runners\n\ checkai export --list List all archived games\n\ - checkai export --all Export all archived games\n\ checkai update Update to the latest version\n\ \n\ Documentation: https://github.com/JosunLP/checkai")] @@ -181,6 +216,10 @@ struct Cli { #[arg(short, long, global = true)] lang: Option, + /// Disable colored output (the NO_COLOR env var is honored too). + #[arg(long, global = true)] + no_color: bool, + #[command(subcommand)] command: Option, } @@ -248,8 +287,23 @@ Examples:\n\ analysis_completed_ttl_secs: u64, }, - /// Play a chess game in the terminal (two-player). - Play, + /// Play chess in the terminal — vs the built-in engine or a human. + Play(cli::play::PlayArgs), + + /// Watch the engine play itself (engine-vs-engine showcase). + Watch(cli::watch::WatchArgs), + + /// Analyze a position (--fen) or an entire game (--moves). + Analyze(cli::analyze::AnalyzeArgs), + + /// Run the fixed engine benchmark suite (nodes, time, NPS). + Bench(cli::bench::BenchArgs), + + /// Verify move generation with perft node counts. + Perft(cli::perft::PerftArgs), + + /// Run as a UCI engine on stdin/stdout (for chess GUIs). + Uci(cli::uci::UciArgs), /// Export archived games in various formats. #[command(after_help = "\ @@ -322,9 +376,17 @@ async fn main() -> std::io::Result<()> { // Clean up leftover .old.exe from previous updates (Windows) update::cleanup_old_binary(); + // UCI is machine-facing: plain output, no banners, no animations. + let ctx = match cli.command { + Some(Commands::Uci(_)) => CliContext { + theme: cli::theme::Theme::plain(), + }, + _ => CliContext::new(cli.no_color), + }; + match cli.command { None => { - print_welcome(); + cli::welcome::print_welcome(&ctx.theme); Ok(()) } Some(Commands::Serve { @@ -355,11 +417,19 @@ async fn main() -> std::io::Result<()> { }) .await } - Some(Commands::Play) => { - update::check_for_updates().await; - terminal::run_terminal_game(); - Ok(()) + Some(Commands::Play(args)) => { + // Only ping GitHub for updates in interactive sessions; + // piped/scripted runs stay fast and quiet. + if ctx.theme.interactive { + update::check_for_updates().await; + } + run_cli_command(args, &ctx) } + Some(Commands::Watch(args)) => run_cli_command(args, &ctx), + Some(Commands::Analyze(args)) => run_cli_command(args, &ctx), + Some(Commands::Bench(args)) => run_cli_command(args, &ctx), + Some(Commands::Perft(args)) => run_cli_command(args, &ctx), + Some(Commands::Uci(args)) => run_cli_command(args, &ctx), Some(Commands::Export { data_dir, format, @@ -388,104 +458,26 @@ async fn main() -> std::io::Result<()> { Ok(()) } Some(Commands::Version) => { - println!("checkai v{}", update::version()); + println!( + "{} {}", + "checkai".green().bold(), + format!("v{}", update::version()).bold() + ); + println!(" {} {}", t!("cli.locale_label"), &*rust_i18n::locale()); + println!( + " {} https://github.com/JosunLP/checkai", + t!("cli.docs_label") + ); Ok(()) } } } -/// Prints a branded welcome screen when no subcommand is given. -fn print_welcome() { - let version = update::version(); - let locale = rust_i18n::locale().to_string(); - - println!(); - println!( - "{}", - "╔═══════════════════════════════════════════════════╗".cyan() - ); - println!( - "{}", - "║ ║".cyan() - ); - println!( - "{}", - format!( - "║ {} v{}{}║", - t!("cli.welcome_header"), - version, - " ".repeat( - 46usize.saturating_sub(t!("cli.welcome_header").chars().count() + version.len()), - ) - ) - .cyan() - ); - println!( - "{}", - "║ ║".cyan() - ); - println!( - "{}", - "╚═══════════════════════════════════════════════════╝".cyan() - ); - println!(); - println!( - " {} {} {} {}", - "Version:".bold(), - version, - "Locale:".bold(), - locale - ); - println!(); - println!("{}", t!("cli.commands_header").to_string().yellow().bold()); - println!( - " {} {}", - "serve".green().bold(), - t!("cli.cmd_serve_desc") - ); - println!( - " {} {}", - "play".green().bold(), - t!("cli.cmd_play_desc") - ); - println!( - " {} {}", - "export".green().bold(), - t!("cli.cmd_export_desc") - ); - println!( - " {} {}", - "update".green().bold(), - t!("cli.cmd_update_desc") - ); - println!( - " {} {}", - "version".green().bold(), - t!("cli.cmd_version_desc") - ); - println!(); - println!( - "{}", - t!("cli.quickstart_header").to_string().yellow().bold() - ); - println!( - " {} {}", - "$ checkai serve".dimmed(), - t!("cli.quickstart_serve") - ); - println!( - " {} {}", - "$ checkai play".dimmed(), - t!("cli.quickstart_play") - ); - println!( - " {} {}", - "$ checkai --help".dimmed(), - t!("cli.quickstart_help") - ); - println!(); - println!(" {}", t!("cli.run_help_hint", cmd = "--help".green())); - println!(); +/// Dispatches a [`CliCommand`], converting CLI errors to exit-worthy +/// I/O errors for `main`'s signature. +fn run_cli_command(cmd: impl CliCommand, ctx: &CliContext) -> std::io::Result<()> { + cmd.run(ctx) + .map_err(|e| std::io::Error::other(e.to_string())) } /// Starts the HTTP + WebSocket server with all API routes and Swagger UI. diff --git a/src/search.rs b/src/search.rs index 0e46e7f..3e47155 100644 --- a/src/search.rs +++ b/src/search.rs @@ -2,21 +2,36 @@ //! //! Implements a full-featured chess search with: //! - Iterative deepening with aspiration windows -//! - Principal Variation Search (PVS / Negascout) -//! - Transposition table -//! - Null-move pruning -//! - Late Move Reductions (LMR) -//! - Killer move heuristic -//! - History heuristic for move ordering -//! - MVV-LVA capture ordering -//! - Quiescence search to resolve tactical positions +//! - Principal Variation Search (PVS / Negascout) with proper re-searches +//! - Transposition table with generation-based aging and depth-preferred +//! replacement, probed and updated in both the main search and quiescence +//! - Hard time/node limits enforced *inside* the tree (checked every +//! [`NODE_CHECK_INTERVAL`] nodes), with partial iterations discarded +//! - In-tree repetition detection (a single repetition along the search +//! path or against the supplied game history scores as a draw) and +//! 50-move-rule awareness with mate precedence +//! - Mate-distance pruning +//! - Reverse futility pruning (static null move) +//! - Adaptive null-move pruning with verification search at high depth +//! - Razoring and classic futility pruning at frontier nodes +//! - Late Move Reductions driven by a precomputed log-log table, adjusted +//! for PV nodes, killers and history +//! - Late Move Pruning of late quiets at shallow depth +//! - Internal Iterative Reduction when no TT move is available +//! - Check extensions (capped by ply) +//! - Killer move, counter-move, and capped history heuristics (with +//! gravity-style aging and maluses for failed quiets) +//! - MVV-LVA + Static Exchange Evaluation (SEE) capture ordering and pruning +//! - Quiescence search with stand-pat, per-capture delta pruning, SEE +//! pruning, TT integration, and check-evasion handling //! //! The search operates on a read-only snapshot of the game state and //! is fully isolated from the core engine's game loop. use std::sync::Arc; +use std::sync::LazyLock; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Instant; +use std::time::{Duration, Instant}; use crate::eval::{self, DRAW_SCORE, MATE_SCORE, MATE_THRESHOLD}; use crate::movegen; @@ -30,22 +45,36 @@ use crate::zobrist; /// Default transposition table size in MB. const DEFAULT_TT_SIZE_MB: usize = 64; -/// Null-move pruning depth reduction. -const NULL_MOVE_REDUCTION: i32 = 3; - /// Maximum search depth (hard ceiling). pub const MAX_DEPTH: i32 = 128; +/// Highest interactive skill level accepted by [`SearchLimits::for_level`]. +pub const MAX_SKILL_LEVEL: u8 = 10; + /// Infinity value for alpha-beta bounds. const INFINITY: i32 = MATE_SCORE + 1; /// Aspiration window initial width (centipawns). const ASPIRATION_WINDOW: i32 = 50; +/// How often (in nodes) the in-tree hard time/node limit is re-checked. +/// Must be a power of two; the check triggers when +/// `nodes & (NODE_CHECK_INTERVAL - 1) == 0`. +const NODE_CHECK_INTERVAL: u64 = 2048; + /// Futility pruning margins (indexed by depth remaining). -/// At depth 1 we can prune if eval + margin < alpha. +/// At depth `d` (1..=3) quiet moves are skipped when +/// `static_eval + FUTILITY_MARGINS[d] <= alpha`. const FUTILITY_MARGINS: [i32; 4] = [0, 200, 400, 600]; +/// Reverse futility pruning (static null move): at shallow non-PV nodes, +/// if `static_eval - RFP_MARGIN_PER_DEPTH * depth >= beta` the node is +/// assumed to fail high and the static eval is returned immediately. +const RFP_MARGIN_PER_DEPTH: i32 = 90; + +/// Maximum depth at which reverse futility pruning applies. +const RFP_MAX_DEPTH: i32 = 7; + /// Razoring margin: if static eval + RAZORING_MARGIN < alpha at depth 1-2, /// drop into quiescence search directly. const RAZORING_MARGIN: i32 = 300; @@ -53,6 +82,75 @@ const RAZORING_MARGIN: i32 = 300; /// Late-move pruning thresholds indexed by depth (max quiet moves to search). const LMP_THRESHOLDS: [usize; 5] = [0, 5, 8, 13, 20]; +/// Null-move pruning: base depth reduction. The effective reduction is +/// `NULL_MOVE_BASE_REDUCTION + depth / 4 + min(2, (static_eval - beta) / 200)`. +const NULL_MOVE_BASE_REDUCTION: i32 = 3; + +/// Minimum remaining depth at which a null-move fail-high is verified with +/// a reduced-depth search (zugzwang guard at high depths). +const NULL_MOVE_VERIFICATION_DEPTH: i32 = 10; + +/// Minimum depth for Internal Iterative Reduction: when no TT move is +/// available at `depth >= IIR_MIN_DEPTH`, the search depth is reduced by +/// one ply (move ordering is poor, so the subtree is cheapened; the TT +/// fills up and a later, deeper visit re-searches with a good move first). +const IIR_MIN_DEPTH: i32 = 4; + +/// Maximum depth at which losing captures (SEE < 0) are pruned outright +/// at non-PV nodes. +const SEE_PRUNE_MAX_DEPTH: i32 = 3; + +/// Quiescence delta pruning margin: a capture is skipped when even +/// `stand_pat + victim_value + QS_DELTA_MARGIN` cannot reach alpha. +const QS_DELTA_MARGIN: i32 = 200; + +/// History scores are kept within `[-HISTORY_MAX, HISTORY_MAX]` by the +/// gravity-style update formula (see [`update_history`]). +const HISTORY_MAX: i32 = 16_384; + +/// Base term of the LMR reduction formula. +const LMR_BASE: f64 = 0.75; + +/// Divisor of the `ln(depth) * ln(move_number)` term of the LMR formula. +const LMR_DIVISOR: f64 = 2.25; + +/// Precomputed Late Move Reduction table: +/// `LMR_TABLE[depth.min(63)][move_number.min(63)]` +/// = `LMR_BASE + ln(depth) * ln(move_number) / LMR_DIVISOR` (floored, >= 0). +static LMR_TABLE: LazyLock<[[u8; 64]; 64]> = LazyLock::new(|| { + let mut table = [[0u8; 64]; 64]; + for (d, row) in table.iter_mut().enumerate().skip(1) { + for (m, cell) in row.iter_mut().enumerate().skip(1) { + let r = LMR_BASE + (d as f64).ln() * (m as f64).ln() / LMR_DIVISOR; + *cell = r.max(0.0) as u8; + } + } + table +}); + +// Move-ordering score tiers (descending priority). + +/// Ordering score for the transposition-table move. +const ORDER_TT_MOVE: i32 = 10_000_000; + +/// Base ordering score for queen promotions (tried right after good captures). +const ORDER_PROMOTION: i32 = 1_100_000; + +/// Base ordering score for good captures (SEE >= 0), plus MVV-LVA. +const ORDER_GOOD_CAPTURE: i32 = 1_000_000; + +/// Ordering score for the first killer move. +const ORDER_KILLER_0: i32 = 900_000; + +/// Ordering score for the second killer move. +const ORDER_KILLER_1: i32 = 899_000; + +/// Ordering score for the counter-move of the opponent's previous move. +const ORDER_COUNTER_MOVE: i32 = 898_000; + +/// Base ordering score for losing captures (SEE < 0); tried after quiets. +const ORDER_BAD_CAPTURE: i32 = -1_000_000; + // --------------------------------------------------------------------------- // Transposition table // --------------------------------------------------------------------------- @@ -68,6 +166,9 @@ pub enum TTFlag { Beta, } +/// Sentinel marking a TT entry that carries no cached static evaluation. +pub const TT_EVAL_NONE: i32 = i32::MIN + 1; + /// A single transposition table entry. #[derive(Debug, Clone, Copy)] pub struct TTEntry { @@ -76,6 +177,10 @@ pub struct TTEntry { pub score: i32, pub flag: TTFlag, pub best_move: Option, + /// Cached static evaluation of the position ([`TT_EVAL_NONE`] if absent). + pub static_eval: i32, + /// Search generation that wrote this entry (used for aging). + pub generation: u8, } /// Compact move encoding for TT storage (4 bytes). @@ -127,9 +232,18 @@ impl EncodedMove { } /// The transposition table. +/// +/// Single-slot, power-of-two sized, with a generation counter for aging: +/// entries written by older searches are always evictable, while within +/// the current generation deeper entries are preferred (an entry is only +/// kept if it is from this generation and more than one ply deeper than +/// the incoming one). Same-key stores always update, but retain the old +/// best move when the new store has none. pub struct TranspositionTable { entries: Vec>, mask: usize, + /// Current search generation; bumped via [`TranspositionTable::new_generation`]. + generation: u8, } impl TranspositionTable { @@ -148,9 +262,18 @@ impl TranspositionTable { Self { entries: vec![None; num_entries], mask: num_entries - 1, + generation: 0, } } + /// Advances the table to a new search generation. + /// + /// Called once per search; entries from previous generations become + /// freely replaceable, implementing a cheap aging scheme. + pub fn new_generation(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + /// Probes the TT for an entry matching the given hash. pub fn probe(&self, key: u64) -> Option<&TTEntry> { let index = (key as usize) & self.mask; @@ -159,7 +282,9 @@ impl TranspositionTable { .filter(|entry| entry.key == key) } - /// Stores an entry in the TT (always-replace strategy). + /// Stores an entry without a cached static evaluation. + /// + /// Convenience wrapper around [`TranspositionTable::store_with_eval`]. pub fn store( &mut self, key: u64, @@ -167,14 +292,44 @@ impl TranspositionTable { score: i32, flag: TTFlag, best_move: Option<&ChessMove>, + ) { + self.store_with_eval(key, depth, score, flag, best_move, TT_EVAL_NONE); + } + + /// Stores an entry using the depth-preferred + aging replacement scheme. + pub fn store_with_eval( + &mut self, + key: u64, + depth: i32, + score: i32, + flag: TTFlag, + best_move: Option<&ChessMove>, + static_eval: i32, ) { let index = (key as usize) & self.mask; + let mut encoded = best_move.map(EncodedMove::from_chess_move); + + if let Some(existing) = &self.entries[index] { + if existing.key == key { + // Same position: always refresh, but never lose a known move. + if encoded.is_none() { + encoded = existing.best_move; + } + } else if existing.generation == self.generation && existing.depth > depth + 1 { + // Different position, current generation, clearly deeper: + // keep the more valuable existing entry. + return; + } + } + self.entries[index] = Some(TTEntry { key, depth, score, flag, - best_move: best_move.map(EncodedMove::from_chess_move), + best_move: encoded, + static_eval, + generation: self.generation, }); } @@ -416,6 +571,127 @@ pub struct SearchResult { pub time_ms: u64, } +// --------------------------------------------------------------------------- +// Search limits & progress reporting +// --------------------------------------------------------------------------- + +/// Resource limits for a single search invocation. +/// +/// All limits are optional except `max_depth`. The search stops as soon +/// as any limit is exceeded and returns the best result found so far. +#[derive(Debug, Clone)] +pub struct SearchLimits { + /// Maximum search depth in plies (clamped to `[1, MAX_DEPTH]`). + pub max_depth: i32, + /// Total time budget for this search, in milliseconds. + pub move_time_ms: Option, + /// Maximum number of nodes to search. + pub max_nodes: Option, +} + +impl Default for SearchLimits { + fn default() -> Self { + Self { + max_depth: MAX_DEPTH, + move_time_ms: None, + max_nodes: None, + } + } +} + +impl SearchLimits { + /// Limits for a pure fixed-depth search (no time or node budget). + pub fn depth(max_depth: i32) -> Self { + Self { + max_depth, + ..Self::default() + } + } + + /// Limits for a time-budgeted search (depth capped at `MAX_DEPTH`). + pub fn move_time(move_time_ms: u64) -> Self { + Self { + move_time_ms: Some(move_time_ms), + ..Self::default() + } + } + + /// Limits for an interactive skill level from `1` (weakest) to + /// [`MAX_SKILL_LEVEL`] (strongest). + /// + /// Higher levels grant a longer per-move time budget and a higher depth + /// ceiling; low levels are intentionally depth-capped so a casual player + /// can win. Out-of-range values are clamped. + pub fn for_level(level: u8) -> Self { + let level = i32::from(level.clamp(1, MAX_SKILL_LEVEL)); + let move_time_ms = (level * level * 30).clamp(100, 4000) as u64; + let max_depth = (level * 2 + 2).min(MAX_DEPTH); + Self { + max_depth, + move_time_ms: Some(move_time_ms), + max_nodes: None, + } + } +} + +/// Progress snapshot emitted after each completed iterative-deepening +/// iteration. Consumed by live CLI displays and the UCI `info` output. +#[derive(Debug, Clone)] +pub struct IterationInfo { + /// Completed iteration depth in plies. + pub depth: i32, + /// Score in centipawns from the side to move's perspective. + pub score_cp: i32, + /// Full moves until mate (positive: side to move mates, + /// negative: side to move gets mated). `None` if no forced mate. + pub mate_in: Option, + /// Total nodes searched so far (including quiescence nodes). + pub nodes: u64, + /// Elapsed wall-clock time since the search started, in milliseconds. + pub elapsed_ms: u64, + /// Nodes per second over the whole search so far. + pub nps: u64, + /// Principal variation in long algebraic notation (e.g. `["e2e4", "e7e5"]`). + pub pv: Vec, +} + +/// Converts a search score to a "mate in N full moves" value, if forced. +pub fn score_to_mate_in(score: i32) -> Option { + if score.abs() > MATE_THRESHOLD { + let plies = MATE_SCORE - score.abs(); + let moves = (plies + 1) / 2; + Some(if score > 0 { moves } else { -moves }) + } else { + None + } +} + +/// Converts a transposition-table-stored, ply-independent mate score back +/// into a node-local score (shifted by the current `ply`). +#[inline] +fn denormalize_mate(score: i32, ply: i32) -> i32 { + if score > MATE_THRESHOLD { + score - ply + } else if score < -MATE_THRESHOLD { + score + ply + } else { + score + } +} + +/// Converts a node-local mate score into a ply-independent score suitable +/// for transposition-table storage (the inverse of [`denormalize_mate`]). +#[inline] +fn normalize_mate(score: i32, ply: i32) -> i32 { + if score > MATE_THRESHOLD { + score + ply + } else if score < -MATE_THRESHOLD { + score - ply + } else { + score + } +} + // --------------------------------------------------------------------------- // Move ordering // --------------------------------------------------------------------------- @@ -446,15 +722,18 @@ fn piece_value(kind: PieceKind) -> i32 { /// Orders moves for optimal alpha-beta pruning. /// -/// Priority: -/// 1. TT best move (score = 10_000_000) -/// 2. Captures ordered by MVV-LVA (score = 1_000_000 + mvv_lva) -/// 3. Killer moves (score = 900_000 / 899_000) -/// 4. Counter-move heuristic (score = 898_000) -/// 5. Quiet moves by history heuristic +/// Priority (descending): +/// 1. TT best move ([`ORDER_TT_MOVE`]) +/// 2. Queen promotions ([`ORDER_PROMOTION`] + MVV-LVA) +/// 3. Good captures, SEE >= 0 ([`ORDER_GOOD_CAPTURE`] + MVV-LVA) +/// 4. Killer moves ([`ORDER_KILLER_0`] / [`ORDER_KILLER_1`]) +/// 5. Counter-move of the opponent's previous move ([`ORDER_COUNTER_MOVE`]) +/// 6. Quiet moves by history score (`[-HISTORY_MAX, HISTORY_MAX]`) +/// 7. Losing captures, SEE < 0 ([`ORDER_BAD_CAPTURE`] + MVV-LVA) fn score_moves( moves: &[ChessMove], board: &Board, + turn: Color, tt_move: Option<&ChessMove>, killers: &[Option; 2], counter_move: Option<&ChessMove>, @@ -463,19 +742,26 @@ fn score_moves( moves .iter() .map(|mv| { + let is_capture = board.get(mv.to).is_some() || mv.is_en_passant; let score = if tt_move.is_some_and(|tm| tm == mv) { - 10_000_000 - } else if board.get(mv.to).is_some() || mv.is_en_passant { - // Capture - 1_000_000 + mvv_lva_score(board, mv) + ORDER_TT_MOVE + } else if mv.promotion == Some(PieceKind::Queen) { + ORDER_PROMOTION + mvv_lva_score(board, mv) + } else if is_capture { + if see(board, mv, turn) >= 0 { + ORDER_GOOD_CAPTURE + mvv_lva_score(board, mv) + } else { + ORDER_BAD_CAPTURE + mvv_lva_score(board, mv) + } } else if killers[0].as_ref().is_some_and(|k| k == mv) { - 900_000 + ORDER_KILLER_0 } else if killers[1].as_ref().is_some_and(|k| k == mv) { - 899_000 + ORDER_KILLER_1 } else if counter_move.is_some_and(|cm| cm == mv) { - 898_000 + ORDER_COUNTER_MOVE } else { - // History heuristic + // History heuristic (always within +-HISTORY_MAX, far below + // the killer tier and above the bad-capture tier). history[mv.from.index()][mv.to.index()] }; (*mv, score) @@ -483,6 +769,15 @@ fn score_moves( .collect() } +/// Applies a gravity-style history update. +/// +/// `h += bonus - |bonus| * h / HISTORY_MAX` keeps the score within +/// `[-HISTORY_MAX, HISTORY_MAX]` without explicit clamping and makes +/// saturated scores decay naturally (recent results outweigh stale ones). +fn update_history(entry: &mut i32, bonus: i32) { + *entry += bonus - bonus.abs() * *entry / HISTORY_MAX; +} + /// Sort scored moves in descending order. fn sort_moves(scored: &mut [(ChessMove, i32)]) { scored.sort_unstable_by_key(|m| std::cmp::Reverse(m.1)); @@ -505,6 +800,20 @@ pub struct SearchEngine { pub stats: SearchStats, /// Cancellation flag — set to `true` to abort the search. pub abort: Arc, + /// Hard wall-clock deadline for the current search (in-tree time limit). + deadline: Option, + /// Hard node budget for the current search (in-tree node limit). + node_limit: Option, + /// Set once a hard limit is hit; makes every node bail out immediately. + stopped: bool, + /// Zobrist hashes of the ancestors along the current search line, indexed + /// by ply (`path[k]` is the position at ply `k`). Used to detect + /// draw-by-repetition inside the tree. + path: Vec, + /// Zobrist hashes of the positions that preceded the search root in the + /// actual game (set via [`SearchEngine::set_game_history`]). Lets the + /// engine see repetitions that span the move already played on the board. + game_history: Vec, } impl SearchEngine { @@ -517,6 +826,11 @@ impl SearchEngine { counter_moves: [[None; 64]; 64], stats: SearchStats::default(), abort: Arc::new(AtomicBool::new(false)), + deadline: None, + node_limit: None, + stopped: false, + path: vec![0u64; MAX_DEPTH as usize], + game_history: Vec::new(), } } @@ -525,6 +839,16 @@ impl SearchEngine { Self::new(DEFAULT_TT_SIZE_MB) } + /// Supplies the position hashes that precede the search root in the real + /// game, so the search can score a line that repeats one of them as a + /// draw. Pass the Zobrist hashes (see [`zobrist::hash_position`]) of every + /// earlier position, in game order. Replaces any previously set history; + /// pass an empty slice to clear it. + pub fn set_game_history(&mut self, history: &[u64]) { + self.game_history.clear(); + self.game_history.extend_from_slice(history); + } + /// Replaces the internal abort flag with a shared external token. /// /// This allows external orchestration (e.g. analysis job cancellation) @@ -538,13 +862,98 @@ impl SearchEngine { self.abort.store(false, Ordering::Relaxed); } + /// Returns `true` if the search should stop — either an external abort + /// token fired or an internal hard limit has already been tripped. + #[inline] + fn should_stop(&self) -> bool { + self.stopped || self.abort.load(Ordering::Relaxed) + } + + /// Checks the wall-clock and node hard limits. Called periodically from + /// inside the tree (every [`NODE_CHECK_INTERVAL`] nodes) so the relatively + /// expensive [`Instant::now`] call stays off the hot path. + #[inline] + fn hit_hard_limit(&self) -> bool { + if let Some(deadline) = self.deadline + && Instant::now() >= deadline + { + return true; + } + if let Some(limit) = self.node_limit + && self.stats.nodes >= limit + { + return true; + } + false + } + + /// Returns `true` if the position with the given `hash` has already + /// occurred — either earlier on the current search path or in the + /// pre-search game history. Only positions within the current + /// reversible-move window (`halfmove_clock` plies back) can repeat, so the + /// scan stops there. A single match is treated as a draw. + fn is_repetition(&self, hash: u64, halfmove_clock: u32, ply: i32) -> bool { + let window = halfmove_clock as usize; + let mut scanned = 0usize; + + // In-tree ancestors, most recent first: path[ply-1] down to path[0]. + let mut idx = ply as usize; + while idx > 0 && scanned < window { + idx -= 1; + scanned += 1; + if self.path[idx] == hash { + return true; + } + } + + // Continue into the real game's history (most recent first). + for &h in self.game_history.iter().rev() { + if scanned >= window { + break; + } + scanned += 1; + if h == hash { + return true; + } + } + + false + } + /// Runs iterative deepening search to the specified depth. /// /// Returns the best move and evaluation at the target depth. + /// Convenience wrapper around [`SearchEngine::search_limited`] with a + /// pure depth limit and no progress reporting. pub fn search(&mut self, pos: &SearchPosition, max_depth: i32) -> SearchResult { - let max_depth = max_depth.clamp(1, MAX_DEPTH); + self.search_limited(pos, &SearchLimits::depth(max_depth), None) + } + + /// Runs iterative deepening search under the given [`SearchLimits`]. + /// + /// The search stops when the depth, time, or node budget is exhausted + /// (or the abort token fires) and returns the best result found so far. + /// After each completed iteration, `on_iteration` is invoked with a + /// progress snapshot — used for live CLI displays and UCI `info` lines. + pub fn search_limited( + &mut self, + pos: &SearchPosition, + limits: &SearchLimits, + mut on_iteration: Option<&mut dyn FnMut(&IterationInfo)>, + ) -> SearchResult { + let max_depth = limits.max_depth.clamp(1, MAX_DEPTH); let start = Instant::now(); self.stats = SearchStats::default(); + self.stopped = false; + self.deadline = limits + .move_time_ms + .map(|ms| start + Duration::from_millis(ms)); + self.node_limit = limits.max_nodes; + + // Advance the TT generation so entries from previous searches become + // preferred replacement candidates (depth-preferred within the current + // generation, age-out across generations). + self.tt.new_generation(); // Clear killer and history tables for k in &mut self.killers { @@ -565,14 +974,31 @@ impl SearchEngine { // Iterative deepening for depth in 1..=max_depth { - if self.abort.load(Ordering::Relaxed) { + if self.should_stop() { + break; + } + + // Stop before starting an iteration that would bust the budget: + // a hard time/node check plus a soft heuristic — if more than + // half the time budget is gone, the next (deeper) iteration is + // unlikely to finish in the remaining half. + if let Some(budget_ms) = limits.move_time_ms { + let elapsed_ms = start.elapsed().as_millis() as u64; + if depth > 1 && (elapsed_ms >= budget_ms || elapsed_ms * 2 >= budget_ms) { + break; + } + } + if let Some(max_nodes) = limits.max_nodes + && depth > 1 + && self.stats.nodes >= max_nodes + { break; } let score; if depth <= 4 || best_score.abs() > MATE_THRESHOLD { // Simple window for shallow depths or near-mate scores - score = self.alpha_beta(pos, depth, -INFINITY, INFINITY, 0, true); + score = self.alpha_beta(pos, depth, -INFINITY, INFINITY, 0, true, None); } else { // Aspiration windows for deeper searches let mut delta = ASPIRATION_WINDOW; @@ -581,8 +1007,8 @@ impl SearchEngine { let mut found_score = None; loop { - let s = self.alpha_beta(pos, depth, alpha, beta, 0, true); - if self.abort.load(Ordering::Relaxed) { + let s = self.alpha_beta(pos, depth, alpha, beta, 0, true, None); + if self.should_stop() { break; } if s <= alpha { @@ -598,7 +1024,7 @@ impl SearchEngine { if delta > 2000 { // Fallback to full window found_score = - Some(self.alpha_beta(pos, depth, -INFINITY, INFINITY, 0, true)); + Some(self.alpha_beta(pos, depth, -INFINITY, INFINITY, 0, true, None)); break; } } @@ -611,7 +1037,7 @@ impl SearchEngine { }); } - if self.abort.load(Ordering::Relaxed) { + if self.should_stop() { break; } @@ -625,6 +1051,22 @@ impl SearchEngine { best_pv = pv; } + // Report progress to the caller (live CLI display, UCI info) + if let Some(cb) = on_iteration.as_mut() { + let elapsed_ms = start.elapsed().as_millis() as u64; + let scaled_nodes = self.stats.nodes * 1000; + let nps = scaled_nodes.checked_div(elapsed_ms).unwrap_or(scaled_nodes); + cb(&IterationInfo { + depth, + score_cp: best_score, + mate_in: score_to_mate_in(best_score), + nodes: self.stats.nodes, + elapsed_ms, + nps, + pv: best_pv.iter().map(|m| m.to_string()).collect(), + }); + } + log::trace!( "depth {} score {} pv {} nodes {} time {}ms", depth, @@ -652,126 +1094,172 @@ impl SearchEngine { } /// Principal Variation Search (alpha-beta with PVS enhancements). + /// + /// `prev_move` is the opponent's move that led to `pos` (used by the + /// counter-move heuristic); it is `None` at the root and after a null move. + #[allow(clippy::too_many_arguments)] fn alpha_beta( &mut self, pos: &SearchPosition, - depth: i32, + mut depth: i32, mut alpha: i32, - beta: i32, + mut beta: i32, ply: i32, is_pv: bool, + prev_move: Option, ) -> i32 { - // Check for cancellation - if self.abort.load(Ordering::Relaxed) { + // Prompt cancellation: external abort token or an internal hard stop. + if self.should_stop() { return 0; } self.stats.nodes += 1; - // Hard ply ceiling to prevent out-of-bounds access on killers table + // Periodic hard time/node-limit check, kept off the per-node hot path. + if self.stats.nodes & (NODE_CHECK_INTERVAL - 1) == 0 && self.hit_hard_limit() { + self.stopped = true; + return 0; + } + + // Hard ply ceiling to prevent out-of-bounds access on the killer table. if ply >= MAX_DEPTH { return eval::evaluate(&pos.board, pos.turn); } - // Depth exhausted → quiescence search + // Depth exhausted → quiescence search. if depth <= 0 { return self.quiescence(pos, alpha, beta, ply); } - // Draw detection: 50-move rule check + // Draw detection: 50-move rule check. if pos.halfmove_clock >= 100 { return DRAW_SCORE; } - // Probe transposition table - let tt_move: Option; + // Record this node on the current search line, then test for a draw by + // repetition against earlier nodes on the path and the pre-search game + // history. A single recurrence is enough — the side to move can usually + // force the threefold — so this lets the engine find saving perpetual + // checks and avoid repeating away a winning position. + self.path[ply as usize] = pos.hash; + if ply > 0 && self.is_repetition(pos.hash, pos.halfmove_clock, ply) { + return DRAW_SCORE; + } + + // Mate-distance pruning: tighten the window to the best/worst mate that + // is still reachable from this ply. Cuts off lines that cannot improve + // on an already-found mate and shortens proven mating sequences. + alpha = alpha.max(-MATE_SCORE + ply); + beta = beta.min(MATE_SCORE - ply - 1); + if alpha >= beta { + return alpha; + } + + // Probe the transposition table. + let mut tt_move: Option = None; + let mut tt_eval = TT_EVAL_NONE; if let Some(entry) = self.tt.probe(pos.hash) { self.stats.tt_hits += 1; tt_move = entry.best_move.map(|em| em.to_chess_move()); + tt_eval = entry.static_eval; if !is_pv && entry.depth >= depth { - // Denormalize mate scores: table stores ply-independent - // distance-to-mate; shift by current ply to get node-local score. - let tt_score = if entry.score > MATE_THRESHOLD { - entry.score - ply - } else if entry.score < -MATE_THRESHOLD { - entry.score + ply - } else { - entry.score - }; + let tt_score = denormalize_mate(entry.score, ply); match entry.flag { TTFlag::Exact => { self.stats.tt_cutoffs += 1; return tt_score; } - TTFlag::Beta => { - if tt_score >= beta { - self.stats.tt_cutoffs += 1; - return tt_score; - } + TTFlag::Beta if tt_score >= beta => { + self.stats.tt_cutoffs += 1; + return tt_score; } - TTFlag::Alpha => { - if tt_score <= alpha { - self.stats.tt_cutoffs += 1; - return tt_score; - } + TTFlag::Alpha if tt_score <= alpha => { + self.stats.tt_cutoffs += 1; + return tt_score; } + _ => {} } } - } else { - tt_move = None; } let in_check = pos.is_in_check(); - // Null-move pruning - // Conditions: not in check, not PV, depth >= 3, has non-pawn material - if !in_check && !is_pv && depth >= 3 && has_non_pawn_material(pos) { - let null_pos = pos.make_null_move(); - let null_score = -self.alpha_beta( - &null_pos, - depth - 1 - NULL_MOVE_REDUCTION, - -beta, - -beta + 1, - ply + 1, - false, - ); - if null_score >= beta { - self.stats.null_cutoffs += 1; - return beta; + // Static evaluation, reused by several pruning heuristics. It carries + // no meaning while in check (no "stand pat" option), so we sentinel it. + let static_eval = if in_check { + -INFINITY + } else if tt_eval != TT_EVAL_NONE { + tt_eval + } else { + eval::evaluate(&pos.board, pos.turn) + }; + + // Whole-node pruning, only at non-PV nodes outside of check and clear + // of mate scores. + if !is_pv && !in_check && beta.abs() < MATE_THRESHOLD { + // Reverse futility pruning (static null move): a position far + // enough above beta is assumed to hold up under a real search. + if depth <= RFP_MAX_DEPTH && static_eval - RFP_MARGIN_PER_DEPTH * depth >= beta { + return static_eval; + } + + // Razoring: a position far below alpha at shallow depth is verified + // by a quiescence search and returned if it confirms the failure. + if depth <= 2 && static_eval + RAZORING_MARGIN <= alpha { + let qscore = self.quiescence(pos, alpha, beta, ply); + if qscore <= alpha { + return qscore; + } + } + + // Adaptive null-move pruning with a high-depth verification search. + if depth >= 3 && static_eval >= beta && has_non_pawn_material(pos) { + let r = NULL_MOVE_BASE_REDUCTION + depth / 4 + ((static_eval - beta) / 200).min(2); + let null_pos = pos.make_null_move(); + let null_score = -self.alpha_beta( + &null_pos, + depth - 1 - r, + -beta, + -beta + 1, + ply + 1, + false, + None, + ); + if null_score >= beta { + self.stats.null_cutoffs += 1; + if depth < NULL_MOVE_VERIFICATION_DEPTH { + // Trust the cutoff, but never return an unproven mate. + return if null_score >= MATE_THRESHOLD { + beta + } else { + null_score + }; + } + // Zugzwang guard: verify with a reduced-depth real search. + let verify = + self.alpha_beta(pos, depth - r, beta - 1, beta, ply, false, prev_move); + if verify >= beta { + return verify; + } + } } } - // Futility pruning: if the static eval is far below alpha at low depths, - // we can skip quiet moves (captures are always searched). - let static_eval = eval::evaluate(&pos.board, pos.turn); + // Internal Iterative Reduction: with no TT move to order on, the subtree + // is cheapened by one ply; a later, deeper visit re-searches it with a + // good move first once the TT is populated. + if tt_move.is_none() && depth >= IIR_MIN_DEPTH { + depth -= 1; + } + + // Quiet-move futility flag for the frontier (captures/checks always run). let futile = !in_check && !is_pv && (1..=3).contains(&depth) && static_eval + FUTILITY_MARGINS[depth as usize] <= alpha && alpha.abs() < MATE_THRESHOLD; - // Razoring: at shallow depths, if static eval is far below alpha, - // verify with quiescence search and return if it confirms. - if !is_pv && !in_check && depth <= 2 && static_eval + RAZORING_MARGIN <= alpha { - let qscore = self.quiescence(pos, alpha, beta, ply); - if qscore <= alpha { - return qscore; - } - } - - // Internal Iterative Deepening (IID): at PV nodes with no TT move, - // run a shallow search to find a move for ordering. - let tt_move = if tt_move.is_none() && is_pv && depth >= 4 { - let iid_depth = depth - 2; - self.alpha_beta(pos, iid_depth, alpha, beta, ply, true); - self.tt - .probe(pos.hash) - .and_then(|e| e.best_move.map(|em| em.to_chess_move())) - } else { - tt_move - }; - // Generate and order moves let moves = pos.legal_moves(); @@ -786,25 +1274,16 @@ impl SearchEngine { } } - let killers = &self.killers[ply as usize]; - // Look up counter-move based on the *previous* move's from/to - // (previous ply's move is the opponent's last move — approximated - // by checking the TT entry of the parent if available; we use - // the board state change instead for simplicity). - let counter = if ply > 0 { - // Use parent position data — we don't have it directly, so - // check the counter-move table entry for the last move applied. - // This is approximated by storing the counter on beta cutoffs below. - None // filled at cutoff site - } else { - None - }; + let killers = self.killers[ply as usize]; + // Counter-move: the best known reply to the opponent's previous move. + let counter = prev_move.and_then(|pm| self.counter_moves[pm.from.index()][pm.to.index()]); let mut scored = score_moves( &moves, &pos.board, + pos.turn, tt_move.as_ref(), - killers, - counter, + &killers, + counter.as_ref(), &self.history, ); sort_moves(&mut scored); @@ -813,92 +1292,115 @@ impl SearchEngine { let mut best_move: Option = None; let mut flag = TTFlag::Alpha; let mut quiet_moves_searched = 0usize; + // Quiet moves tried before a cutoff, penalised via the history malus. + let mut tried_quiets: Vec = Vec::new(); for (i, &(mv, _)) in scored.iter().enumerate() { - let child = pos.make_move(&mv); let is_capture = pos.board.get(mv.to).is_some() || mv.is_en_passant; + let is_quiet = !is_capture && mv.promotion.is_none(); + let child = pos.make_move(&mv); let gives_check = child.is_in_check(); - // Futility pruning: skip quiet moves at frontier/pre-frontier nodes - if futile && !is_capture && mv.promotion.is_none() && !gives_check && i > 0 { + // Frontier futility pruning of quiet, non-checking moves. + if futile && is_quiet && !gives_check && i > 0 { continue; } - // Late Move Pruning: at low depths, skip late quiet moves entirely + // Late Move Pruning: at low depths, skip late quiet moves entirely. if !is_pv && !in_check - && !is_capture + && is_quiet && !gives_check - && mv.promotion.is_none() && (1..=4).contains(&depth) && quiet_moves_searched >= LMP_THRESHOLDS[depth as usize] { continue; } - if !is_capture { - quiet_moves_searched += 1; - } - - // SEE pruning: skip bad captures (losing exchanges) at low depth - if depth <= 3 + // SEE pruning: skip losing captures at shallow non-PV nodes. + if depth <= SEE_PRUNE_MAX_DEPTH && !is_pv && is_capture - && !see_capture_is_good(&pos.board, mv.from, mv.to) && i > 0 + && see(&pos.board, &mv, pos.turn) < 0 { continue; } - let mut score; + if is_quiet { + quiet_moves_searched += 1; + tried_quiets.push(mv); + } - // Check extension: extend search by 1 ply if the move gives check - let extension = if gives_check { 1 } else { 0 }; + // Check extension: extend by one ply if the move gives check. + let extension = i32::from(gives_check); + let new_depth = depth - 1 + extension; + let mut score; if i == 0 { - // First move: search with full window + // First move: full-window PV search. score = - -self.alpha_beta(&child, depth - 1 + extension, -beta, -alpha, ply + 1, is_pv); + -self.alpha_beta(&child, new_depth, -beta, -alpha, ply + 1, is_pv, Some(mv)); } else { - // Late Move Reductions + // Late Move Reductions via the precomputed log-log table. let mut reduction = 0; - if depth >= 3 - && !in_check - && !is_capture - && mv.promotion.is_none() - && !gives_check - && i >= 4 - { - // LMR: reduce depth for late, non-tactical moves - reduction = 1 + (i as i32 / 8); - reduction = reduction.min(depth - 1); - self.stats.lmr_searches += 1; + if depth >= 3 && is_quiet && !gives_check && !in_check && i >= 2 { + let d = depth.min(63) as usize; + let m = i.min(63); + let mut r = LMR_TABLE[d][m] as i32; + if is_pv { + r -= 1; + } + if killers[0] == Some(mv) || killers[1] == Some(mv) || counter == Some(mv) { + r -= 1; + } + // Ease the reduction for moves with a strong history score. + r -= (self.history[mv.from.index()][mv.to.index()] / 4096).clamp(-2, 2); + reduction = r.clamp(0, new_depth - 1); + if reduction > 0 { + self.stats.lmr_searches += 1; + } } - // Zero-window search (PVS) + // Reduced zero-window search. score = -self.alpha_beta( &child, - depth - 1 - reduction + extension, + new_depth - reduction, -alpha - 1, -alpha, ply + 1, false, + Some(mv), ); - // Re-search with full window if ZWS failed high - if score > alpha && (reduction > 0 || !is_pv) { + // A reduced move that beat alpha is re-searched at full depth. + if score > alpha && reduction > 0 { score = -self.alpha_beta( &child, - depth - 1 + extension, + new_depth, + -alpha - 1, + -alpha, + ply + 1, + false, + Some(mv), + ); + } + + // Genuine PV moves are re-searched with the full window. + if score > alpha && score < beta { + score = -self.alpha_beta( + &child, + new_depth, -beta, -alpha, ply + 1, is_pv, + Some(mv), ); } } - if self.abort.load(Ordering::Relaxed) { + if self.should_stop() { return 0; } @@ -911,26 +1413,34 @@ impl SearchEngine { flag = TTFlag::Exact; if score >= beta { - // Beta cutoff + // Beta cutoff. self.stats.beta_cutoffs += 1; flag = TTFlag::Beta; - // Update killer moves (non-captures only) - if !is_capture { - let ply_idx = ply as usize; - if self.killers[ply_idx][0] != Some(mv) { - self.killers[ply_idx][1] = self.killers[ply_idx][0]; - self.killers[ply_idx][0] = Some(mv); + // Reward a quiet cutoff move, penalise the quiets that + // failed before it, and refresh the move-ordering tables. + if is_quiet { + let bonus = (depth * depth).min(HISTORY_MAX); + update_history( + &mut self.history[mv.from.index()][mv.to.index()], + bonus, + ); + for &q in &tried_quiets { + if q != mv { + update_history( + &mut self.history[q.from.index()][q.to.index()], + -bonus, + ); + } } - // Update history heuristic - self.history[mv.from.index()][mv.to.index()] += depth * depth; - - // Update counter-move table: record this move as - // a good reply to the previous move (if any). - if let Some(prev_mv) = best_move { - self.counter_moves[prev_mv.from.index()][prev_mv.to.index()] = - Some(mv); + let kp = ply as usize; + if self.killers[kp][0] != Some(mv) { + self.killers[kp][1] = self.killers[kp][0]; + self.killers[kp][0] = Some(mv); + } + if let Some(pm) = prev_move { + self.counter_moves[pm.from.index()][pm.to.index()] = Some(mv); } } @@ -940,78 +1450,164 @@ impl SearchEngine { } } - // Store in TT — normalize mate scores to be ply-independent - // (relative to the node being stored, not the root). - let tt_score = if best_score > MATE_THRESHOLD { - best_score + ply - } else if best_score < -MATE_THRESHOLD { - best_score - ply - } else { - best_score - }; - self.tt - .store(pos.hash, depth, tt_score, flag, best_move.as_ref()); + // Store the result, normalising mate scores to be ply-independent and + // caching the static eval for reuse by parent pruning heuristics. + self.tt.store_with_eval( + pos.hash, + depth, + normalize_mate(best_score, ply), + flag, + best_move.as_ref(), + if in_check { TT_EVAL_NONE } else { static_eval }, + ); best_score } - /// Quiescence search: only searches captures to resolve tactical positions. - #[allow(clippy::only_used_in_recursion)] + /// Quiescence search: resolves tactical sequences (captures, promotions, + /// and — when in check — evasions) so the static evaluation is only + /// trusted in quiet positions. Uses stand-pat, per-capture delta pruning, + /// SEE pruning of losing captures, and a transposition-table cutoff/store. fn quiescence(&mut self, pos: &SearchPosition, mut alpha: i32, beta: i32, ply: i32) -> i32 { - if self.abort.load(Ordering::Relaxed) { + if self.should_stop() { return 0; } + self.stats.nodes += 1; self.stats.quiescence_nodes += 1; - // Stand pat: static evaluation - let stand_pat = eval::evaluate(&pos.board, pos.turn); + if self.stats.nodes & (NODE_CHECK_INTERVAL - 1) == 0 && self.hit_hard_limit() { + self.stopped = true; + return 0; + } - if stand_pat >= beta { - return beta; + if ply >= MAX_DEPTH { + return eval::evaluate(&pos.board, pos.turn); } - if stand_pat > alpha { - alpha = stand_pat; + + let alpha_orig = alpha; + + // Transposition-table probe (early cutoff + move-ordering hint). + let mut tt_move: Option = None; + if let Some(entry) = self.tt.probe(pos.hash) { + self.stats.tt_hits += 1; + tt_move = entry.best_move.map(|em| em.to_chess_move()); + let tt_score = denormalize_mate(entry.score, ply); + match entry.flag { + TTFlag::Exact => return tt_score, + TTFlag::Beta if tt_score >= beta => return tt_score, + TTFlag::Alpha if tt_score <= alpha => return tt_score, + _ => {} + } } - // Delta pruning: if even a queen capture can't beat alpha, skip - if stand_pat + 1025 < alpha { - return alpha; + let in_check = pos.is_in_check(); + + // Stand pat — only when not in check (a checked side must reply). + let stand_pat = if in_check { + -INFINITY + } else { + let e = eval::evaluate(&pos.board, pos.turn); + if e >= beta { + return e; + } + if e > alpha { + alpha = e; + } + e + }; + + let moves = pos.legal_moves(); + if moves.is_empty() { + // No legal moves: checkmate while in check, otherwise stalemate. + return if in_check { + -MATE_SCORE + ply + } else { + DRAW_SCORE + }; } - // Generate all legal moves, then filter to captures - let all_moves = pos.legal_moves(); - let captures: Vec = all_moves + // In check: search every evasion. Otherwise: captures & promotions only. + let mut candidates: Vec = moves .into_iter() .filter(|mv| { - pos.board.get(mv.to).is_some() || mv.is_en_passant || mv.promotion.is_some() + in_check + || pos.board.get(mv.to).is_some() + || mv.is_en_passant + || mv.promotion.is_some() }) .collect(); + if candidates.is_empty() { + return alpha; + } - // Order captures by MVV-LVA - let mut scored: Vec<(ChessMove, i32)> = captures - .iter() - .map(|mv| (*mv, mvv_lva_score(&pos.board, mv))) - .collect(); - scored.sort_unstable_by_key(|m| std::cmp::Reverse(m.1)); + // Order by MVV-LVA, with the TT move tried first. + candidates.sort_unstable_by_key(|mv| { + let tt_bonus = if Some(*mv) == tt_move { 1 << 20 } else { 0 }; + std::cmp::Reverse(tt_bonus + mvv_lva_score(&pos.board, mv)) + }); + + let mut best_score = stand_pat; + let mut best_move: Option = None; + + for mv in candidates { + let is_capture = pos.board.get(mv.to).is_some() || mv.is_en_passant; + + // Pruning is only safe when not evading check. + if !in_check && is_capture { + // Delta pruning: skip captures that cannot raise alpha. + if mv.promotion.is_none() { + let victim = pos + .board + .get(mv.to) + .map(|p| see_piece_value(p.kind)) + .unwrap_or_else(|| see_piece_value(PieceKind::Pawn)); + if stand_pat + victim + QS_DELTA_MARGIN < alpha { + continue; + } + } + // SEE pruning: skip losing captures outright. + if see(&pos.board, &mv, pos.turn) < 0 { + continue; + } + } - for (mv, _) in scored { let child = pos.make_move(&mv); let score = -self.quiescence(&child, -beta, -alpha, ply + 1); - if self.abort.load(Ordering::Relaxed) { + if self.should_stop() { return 0; } - if score >= beta { - return beta; - } - if score > alpha { - alpha = score; + if score > best_score { + best_score = score; + best_move = Some(mv); + if score > alpha { + alpha = score; + if score >= beta { + break; + } + } } } - alpha + // Store the quiescence result at depth 0 for later reuse. + let flag = if best_score >= beta { + TTFlag::Beta + } else if best_score > alpha_orig { + TTFlag::Exact + } else { + TTFlag::Alpha + }; + self.tt.store( + pos.hash, + 0, + normalize_mate(best_score, ply), + flag, + best_move.as_ref(), + ); + + best_score } /// Extracts the principal variation from the transposition table. @@ -1071,7 +1667,7 @@ fn has_non_pawn_material(pos: &SearchPosition) -> bool { false } -/// SEE piece values for exchange evaluation. +/// SEE piece values for exchange evaluation (P, N, B, R, Q, K). const SEE_VALUES: [i32; 6] = [100, 325, 335, 500, 975, 20000]; /// Returns the SEE value of a piece kind. @@ -1086,37 +1682,195 @@ fn see_piece_value(kind: PieceKind) -> i32 { } } -/// Static Exchange Evaluation — determines if a capture is likely good. +/// Sliding directions used by the SEE attacker scan. +const SEE_DIAGONAL_DIRS: [(i8, i8); 4] = [(-1, -1), (-1, 1), (1, -1), (1, 1)]; +const SEE_STRAIGHT_DIRS: [(i8, i8); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)]; + +/// Finds the least valuable piece of `side` that attacks `target`, given +/// the current occupancy mask `occ` (bit = square index). Pieces removed +/// from `occ` are treated as gone, which naturally exposes x-ray attackers +/// behind them on the same ray. +/// +/// Returns the attacker's square and kind, or `None`. +fn see_least_valuable_attacker( + board: &Board, + occ: u64, + target: Square, + side: Color, +) -> Option<(Square, PieceKind)> { + let occupied = |sq: Square| occ & (1u64 << sq.index()) != 0; + let piece_at = |sq: Square, kind: PieceKind| { + occupied(sq) + && board + .get(sq) + .is_some_and(|p| p.color == side && p.kind == kind) + }; + + // Pawns (a pawn of `side` on (file +- 1, rank - pawn_direction) attacks target). + let dr = -side.pawn_direction(); + for df in [-1i8, 1i8] { + if let Some(sq) = target.offset(df, dr) + && piece_at(sq, PieceKind::Pawn) + { + return Some((sq, PieceKind::Pawn)); + } + } + + // Knights. + const KNIGHT_OFFSETS: [(i8, i8); 8] = [ + (-2, -1), + (-2, 1), + (-1, -2), + (-1, 2), + (1, -2), + (1, 2), + (2, -1), + (2, 1), + ]; + for &(df, dr) in &KNIGHT_OFFSETS { + if let Some(sq) = target.offset(df, dr) + && piece_at(sq, PieceKind::Knight) + { + return Some((sq, PieceKind::Knight)); + } + } + + // First blocker on a ray from `target` in direction `(df, dr)`. + let first_blocker = |df: i8, dr: i8| -> Option { + let mut cur = target; + while let Some(next) = cur.offset(df, dr) { + if occupied(next) { + return Some(next); + } + cur = next; + } + None + }; + + // Bishops (diagonal rays). + for &(df, dr) in &SEE_DIAGONAL_DIRS { + if let Some(sq) = first_blocker(df, dr) + && piece_at(sq, PieceKind::Bishop) + { + return Some((sq, PieceKind::Bishop)); + } + } + + // Rooks (straight rays). + for &(df, dr) in &SEE_STRAIGHT_DIRS { + if let Some(sq) = first_blocker(df, dr) + && piece_at(sq, PieceKind::Rook) + { + return Some((sq, PieceKind::Rook)); + } + } + + // Queens (any ray). + for &(df, dr) in SEE_DIAGONAL_DIRS.iter().chain(SEE_STRAIGHT_DIRS.iter()) { + if let Some(sq) = first_blocker(df, dr) + && piece_at(sq, PieceKind::Queen) + { + return Some((sq, PieceKind::Queen)); + } + } + + // King (adjacent squares). + for dr in -1i8..=1 { + for df in -1i8..=1 { + if df == 0 && dr == 0 { + continue; + } + if let Some(sq) = target.offset(df, dr) + && piece_at(sq, PieceKind::King) + { + return Some((sq, PieceKind::King)); + } + } + } + + None +} + +/// Static Exchange Evaluation: returns the expected material gain (in SEE +/// centipawns, side-to-move perspective) of playing the capture `mv`, +/// assuming both sides keep recapturing with their least valuable attacker +/// and may stand pat at any point (classic swap algorithm). /// -/// Returns `true` if the capture on `to` by the piece on `from` is -/// estimated to win material (SEE >= 0). -fn see_capture_is_good(board: &Board, from: Square, to: Square) -> bool { - let attacker = match board.get(from) { - Some(p) => p, - None => return false, +/// X-rays are handled by removing used attackers from the occupancy mask +/// and rescanning rays. King "captures into check" resolve correctly via +/// the huge king value combined with the stand-pat minimax unwinding. +/// Promotions are not modelled (the move scorer ranks them separately). +fn see(board: &Board, mv: &ChessMove, side: Color) -> i32 { + let target = mv.to; + let Some(first_attacker) = board.get(mv.from) else { + return 0; }; - let victim_value = match board.get(to) { - Some(p) => see_piece_value(p.kind), - None => 0, // en passant or non-capture — treat as 0 + // Build the occupancy mask. + let mut occ = 0u64; + for rank in 0..8u8 { + for file in 0..8u8 { + let sq = Square::new(file, rank); + if board.get(sq).is_some() { + occ |= 1u64 << sq.index(); + } + } + } + + // Victim of the first capture. + let first_victim = if mv.is_en_passant { + // Remove the en-passant-captured pawn from its actual square. + let captured_rank = (mv.to.rank as i8 - side.pawn_direction()) as u8; + occ &= !(1u64 << Square::new(mv.to.file, captured_rank).index()); + see_piece_value(PieceKind::Pawn) + } else { + match board.get(target) { + Some(p) => see_piece_value(p.kind), + None => 0, + } }; - // If capturing with a less valuable piece than the victim, it's always good - let attacker_value = see_piece_value(attacker.kind); - if attacker_value <= victim_value { - return true; - } - - // Simple heuristic: if we're trading a major piece for a much less valuable - // one, check if there might be defenders. For simplicity we use the - // "optimistic" approach: the exchange is good if after the initial capture, - // the attacker's value minus the victim value doesn't result in a large loss. - // A full SEE would simulate all exchanges, but this fast approximation - // catches the most common cases. - // - // gain = victim - attacker (worst case: opponent recaptures immediately) - // If gain >= 0 when assuming opponent recaptures, still fine. - victim_value - attacker_value >= 0 + // Swap list: gain[d] = best material balance after d captures, assuming + // optimal stand-pat decisions resolved in the unwinding loop below. + let mut gain = [0i32; 36]; + let mut d = 0usize; + gain[0] = first_victim; + + let mut attacker_kind = first_attacker.kind; + let mut attacker_sq = mv.from; + let mut stm = side; + + loop { + d += 1; + if d >= gain.len() { + d -= 1; + break; + } + // If the current attacker is captured in turn, the opponent gains it. + gain[d] = see_piece_value(attacker_kind) - gain[d - 1]; + // Prune: if both stand-pat options are losing, no need to continue. + if (-gain[d - 1]).max(gain[d]) < 0 { + break; + } + // The attacker has moved to the target square; remove it from `occ` + // so x-ray attackers behind it are revealed. + occ &= !(1u64 << attacker_sq.index()); + stm = stm.opponent(); + match see_least_valuable_attacker(board, occ, target, stm) { + Some((sq, kind)) => { + attacker_sq = sq; + attacker_kind = kind; + } + None => break, + } + } + + // Negamax the swap list backwards (stand-pat allowed at every level). + while d > 0 { + gain[d - 1] = -((-gain[d - 1]).max(gain[d])); + d -= 1; + } + gain[0] } // --------------------------------------------------------------------------- @@ -1154,6 +1908,116 @@ mod tests { assert_eq!(result.depth, 5); } + #[test] + fn test_for_level_scales_with_skill() { + let weak = SearchLimits::for_level(1); + let strong = SearchLimits::for_level(MAX_SKILL_LEVEL); + assert!(strong.max_depth > weak.max_depth); + assert!(strong.move_time_ms.unwrap() > weak.move_time_ms.unwrap()); + + // Out-of-range levels clamp to the valid range. + assert_eq!( + SearchLimits::for_level(0).max_depth, + SearchLimits::for_level(1).max_depth + ); + assert_eq!( + SearchLimits::for_level(250).max_depth, + SearchLimits::for_level(MAX_SKILL_LEVEL).max_depth + ); + } + + #[test] + fn test_node_limit_bounds_search() { + let pos = starting_pos(); + let mut engine = SearchEngine::with_defaults(); + let limits = SearchLimits { + max_depth: MAX_DEPTH, + move_time_ms: None, + max_nodes: Some(5_000), + }; + let result = engine.search_limited(&pos, &limits, None); + assert!(result.best_move.is_some(), "should still return a move"); + // The in-tree check fires every NODE_CHECK_INTERVAL nodes, so allow + // generous slack above the requested node budget. + assert!( + result.stats.nodes < 200_000, + "node limit should bound the search, got {}", + result.stats.nodes + ); + } + + #[test] + fn test_finds_forced_mate_in_two() { + // K+R vs K: White to move mates in two (1.Kb6 Kb8 2.Rh8#). + let game = crate::game::Game::from_fen("k7/8/2K5/8/8/8/8/7R w - - 0 1").unwrap(); + let pos = SearchPosition::new( + game.board.clone(), + game.turn, + game.castling, + game.en_passant, + game.halfmove_clock, + ); + let mut engine = SearchEngine::with_defaults(); + let result = engine.search(&pos, 8); + assert_eq!( + score_to_mate_in(result.score), + Some(2), + "engine should announce a forced mate in two (score {})", + result.score + ); + } + + #[test] + fn test_repetition_against_game_history_saves_lost_position() { + // White is down a whole queen (lone knight vs queen) and is losing. + // But the knight move Ng1-f3 returns to a position that already + // occurred earlier in the game — a draw by repetition. The engine must + // recognise that saving resource and evaluate the position as a draw + // rather than the lost score the raw material would suggest. + let game = crate::game::Game::from_fen("k7/8/8/8/1q6/8/8/6NK w - - 40 1").unwrap(); + let pos = SearchPosition::new( + game.board.clone(), + game.turn, + game.castling, + game.en_passant, + game.halfmove_clock, + ); + + // Control: with no history, being down a queen is simply losing. + let mut control = SearchEngine::with_defaults(); + let losing = control.search(&pos, 5); + assert!( + losing.score < -300, + "control: down a queen should be losing, got {}", + losing.score + ); + + // Tell the engine the position reached after Ng1-f3 already occurred. + let nf3 = pos + .legal_moves() + .into_iter() + .find(|m| { + m.from == Square::from_algebraic("g1").unwrap() + && m.to == Square::from_algebraic("f3").unwrap() + }) + .expect("Ng1-f3 must be legal"); + let repeated = pos.make_move(&nf3).hash; + + let mut engine = SearchEngine::with_defaults(); + engine.set_game_history(&[repeated]); + let saved = engine.search(&pos, 5); + assert!( + saved.score.abs() < 50, + "Nf3 repeats a prior position → engine holds the draw, got {}", + saved.score + ); + assert_eq!( + saved.best_move, + Some(nf3), + "engine should choose the saving repetition" + ); + } + #[test] fn test_search_does_not_clear_external_abort_token() { let pos = starting_pos(); @@ -1619,6 +2483,60 @@ mod tests { ); } + /// Performance sanity benchmark: prints nodes-per-second for a depth-10 + /// search from the starting position. Opt-in via + /// `cargo test --release -- --ignored bench_nps --nocapture`. + #[test] + #[ignore] + fn bench_nps_depth_10_startpos() { + let pos = starting_pos(); + let mut engine = SearchEngine::with_defaults(); + let result = engine.search(&pos, 10); + let nodes = result.stats.nodes + result.stats.quiescence_nodes; + let scaled = nodes * 1000; + let nps = scaled.checked_div(result.time_ms).unwrap_or(scaled); + println!( + "bench_nps_depth_10_startpos: nodes={} qnodes={} time_ms={} nps={}", + result.stats.nodes, result.stats.quiescence_nodes, result.time_ms, nps + ); + assert!(result.best_move.is_some()); + } + + /// Diagnostic: prints node counts for a few fixed positions at fixed + /// depth. Used to compare pruning effectiveness across engine changes. + /// Opt-in via `cargo test --release -- --ignored bench_nodes --nocapture`. + #[test] + #[ignore] + fn bench_nodes_fixed_positions() { + let cases = [ + ("startpos", starting_pos(), 9), + ( + "kiwipete", + position_from_fen( + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + ), + 8, + ), + ( + "endgame", + position_from_fen("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"), + 10, + ), + ]; + for (name, pos, depth) in cases { + let mut engine = SearchEngine::with_defaults(); + let result = engine.search(&pos, depth); + println!( + "bench_nodes {name}: depth={} nodes={} qnodes={} time_ms={} score={}", + depth, + result.stats.nodes, + result.stats.quiescence_nodes, + result.time_ms, + result.score + ); + } + } + /// Verify the transposition table is actually reused across iterative /// deepening iterations and across two consecutive searches. #[test] diff --git a/src/terminal.rs b/src/terminal.rs index 365dfdc..517504d 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,502 +1,115 @@ -//! Terminal interface for the CheckAI chess engine. +//! Terminal input toolkit for interactive CLI sessions. //! -//! This module provides a command-line interface for playing chess -//! directly in the terminal. It supports: +//! Provides the building blocks shared by the interactive game modes +//! (`checkai play`): line-based input with EOF handling, parsing of +//! coordinate moves (`e2e4`, `e7e8Q`) and of the in-game REPL commands +//! with their single-letter aliases. //! -//! - Colored board display with Unicode pieces -//! - Interactive move input (algebraic notation) -//! - Game state display (check, castling rights, move history) -//! - Draw claims and resignation -//! - Two-player mode (human vs human) - -use colored::Colorize; -use std::io::{self, Write}; - -use crate::game::Game; -use crate::movegen; -use crate::types::*; - -/// Renders the board to the terminal with colors and piece symbols. -/// -/// The board is displayed from White's perspective (rank 8 at top). -/// Dark squares are shown with a dark background, light squares with light. -/// Pieces are colored based on their side (White/Black). -pub fn print_board(game: &Game) { - println!(); - println!(" +---+---+---+---+---+---+---+---+"); - - for rank in (0..8u8).rev() { - print!("{} ", rank + 1); - for file in 0..8u8 { - let sq = Square::new(file, rank); - let is_dark_square = (file + rank) % 2 == 0; - - let piece_str = match game.board.get(sq) { - Some(piece) => { - let symbol = piece_to_unicode(piece); - if piece.color == Color::White { - symbol.white().bold().to_string() - } else { - symbol.blue().bold().to_string() - } - } - None => { - if is_dark_square { - "·".dimmed().to_string() - } else { - " ".to_string() - } - } - }; - - print!("| {} ", piece_str); - } - println!("|"); - println!(" +---+---+---+---+---+---+---+---+"); - } - println!(" a b c d e f g h"); - println!(); -} - -/// Converts a piece to its Unicode chess symbol. -fn piece_to_unicode(piece: Piece) -> &'static str { - match (piece.color, piece.kind) { - (Color::White, PieceKind::King) => "K", - (Color::White, PieceKind::Queen) => "Q", - (Color::White, PieceKind::Rook) => "R", - (Color::White, PieceKind::Bishop) => "B", - (Color::White, PieceKind::Knight) => "N", - (Color::White, PieceKind::Pawn) => "P", - (Color::Black, PieceKind::King) => "k", - (Color::Black, PieceKind::Queen) => "q", - (Color::Black, PieceKind::Rook) => "r", - (Color::Black, PieceKind::Bishop) => "b", - (Color::Black, PieceKind::Knight) => "n", - (Color::Black, PieceKind::Pawn) => "p", - } -} - -/// Prints the game status bar (turn, check, move number, etc.). -pub fn print_status(game: &Game) { - let turn_str = match game.turn { - Color::White => "White".white().bold(), - Color::Black => "Black".blue().bold(), - }; - - let is_check = movegen::is_in_check(&game.board, game.turn); - let legal_moves = game.legal_moves(); - - print!( - "{}", - t!( - "terminal.move_status", - num = game.fullmove_number, - color = turn_str - ), - ); - - if is_check { - print!(" {}", t!("terminal.check").to_string().red().bold()); - } - - println!( - " {}", - t!("terminal.legal_moves_count", count = legal_moves.len()) - ); - - // Castling rights - let wk = if game.castling.white.kingside { - "K" - } else { - "-" - }; - let wq = if game.castling.white.queenside { - "Q" - } else { - "-" - }; - let bk = if game.castling.black.kingside { - "k" - } else { - "-" - }; - let bq = if game.castling.black.queenside { - "q" - } else { - "-" - }; - let rights = format!("{}{}{}{}", wk, wq, bk, bq); - println!( - "{}", - t!( - "terminal.castling_info", - rights = &rights, - clock = game.halfmove_clock - ) - ); - - if let Some(ep) = game.en_passant { - println!( - "{}", - t!("terminal.en_passant_info", square = ep.to_algebraic()) - ); - } - - println!(); +//! Rendering lives in [`crate::cli::board_renderer`]; this module is +//! strictly about reading and interpreting user input. No raw mode is +//! ever enabled, so Ctrl+C and terminal state need no special cleanup. + +use std::io::{self, BufRead, Write}; + +use crate::types::{MoveJson, Square}; + +/// A parsed in-game REPL command. +#[derive(Debug, Clone)] +pub enum GameCommand { + /// A coordinate move such as `e2e4` or `e7e8q`. + Move(MoveJson), + /// List all legal moves. + Moves, + /// Re-render the board. + Board, + /// Show the numbered move history. + History, + /// Print the current FEN string. + Fen, + /// Print the game state as JSON. + Json, + /// Ask the engine for a move suggestion. + Hint, + /// Take back the last full move. + Undo, + /// Resign the game. + Resign, + /// Claim a draw (if eligible). + Draw, + /// Show the in-game help table. + Help, + /// Quit the session. + Quit, } -/// Prints the game result when the game ends. -pub fn print_game_result(game: &Game) { - if let (Some(result), Some(reason)) = (&game.result, &game.end_reason) { - println!(); - println!("{}", "═══════════════════════════════════".yellow()); - println!( - " {} — {}", - t!("terminal.game_over_label").to_string().yellow().bold(), - reason - ); - println!( - "{}", - t!( - "terminal.result_label", - result = result.to_string().green().bold() - ) - ); - println!("{}", "═══════════════════════════════════".yellow()); - println!(); +impl PartialEq for GameCommand { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + // MoveJson does not derive PartialEq, so compare fields. + (Self::Move(a), Self::Move(b)) => { + a.from == b.from && a.to == b.to && a.promotion == b.promotion + } + _ => std::mem::discriminant(self) == std::mem::discriminant(other), + } } } -/// Prints available commands in the terminal, grouped by category. -pub fn print_help() { - println!("{}", t!("terminal.cmd_header").to_string().yellow().bold()); - println!(); - println!( - " {}", - t!("terminal.cmd_section_game").to_string().cyan().bold() - ); - println!( - " {} {}", - "e2e4".green(), - t!("terminal.cmd_move") - ); - println!( - " {} {} {}", - "moves".green(), - "[m]".dimmed(), - t!("terminal.cmd_moves") - ); - println!( - " {} {} {}", - "resign".green(), - "[r]".dimmed(), - t!("terminal.cmd_resign") - ); - println!( - " {} {} {}", - "draw".green(), - "[d]".dimmed(), - t!("terminal.cmd_draw") - ); - println!(); - println!( - " {}", - t!("terminal.cmd_section_display").to_string().cyan().bold() - ); - println!( - " {} {} {}", - "board".green(), - "[b]".dimmed(), - t!("terminal.cmd_board") - ); - println!( - " {} {}", - "history".green(), - t!("terminal.cmd_history") - ); - println!( - " {} {} {}", - "fen".green(), - "[f]".dimmed(), - t!("terminal.cmd_fen") - ); - println!( - " {} {} {}", - "json".green(), - "[j]".dimmed(), - t!("terminal.cmd_json") - ); - println!(); - println!( - " {}", - t!("terminal.cmd_section_system").to_string().cyan().bold() - ); - println!( - " {} {} {}", - "help".green(), - "[h/?]".dimmed(), - t!("terminal.cmd_help") - ); - println!( - " {} {} {}", - "quit".green(), - "[q]".dimmed(), - t!("terminal.cmd_quit") - ); - println!(); -} - -/// Prints the move history. -pub fn print_history(game: &Game) { - if game.move_history.is_empty() { - println!("{}", t!("terminal.no_moves_yet")); - return; - } - - println!( - "{}", - t!("terminal.move_history_label") - .to_string() - .yellow() - .bold() - ); - for (i, record) in game.move_history.iter().enumerate() { - let side = match record.side { - Color::White => "White", - Color::Black => "Black", - }; - println!(" {}. {} {}", i + 1, side, record.notation); +impl Eq for GameCommand {} + +impl GameCommand { + /// Parses user input (case-insensitive) into a command. + /// + /// Keyword commands and their single-letter aliases are tried first; + /// anything else is interpreted as a coordinate move. Returns `None` + /// for unrecognized input. + pub fn parse(input: &str) -> Option { + let normalized = input.trim().to_lowercase(); + match normalized.as_str() { + "moves" | "m" => Some(Self::Moves), + "board" | "b" => Some(Self::Board), + "history" | "hist" => Some(Self::History), + "fen" | "f" => Some(Self::Fen), + "json" | "j" => Some(Self::Json), + "hint" | "i" => Some(Self::Hint), + "undo" | "u" => Some(Self::Undo), + "resign" | "r" => Some(Self::Resign), + "draw" | "d" => Some(Self::Draw), + "help" | "h" | "?" => Some(Self::Help), + "quit" | "exit" | "q" => Some(Self::Quit), + _ => parse_move_input(&normalized).map(Self::Move), + } } - println!(); } -/// Runs the interactive terminal chess game. +/// Prints `prompt`, flushes stdout, and reads one line from stdin. /// -/// Two players alternate entering moves via the terminal. -/// The game continues until checkmate, stalemate, draw, or resignation. -pub fn run_terminal_game() { - let version = crate::update::version(); - - let border = "═══════════════════════════════════════"; - let inner_width = border.chars().count(); - - let title = t!("terminal.banner_title").to_string(); - let subtitle_content = format!(" {} v{}", t!("terminal.banner_subtitle"), version); - - println!(); - println!("{}", format!("\u{2554}{}\u{2557}", border).cyan()); - println!( - "{}", - format!("\u{2551}{:^inner_width$}\u{2551}", title).cyan() - ); - println!( - "{}", - format!("\u{2551}{: "White".white().bold(), - Color::Black => "Black".blue().bold(), - }; - - print!("{} > ", turn_prompt); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_err() { - println!("{}", t!("terminal.input_error")); - continue; - } - let input = input.trim().to_lowercase(); - - if input.is_empty() { - continue; - } - - match input.as_str() { - "quit" | "exit" | "q" => { - println!("{}", t!("terminal.goodbye")); - break; - } - "help" | "h" | "?" => { - print_help(); - } - "board" | "b" => { - print_board(&game); - print_status(&game); - } - "moves" | "m" => { - let moves = game.legal_moves(); - println!( - "{} {}", - t!("terminal.legal_moves_header") - .to_string() - .yellow() - .bold(), - t!("terminal.moves_count", count = moves.len()) - ); - for (i, mv) in moves.iter().enumerate() { - if i > 0 && i % 8 == 0 { - println!(); - } - print!(" {}", mv.to_string().green()); - } - println!(); - println!(); - } - "resign" | "r" => { - let action = ActionJson { - action: "resign".to_string(), - reason: None, - }; - match game.process_action(&action) { - Ok(()) => { - print_board(&game); - print_game_result(&game); - break; - } - Err(e) => println!( - "{}: {}", - t!("terminal.error_label").to_string().red().bold(), - e - ), - } - } - "draw" | "d" => { - // Try to claim a draw - let can_claim_repetition = game - .position_history - .iter() - .filter(|p| *p == game.position_history.last().unwrap()) - .count() - >= 3; - - let can_claim_fifty = game.halfmove_clock >= 100; - - if can_claim_repetition { - let action = ActionJson { - action: "claim_draw".to_string(), - reason: Some("threefold_repetition".to_string()), - }; - match game.process_action(&action) { - Ok(()) => { - print_game_result(&game); - break; - } - Err(e) => println!( - "{}: {}", - t!("terminal.error_label").to_string().red().bold(), - e - ), - } - } else if can_claim_fifty { - let action = ActionJson { - action: "claim_draw".to_string(), - reason: Some("fifty_move_rule".to_string()), - }; - match game.process_action(&action) { - Ok(()) => { - print_game_result(&game); - break; - } - Err(e) => println!( - "{}: {}", - t!("terminal.error_label").to_string().red().bold(), - e - ), - } - } else { - println!( - "{}", - t!( - "terminal.no_draw_available", - clock = game.halfmove_clock, - reps = game - .position_history - .iter() - .filter(|p| *p == game.position_history.last().unwrap()) - .count() - ) - ); - } - } - "history" => { - print_history(&game); - } - "json" | "j" => { - let state = game.to_game_state_json(); - println!("{}", serde_json::to_string_pretty(&state).unwrap()); - println!(); - } - "fen" | "f" => { - let fen = game - .board - .to_position_fen(game.turn, &game.castling, game.en_passant); - let full_fen = format!("{} {} {}", fen, game.halfmove_clock, game.fullmove_number); - println!(" {}", full_fen.green()); - println!(); - } - _ => { - // Try to parse as a move (e.g. "e2e4" or "e7e8Q") - if let Some(move_json) = parse_move_input(&input) { - match game.make_move(&move_json) { - Ok(()) => { - print_board(&game); - print_status(&game); - - if game.is_over() { - print_game_result(&game); - break; - } - } - Err(e) => { - println!( - "{}: {}", - t!("terminal.illegal_move").to_string().red().bold(), - e - ); - } - } - } else { - println!( - "{}", - t!( - "terminal.unknown_cmd_hint", - cmd = &input, - help = "help".green() - ) - ); - } - } - } +/// Returns `None` on EOF or a read error so callers can terminate +/// cleanly instead of spinning on an exhausted pipe. +pub fn read_input_line(prompt: &str) -> Option { + print!("{prompt}"); + io::stdout().flush().ok(); + + let mut input = String::new(); + match io::stdin().lock().read_line(&mut input) { + Ok(0) => None, // EOF + Ok(_) => Some(input.trim().to_string()), + Err(_) => None, } } -/// Parses a move input string like "e2e4" or "e7e8Q" into a MoveJson. +/// Parses a coordinate move like `e2e4` or `e7e8Q` into a [`MoveJson`]. /// -/// Accepts formats: +/// Accepted formats: /// - `e2e4` — normal move -/// - `e7e8Q` — promotion (Q, R, B, N) +/// - `e7e8Q` — promotion (Q, R, B, N — case-insensitive) /// - `e2 e4` — with space separator -fn parse_move_input(input: &str) -> Option { +/// +/// Non-ASCII input is rejected up front so slicing can never panic. +pub fn parse_move_input(input: &str) -> Option { let input = input.replace(' ', ""); let input = input.trim(); - if input.len() < 4 || input.len() > 5 { + if !input.is_ascii() || input.len() < 4 || input.len() > 5 { return None; } @@ -558,4 +171,36 @@ mod tests { assert!(parse_move_input("z9z9").is_none()); assert!(parse_move_input("e2e4x").is_none()); } + + #[test] + fn test_parse_multibyte_input_does_not_panic() { + // 4–5 *bytes* of multi-byte UTF-8 used to panic on byte slicing. + assert!(parse_move_input("♔♕").is_none()); + assert!(parse_move_input("éé").is_none()); + } + + #[test] + fn test_command_keywords_and_aliases() { + assert_eq!(GameCommand::parse("quit"), Some(GameCommand::Quit)); + assert_eq!(GameCommand::parse("Q"), Some(GameCommand::Quit)); + assert_eq!(GameCommand::parse("HELP"), Some(GameCommand::Help)); + assert_eq!(GameCommand::parse("?"), Some(GameCommand::Help)); + assert_eq!(GameCommand::parse("hint"), Some(GameCommand::Hint)); + assert_eq!(GameCommand::parse("i"), Some(GameCommand::Hint)); + assert_eq!(GameCommand::parse("u"), Some(GameCommand::Undo)); + assert_eq!(GameCommand::parse("hist"), Some(GameCommand::History)); + assert_eq!(GameCommand::parse("d"), Some(GameCommand::Draw)); + } + + #[test] + fn test_command_move_fallback() { + match GameCommand::parse("E2E4") { + Some(GameCommand::Move(m)) => { + assert_eq!(m.from, "e2"); + assert_eq!(m.to, "e4"); + } + other => panic!("expected move, got {other:?}"), + } + assert_eq!(GameCommand::parse("xyzzy"), None); + } } diff --git a/src/types.rs b/src/types.rs index 0f921a2..354d622 100644 --- a/src/types.rs +++ b/src/types.rs @@ -260,6 +260,34 @@ impl CastlingRights { } if s.is_empty() { "-".to_string() } else { s } } + + /// Parses a FEN castling-availability field (e.g. `"KQkq"` or `"-"`). + /// + /// Recognised characters set the matching right; any others (including + /// `'-'`) are ignored, so an empty or `"-"` field clears all rights. + /// Inverse of [`CastlingRights::to_fen`]. + pub fn from_fen(s: &str) -> CastlingRights { + let mut rights = CastlingRights { + white: SideCastlingRights { + kingside: false, + queenside: false, + }, + black: SideCastlingRights { + kingside: false, + queenside: false, + }, + }; + for c in s.chars() { + match c { + 'K' => rights.white.kingside = true, + 'Q' => rights.white.queenside = true, + 'k' => rights.black.kingside = true, + 'q' => rights.black.queenside = true, + _ => {} + } + } + rights + } } // --------------------------------------------------------------------------- @@ -436,6 +464,49 @@ impl Board { fen } + + /// Parses the piece-placement field of a FEN string (the part before the + /// first space, e.g. `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"`). + /// + /// Ranks are listed from 8 down to 1; within a rank, files run a→h and + /// digits denote runs of empty squares. Returns an error describing the + /// first malformed rank. Inverse of the placement part of + /// [`Board::to_position_fen`]. + pub fn from_piece_placement(placement: &str) -> Result { + let mut board = Board::default(); + let ranks: Vec<&str> = placement.split('/').collect(); + if ranks.len() != 8 { + return Err(format!( + "FEN placement must have 8 ranks, found {}", + ranks.len() + )); + } + for (i, rank_str) in ranks.iter().enumerate() { + // The first substring is rank 8 (index 7); the last is rank 1. + let rank = 7 - i as u8; + let mut file = 0u8; + for c in rank_str.chars() { + if let Some(skip) = c.to_digit(10) { + file += skip as u8; + if file > 8 { + return Err(format!("FEN rank '{}' overflows 8 files", rank_str)); + } + } else { + if file >= 8 { + return Err(format!("FEN rank '{}' has too many files", rank_str)); + } + let piece = Piece::from_fen_char(c) + .ok_or_else(|| format!("Invalid piece symbol '{}' in FEN", c))?; + board.set(Square::new(file, rank), Some(piece)); + file += 1; + } + } + if file != 8 { + return Err(format!("FEN rank '{}' does not fill 8 files", rank_str)); + } + } + Ok(board) + } } // --------------------------------------------------------------------------- diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index fa5bf97..df34afc 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -16,7 +16,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "checkai-wasm" -version = "0.7.0" +version = "0.8.0" dependencies = [ "console_log", "js-sys", diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index d803b81..a320f91 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "checkai-wasm" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "CheckAI chess engine compiled to WebAssembly — use as a Node.js CLI or library" license = "MIT" diff --git a/web/package.json b/web/package.json index 4ea93f9..5bb4779 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@checkai/web", - "version": "0.7.0", + "version": "0.8.0", "packageManager": "bun@1.3.10", "private": true, "type": "module",