diff --git a/docs/uci-engine-architecture-plan.md b/docs/uci-engine-architecture-plan.md new file mode 100644 index 0000000..043fdad --- /dev/null +++ b/docs/uci-engine-architecture-plan.md @@ -0,0 +1,448 @@ +# UCI Engine Architecture Plan + +## Goal + +Support two families of executables: + +1. **GUI/UI executable** + - Renders and tracks games. + - Lets humans play/edit/replay games. + - Can let people play against bots. + - Can spawn bot/engine executables as subprocesses on demand. + - Speaks UCI to engine subprocesses. + - Can optionally use a user-selected external UCI engine, such as Stockfish, for playback analysis. + - Eventually stores games in a database. + +2. **Bot/engine executables** + - No GUI. + - Implement chess engine logic. + - Speak UCI over stdin/stdout. + - Can be launched by the GUI. + - Can also run as long-lived processes connected to external services such as lichess.org through a service adapter. + +## Important terminology + +In UCI terminology, the **engine** is the side that implements the UCI command loop over stdin/stdout. + +The GUI/app is the **UCI controller/client**: + +```text +GUI/controller +├── starts engine process +├── writes UCI commands to engine stdin +├── reads UCI responses from engine stdout +└── applies bestmove to the current game +``` + +The engine executable is the **UCI engine/server-like process**: + +```text +Engine +├── reads commands from stdin +├── maintains/searches position +├── writes info/bestmove responses to stdout +└── exits on quit +``` + +Avoid naming the GUI-side wrapper a "server" in code. Prefer names like: + +```text +UciEngineClient +UciController +EngineProcess +``` + +## Desired long-term process architecture + +```text + ┌─────────────────────┐ + │ zig-chess-gui │ + │ │ + │ - Vulkan UI │ + │ - game tracking │ + │ - human input │ + │ - playback │ + │ - database later │ + │ - UCI client │ + └──────────┬──────────┘ + │ UCI stdin/stdout + ▼ + ┌─────────────────────┐ + │ zig-chess-engine-* │ + │ │ + │ - evaluation │ + │ - search │ + │ - move selection │ + │ - UCI command loop │ + └─────────────────────┘ +``` + +Future external-service architecture: + +```text + ┌─────────────────────┐ + │ lichess-adapter │ + │ │ + │ - lichess API │ + │ - account/game I/O │ + │ - UCI client │ + └──────────┬──────────┘ + │ UCI stdin/stdout or socket/process bridge + ▼ + ┌─────────────────────┐ + │ zig-chess-engine-* │ + └─────────────────────┘ +``` + +Key design principle: + +```text +GUI ───────────────┐ + ├── UCI ── engine executable +Lichess adapter ───┘ +``` + +The engine should not know whether it is being used by the GUI, a test harness, or a lichess adapter. + +## Suggested source layout + +Possible future layout: + +```text +src/ +├── chess/ // board/game/FEN/PGN/legal moves +├── main.zig // current GUI entry point, or later src/gui/main.zig +├── uci/ +│ ├── protocol.zig // shared UCI parse/format types +│ ├── client.zig // GUI/service side: controls engine process +│ └── server.zig // engine side: stdin/stdout command loop +├── engine/ +│ ├── engine.zig // common engine interface +│ ├── eval.zig // static evaluation +│ ├── search.zig // search algorithms +│ └── perft.zig // move-generator validation tooling +└── bots/ + ├── random.zig // random/legal-move bot executable + ├── material.zig // material-eval bot executable + └── search.zig // stronger search bot executable +``` + +This layout can evolve. The important boundary is that engine executables communicate with outside controllers through UCI. + +## Build targets + +Eventually `build.zig` should produce multiple executables, for example: + +```text +zig-chess-gui +zig-chess-random-bot +zig-chess-material-bot +zig-chess-search-bot +``` + +The GUI target links rendering/UI code. + +The engine targets should avoid linking Vulkan/windowing code. + +## Analysis engine policy + +The GUI should support an optional external UCI engine for real-time analysis during game playback. + +Primary intended use case: + +```text +Playback mode +├── user steps through game +├── GUI sends current position to analysis engine +├── analysis engine returns eval/PV/best line +└── GUI displays analysis to the user +``` + +This analysis engine is separate from project bot engines: + +- It is for user-facing analysis only. +- It must not be used by project bot engines as their search/evaluation implementation. +- Project bots should have their own evaluation/search logic. +- The GUI should treat the analysis engine as just another external UCI process. + +### Stockfish licensing policy + +Stockfish is GPL-3.0 licensed. Because this project is intended to be MIT licensed, do **not** bundle Stockfish directly into the executable or source distribution by default. + +Preferred approaches: + +1. **User-provided engine path** + - User installs Stockfish or another UCI engine separately. + - GUI stores/configures the path. + - GUI launches it as an external process. + +2. **Optional download flow** + - GUI can offer to download Stockfish or guide the user through downloading it. + - The download should be explicit and optional. + - The UI should clearly identify the engine and its license before download/use. + - Keep downloaded engine files outside the MIT-licensed source tree/release bundle unless licensing obligations are intentionally handled. + +3. **Generic UCI analysis engine support** + - Do not hard-code Stockfish as the only option. + - Any compatible UCI engine path should work. + +### Analysis engine UI/config needs + +Eventually the GUI should provide: + +- analysis engine executable path +- enable/disable analysis +- analysis depth or movetime +- optional thread/hash settings via UCI options +- current eval display +- principal variation display +- best move display +- start/stop analysis when stepping through playback + +Suggested app-side abstraction: + +```text +AnalysisEngine +├── engine_process / UciEngineClient +├── executable_path +├── enabled +├── depth or movetime limit +├── latest_score +├── latest_pv +└── latest_bestmove +``` + +## UCI protocol responsibilities + +### GUI/client side + +The GUI should be able to: + +- Spawn an engine executable. +- Send UCI commands. +- Read engine output asynchronously or incrementally. +- Track engine readiness. +- Track available engine options. +- Send current game position. +- Request a move/search. +- Stop a search. +- Shut down the engine process cleanly. + +Common commands sent by GUI: + +```text +uci +isready +ucinewgame +position startpos +position startpos moves e2e4 e7e5 +position fen moves ... +go depth 5 +go movetime 1000 +stop +quit +``` + +Common responses parsed by GUI: + +```text +id name +id author +option name type ... +uciok +readyok +info depth 3 score cp 20 nodes 1234 time 10 pv e2e4 e7e5 +bestmove e2e4 +bestmove e2e4 ponder e7e5 +``` + +### Engine/server side + +Each engine executable should: + +- Read one line at a time from stdin. +- Parse UCI commands. +- Maintain an internal position. +- Search when given `go`. +- Print `bestmove ...` when done. +- Print `info ...` optionally during search. +- Respond to `stop` promptly. +- Exit on `quit`. + +## First milestone: minimal UCI engine executable + +Implement an engine executable that supports only: + +Input: + +```text +uci +isready +quit +``` + +Output: + +```text +id name ZigChess Random Bot +id author WayfinderAK +uciok +readyok +``` + +No search or move generation required yet. + +Purpose: + +- Prove executable separation. +- Prove stdin/stdout loop. +- Prove GUI/test harness can launch and communicate with engine. + +## Second milestone: GUI-side engine process wrapper + +Implement a UI/application-side wrapper that can: + +- Start an engine executable by path. +- Send `uci`. +- Wait for `uciok`. +- Send `isready`. +- Wait for `readyok`. +- Send `quit` on shutdown. + +Suggested name: + +```zig +UciEngineClient +``` + +or: + +```zig +EngineProcess +``` + +This code belongs on the GUI/controller side, not inside the engine bot. + +## Third milestone: position and bestmove + +Add UCI support for: + +```text +position startpos +position startpos moves e2e4 e7e5 +position fen moves ... +go depth 1 +bestmove +``` + +For the first engine, `go depth 1` can simply pick the first legal move or a random legal move. + +Move format is UCI long algebraic coordinate format: + +```text +e2e4 +e7e8q +e1g1 +``` + +This is different from SAN/PGN notation. + +## Fourth milestone: reusable protocol module + +Create shared parse/format helpers for UCI text: + +```text +src/uci/protocol.zig +``` + +Potential responsibilities: + +- Parse GUI-to-engine commands. +- Parse engine-to-GUI responses. +- Format moves as UCI coordinate moves. +- Parse UCI coordinate moves into from/to/promotion. +- Format `position ...` commands from a `Game` move list. + +Keep protocol parsing separate from subprocess management and separate from search/evaluation. + +## Fifth milestone: stronger engines + +Once the protocol boundary works: + +- random legal move engine +- material evaluation engine +- shallow search engine +- alpha-beta search engine +- improved move ordering +- transposition table +- time management + +Each stronger engine can be its own executable or selected by options/config. + +## Future lichess adapter + +Do not put lichess-specific code inside the engine. + +Instead, later create a separate adapter/service that: + +- talks to lichess.org APIs +- manages authentication/account events +- receives games/moves from lichess +- launches or connects to an engine executable +- sends positions/search commands via UCI +- sends engine moves back to lichess + +This keeps engines reusable by: + +- GUI +- lichess adapter +- local match runner +- tournament harness +- tests/benchmarks + +## Future playback analysis milestone + +After the generic UCI client exists, add analysis support to playback mode: + +1. Let the user configure an external UCI engine path. +2. Start that engine as an analysis process. +3. Send `uci` / `isready` handshake. +4. When playback cursor changes, send: + +```text +position fen +go depth +``` + +or: + +```text +position fen +go movetime +``` + +5. Parse `info` lines for score and PV. +6. Display the latest analysis in the UI. +7. Send `stop` when the user moves to another ply or disables analysis. +8. Send `quit` when closing the app or changing engine path. + +Keep this separate from bot-vs-human play. The analysis engine is an advisor for the user, not the implementation of project bots. + +## References + +Primary UCI reference: + +- Stefan Meyer-Kahlen, **Universal Chess Interface (UCI)** protocol, April 2006. Commonly distributed as `uci.txt` and mirrored by chess-programming resources. Covers `uci`, `isready`, `position`, `go`, `stop`, `quit`, `info`, `bestmove`, and engine options. + +Stockfish licensing/reference: + +- Official Stockfish repository: https://github.com/official-stockfish/Stockfish +- Stockfish `Copying.txt`: GNU General Public License v3.0, https://github.com/official-stockfish/Stockfish/blob/master/Copying.txt +- Because the project goal is MIT licensing, Stockfish should be user-provided or optionally downloaded rather than bundled by default. + +Useful related notation distinction: + +- UCI moves are coordinate moves such as `e2e4` or `e7e8q`. +- PGN/SAN moves are display/game-record notation such as `e4`, `Nf3`, `O-O`, `Qxf7#`. +- The GUI move list should display PGN/SAN. +- Engine communication should use UCI coordinate moves. diff --git a/docs/ui-move-list-playback-plan.md b/docs/ui-move-list-playback-plan.md new file mode 100644 index 0000000..0083611 --- /dev/null +++ b/docs/ui-move-list-playback-plan.md @@ -0,0 +1,382 @@ +# UI Move List and Playback Mode Plan + +## Goal + +Add UI support for: + +- A fixed-size move list panel on the left side of the board. +- Two move columns: White move and Black move, with turn number on the left. +- Standard PGN/SAN notation display. +- Scroll support when the move list is longer than the visible panel. +- A move-list button that copies the current game as PGN to the clipboard. +- A playback-only vertical analysis bar between the board and move list. +- A board-orientation control that can render from White's perspective or Black's perspective. +- A new playback mode where pieces cannot be moved, and the user can step through a loaded game. +- In edit mode, a UI control to choose whose move is next. +- Mode-specific paste behavior: + - Edit mode: `Ctrl+V` pastes FEN. + - Playback mode: `Ctrl+V` pastes PGN. + - Play mode: no FEN/PGN paste. + +## Collaboration rule + +The assistant should manage only UI/application-side changes for this feature. + +Do **not** edit files under `src/chess/` unless explicitly requested. + +When UI work needs chess-layer functionality, stop and describe the required chess API/data shape so the project owner can implement it. + +## Current state + +- `BoardState` owns the current chess position. +- `src/chess/game.zig` has a basic `Game` framework with: + - `initial_state` + - `state` + - `moves` + - `time_control` + - `clocks` + - `status` +- UI currently uses `current_state` directly in `src/main.zig`. +- Existing modes are: + - `play` + - `edit` +- Existing paste behavior parses clipboard as FEN regardless of mode. +- FEN copy is available via `Ctrl+C`. +- Desired copy behavior: `Ctrl+C` should continue to copy FEN in every mode. PGN copy should use an explicit move-list button, not replace `Ctrl+C`. + +## Desired architecture + +Eventually, UI should treat `Game` as the game/session model: + +```text +UI/App +├── current mode: play | edit | playback +├── active Game +├── playback cursor / ply index +├── move list scroll offset +├── board orientation: white perspective | black perspective +├── analysis display state for playback mode +└── rendering state +``` + +The chess layer should provide enough data for the UI to display moves and reconstruct positions, but the UI should not implement chess notation rules itself. + +## Stage 0: Board orientation and edit-side-to-move controls + +### UI work + +- Add app/UI state for board orientation: + +```text +white perspective: current rendering +black perspective: visually flipped board, with White pieces/ranks at the top and Black pieces/ranks at the bottom +``` + +- This must be a purely visual transform. +- Keep the same chess square numbering and logical coordinates: + - `a1` is still square `0` + - White still starts on ranks 1 and 2 logically + - Black still starts on ranks 7 and 8 logically +- Update all board visual mappings consistently: + - square rendering + - piece rendering + - coordinate labels + - mouse hit-testing + - hover/selection/check/checkmate highlights + - valid/legal move dots + - dragging source/target display + - promotion popup placement +- Add a UI control to toggle orientation, probably near the mode buttons or move list panel. +- In edit mode, add a control to choose whose move is next: + - White to move + - Black to move +- Changing side-to-move in edit mode should update only the current board state's active color/turn field and then rebuild rendering. +- In non-edit modes, the side-to-move selector should either be hidden or disabled. + +### Chess-layer needs + +No chess-rule changes are required for board orientation. It is visual only. + +For edit-side-to-move, the UI needs mutable access to the current position's active color. If `BoardState.turn` remains public, no new chess API is required. If you later make it private, add a small setter such as: + +```zig +pub fn setTurn(self: *BoardState, color: piece.Color) void +``` + +## Stage 1: Add playback mode UI shell + +### UI work + +- Add `playback` to the app/input mode enum. +- Add a third mode button, likely labeled `VIEW` or `PGN`/`REPLAY`. +- In playback mode: + - ignore board clicks/drags for moving pieces + - no palette editing + - no legal-move highlight generation +- Keep reset behavior available or decide whether reset resets the active game/playback. + +### Chess-layer needs + +None. + +## Stage 2: Make paste mode-specific + +### UI work + +Change clipboard paste behavior: + +- If mode is `.edit`: + - parse clipboard as FEN + - replace current board state/game position +- If mode is `.playback`: + - send clipboard text to a future PGN import function + - for now, log that PGN paste is not yet implemented if chess API is missing +- If mode is `.play`: + - ignore paste or log debug message + +### Chess-layer needs + +Eventually needed: + +```zig +pub fn loadPgn(allocator: std.mem.Allocator, pgn_text: []const u8) !Game +``` + +or equivalent parse API. + +For stage 2, this can be stubbed from the UI side with no chess changes. + +## Stage 3: Move list panel with placeholder/static data + +### UI work + +- Define a fixed rectangle left of the board. +- Render panel background and border. +- Render column headers, for example: + +```text +# White Black +``` + +- Add a visible PGN copy button inside or attached to the move list panel. + - Clicking it should copy the current game PGN to the clipboard once chess-layer PGN export exists. + - Until PGN export exists, the button can be visually present but log that export is not implemented. +- Render rows using temporary/static strings or currently available move placeholders. +- Establish layout constants: + - panel left/right/top/bottom + - row height + - turn-number column x + - white column x + - black column x + - max visible rows + +### Chess-layer needs + +None for placeholder rendering. + +## Stage 4: Record/display move notation from played games + +### UI work + +- Replace direct `current_state.move(...)` calls with `game.makeMove(...)` once `Game` is wired into `main.zig`. +- Read `game.moves` and draw move rows. +- Update the move list after every played move. + +### Chess-layer needs + +The `Game` move history should expose display notation for each ply. + +Suggested shape: + +```zig +pub const MoveRecord = struct { + from: bitboard.Square, + to: bitboard.Square, + promotion: ?piece.PieceType, + san: []const u8, // or fixed buffer / owned slice + time_remaining: u32, +}; +``` + +If avoiding heap allocation in each move record, possible alternatives: + +```zig +san: [16]u8, +san_len: u8, +``` + +The chess layer should generate SAN before mutating the board, because SAN depends on the pre-move position. + +Needed API could be: + +```zig +pub fn makeMove(self: *Game, allocator: std.mem.Allocator, from: Square, to: Square, promotion: ?PieceType) !void +``` + +or: + +```zig +pub fn formatSanMove(allocator: std.mem.Allocator, state_before: BoardState, from: Square, to: Square, promotion: ?PieceType) ![]u8 +``` + +## Stage 5: Playback analysis bar + +### UI work + +- Add a vertical analysis bar between the board and the move list panel. +- The analysis bar should be visible only in playback mode. +- The bar is divided into a White section and a Black section. +- When analysis is equal, the White/Black divide should be centered. +- When White is winning, the White portion grows and the divide moves toward Black's side. +- When Black is winning, the Black portion grows and the divide moves toward White's side. +- The bar is purely display; it should not affect board state or move legality. +- Initial placeholder behavior can render the bar at 50/50 until analysis data exists. +- Later, map engine evaluation scores to a display fraction. + +Suggested visual mapping: + +```text +score = 0.00 pawns -> 50% White / 50% Black +score > 0, White better -> White section larger +score < 0, Black better -> Black section larger +``` + +Use a bounded/nonlinear mapping so very large evaluations do not immediately consume the whole bar. For example, later UI code could map centipawns to a normalized value with a clamp or sigmoid-like curve. Exact mapping can be tuned visually. + +### Chess/engine-layer needs + +No chess-layer changes are needed for placeholder rendering. + +Eventually, playback analysis needs external UCI analysis data: + +```text +score cp +score mate +pv +``` + +The UI only needs a normalized analysis value or raw score from the UCI analysis client. + +## Stage 6: Scrolling move list + +### UI work + +- Track `move_list_scroll_row` or similar state in `main.zig`. +- Add mouse wheel handling when cursor is over the move panel. +- Clamp scroll offset between `0` and `max(0, total_rows - visible_rows)`. +- Optional later: visual scrollbar. + +### Chess-layer needs + +None. + +## Stage 7: Playback cursor and stepping + +### UI work + +- Add `playback_ply_index` app state. +- In playback mode: + - Right arrow: advance one ply + - Left arrow: go back one ply + - Home: first position + - End: final position +- Render the board for the current playback ply, not necessarily the final game state. +- Highlight or mark the current row/ply in the move list. + +### Chess-layer needs + +Need a way to reconstruct board state at an arbitrary ply. + +Possible API: + +```zig +pub fn stateAtPly(self: *const Game, ply_index: usize) board.BoardState +``` + +or, if allocation/errors are involved: + +```zig +pub fn replayToPly(self: *const Game, ply_index: usize) !board.BoardState +``` + +This can replay from `initial_state` through the first `ply_index` moves. + +## Stage 8: PGN paste/import and PGN copy/export + +### UI work + +- In playback mode, `Ctrl+V` gets clipboard text and passes it to chess PGN parser. +- Keep `Ctrl+C` as FEN copy in every mode. +- Add click handling for the move-list PGN copy button. +- When the PGN copy button is clicked, copy exported PGN to the clipboard. +- On success: + - replace active game with parsed game + - set mode to playback or remain playback + - set playback cursor to final ply or start; choose behavior intentionally + - reset move-list scroll +- On failure: + - log a warning + - keep current game unchanged + +### Chess-layer needs + +PGN parser that returns a `Game` with move history and final state, plus PGN export for the current game. + +Suggested API: + +```zig +pub fn initFromPgn(allocator: std.mem.Allocator, pgn_text: []const u8, time_control: TimeControl) !Game +``` + +or: + +```zig +pub fn parsePgn(allocator: std.mem.Allocator, pgn_text: []const u8) !Game +``` + +Suggested export API: + +```zig +pub fn formatPgn(allocator: std.mem.Allocator, game: Game) ![]u8 +``` + +or as a method: + +```zig +pub fn formatPgn(self: *const Game, allocator: std.mem.Allocator) ![]u8 +``` + +Parser should understand at least: + +- move numbers: `1.`, `1...` +- SAN moves +- castling: `O-O`, `O-O-O` +- captures: `x` +- promotion: `=Q`, `=R`, `=B`, `=N` +- check/checkmate suffixes: `+`, `#` +- game termination markers: `1-0`, `0-1`, `1/2-1/2`, `*` +- comments and tags can be added later if desired + +Reference: Steven J. Edwards, *Portable Game Notation Specification and Implementation Guide*, especially movetext/SAN sections. + +## Stage 9: Optional polish + +- Auto-scroll move list to latest move in play mode. +- Click a move row in playback mode to jump to that ply. +- Show current move highlight. +- Add scrollbar. +- Show numeric eval next to the analysis bar. +- Show best line/PV next to or below the move list. +- Add richer PGN copy/export options, such as including/excluding tag pairs. +- Display game result in the move panel. +- Show time remaining per move later if clocks are implemented. + +## Immediate next action + +Start with Stage 0, then Stage 1 and Stage 2 in UI-only files: + +- `src/board_input.zig` +- `src/text_render.zig` +- `src/main.zig` + +Do not modify `src/chess/` for these stages. diff --git a/src/chess/board.zig b/src/chess/board.zig index 2d828d3..4b7c0c8 100644 --- a/src/chess/board.zig +++ b/src/chess/board.zig @@ -66,6 +66,37 @@ pub const BoardState = struct { }; } + pub fn initStartingPosition() BoardState { + return .{ + .turn = piece.Color.white, + .castle_rights = 0b1111, + .en_passant = 0, + .halfmove = 0, + .fullmove = 1, + .bitboards = [_]u64{ + 0xFFFF00000000FFFF, // 0: all occupancy + + 0x00FF000000000000, // 1: black pawns + 0x4200000000000000, // 2: black knights + 0x2400000000000000, // 3: black bishops + 0x8100000000000000, // 4: black rooks + 0x0800000000000000, // 5: black queen + 0x1000000000000000, // 6: black king + 0xFFFF000000000000, // 7: black occupancy + + 0x0000000000000000, // 8: empty + + 0x000000000000FF00, // 9: white pawns + 0x0000000000000042, // 10: white knights + 0x0000000000000024, // 11: white bishops + 0x0000000000000081, // 12: white rooks + 0x0000000000000008, // 13: white queen + 0x0000000000000010, // 14: white king + 0x000000000000FFFF, // 15: white occupancy + }, + }; + } + pub fn getSquare(self: BoardState, square: bitboard.Square) u4 { const mask = bitboard.bit(square); for (self.bitboards[1..7], 1..) |board, i| { @@ -120,7 +151,7 @@ pub const BoardState = struct { } } - pub fn move(self: *BoardState, from_square: bitboard.Square, to_square: bitboard.Square, promotion_type: ?piece.PieceType) !void { + pub fn move(self: *BoardState, from_square: bitboard.Square, to_square: bitboard.Square, promotion_type: ?piece.PieceType) void { const from_file: u3 = @intCast(from_square % 8); const from_rank: u3 = @intCast(from_square / 8); const to_file: u3 = @intCast(to_square % 8); @@ -224,22 +255,22 @@ pub const BoardState = struct { } } - pub fn getValidMoves(self: *BoardState, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalMoves(self: *BoardState, square: bitboard.Square) bitboard.Bitboard { const p = self.getSquare(square); return switch (piece.typeOf(p)) { - piece.PieceType.pawn => self.getValidPawnMoves(p, square), - piece.PieceType.knight => self.getValidKnightMoves(p, square), - piece.PieceType.bishop => self.getValidBishopMoves(p, square), - piece.PieceType.rook => self.getValidRookMoves(p, square), - piece.PieceType.queen => self.getValidQueenMoves(p, square), - piece.PieceType.king => self.getValidKingMoves(p, square), + piece.PieceType.pawn => self.getPseudoLegalPawnMoves(p, square), + piece.PieceType.knight => self.getPseudoLegalKnightMoves(p, square), + piece.PieceType.bishop => self.getPseudoLegalBishopMoves(p, square), + piece.PieceType.rook => self.getPseudoLegalRookMoves(p, square), + piece.PieceType.queen => self.getPseudoLegalQueenMoves(p, square), + piece.PieceType.king => self.getPseudoLegalKingMoves(p, square), else => 0, }; } pub fn getLegalMoves(self: *BoardState, square: bitboard.Square) bitboard.Bitboard { var legal_moves: bitboard.Bitboard = 0; - var pseudo_moves = self.getValidMoves(square); + var pseudo_moves = self.getPseudoLegalMoves(square); const p = self.getSquare(square); const color = piece.colorOf(p) orelse return 0; @@ -306,7 +337,7 @@ pub const BoardState = struct { return !self.hasAnyLegalMove(color) and !self.isInCheck(color); } - pub fn getValidPawnMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalPawnMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { var valid_moves: bitboard.Bitboard = 0; const color = piece.colorOf(p) orelse return 0; switch (color) { @@ -347,7 +378,7 @@ pub const BoardState = struct { } return valid_moves; } - pub fn getValidKnightMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalKnightMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], @@ -357,7 +388,7 @@ pub const BoardState = struct { return attacks; } - pub fn getValidBishopMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalBishopMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], @@ -370,7 +401,7 @@ pub const BoardState = struct { return legal; } - pub fn getValidRookMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalRookMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], @@ -383,7 +414,7 @@ pub const BoardState = struct { return legal; } - pub fn getValidQueenMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalQueenMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { const color = piece.colorOf(p) orelse return 0; const friendlies = switch (color) { .white => self.bitboards[15], @@ -396,7 +427,7 @@ pub const BoardState = struct { return legal; } - pub fn getValidKingMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { + pub fn getPseudoLegalKingMoves(self: *BoardState, p: u4, square: bitboard.Square) bitboard.Bitboard { const color = piece.colorOf(p) orelse return 0; const friendlies = if (color == .white) self.bitboards[15] @@ -665,11 +696,11 @@ test "move toggles turn and increments fullmove after black move" { state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(1)), piece.encode(.black, .knight)); - try state.move(1, 18, null); + state.move(1, 18, null); try std.testing.expectEqual(piece.Color.black, state.turn); try std.testing.expectEqual(@as(u32, 1), state.fullmove); - try state.move(57, 42, null); + state.move(57, 42, null); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u32, 2), state.fullmove); } @@ -679,7 +710,7 @@ test "quiet non-pawn move increments halfmove clock" { state.halfmove = 4; state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); - try state.move(1, 18, null); + state.move(1, 18, null); try std.testing.expectEqual(@as(u8, 5), state.halfmove); } @@ -689,7 +720,7 @@ test "pawn move resets halfmove clock" { state.halfmove = 4; state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); - try state.move(12, 20, null); + state.move(12, 20, null); try std.testing.expectEqual(@as(u8, 0), state.halfmove); } @@ -700,7 +731,7 @@ test "capture resets halfmove clock" { state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); state.setSquare((@as(u6, @intCast(2)) * 8) + @as(u6, @intCast(2)), piece.encode(.black, .bishop)); - try state.move(1, 18, null); + state.move(1, 18, null); try std.testing.expectEqual(@as(u8, 0), state.halfmove); } @@ -709,7 +740,7 @@ test "white double pawn push sets en passant target to passed square" { var state = BoardState.empty(); state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); - try state.move(12, 28, null); + state.move(12, 28, null); try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 20), state.en_passant); } @@ -719,7 +750,7 @@ test "black double pawn push sets en passant target to passed square" { state.turn = .black; state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); - try state.move(51, 35, null); + state.move(51, 35, null); try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 43), state.en_passant); } @@ -729,7 +760,7 @@ test "single pawn move clears en passant target" { state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); - try state.move(12, 20, null); + state.move(12, 20, null); try std.testing.expectEqual(@as(u7, 0), state.en_passant); } @@ -739,7 +770,7 @@ test "quiet non-pawn move clears en passant target" { state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); - try state.move(1, 18, null); + state.move(1, 18, null); try std.testing.expectEqual(@as(u7, 0), state.en_passant); } @@ -753,7 +784,7 @@ test "non-king non-rook quiet move preserves castling rights while updating othe state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .knight)); - try state.move(1, 18, null); + state.move(1, 18, null); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); try std.testing.expectEqual(@as(u8, 4), state.halfmove); @@ -767,7 +798,7 @@ test "white king move clears white castling rights only" { state.castle_rights = 0b1111; state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); - try state.move(4, 12, null); + state.move(4, 12, null); try std.testing.expectEqual(@as(u4, 0b0011), state.castle_rights); } @@ -778,7 +809,7 @@ test "black king move clears black castling rights only" { state.castle_rights = 0b1111; state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); - try state.move(60, 52, null); + state.move(60, 52, null); try std.testing.expectEqual(@as(u4, 0b1100), state.castle_rights); } @@ -787,27 +818,27 @@ test "rook moves from original squares clear matching castling rights" { var white_king_side = BoardState.empty(); white_king_side.castle_rights = 0b1111; white_king_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); - try white_king_side.move(7, 15, null); + white_king_side.move(7, 15, null); try std.testing.expectEqual(@as(u4, 0b0111), white_king_side.castle_rights); var white_queen_side = BoardState.empty(); white_queen_side.castle_rights = 0b1111; white_queen_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); - try white_queen_side.move(0, 8, null); + white_queen_side.move(0, 8, null); try std.testing.expectEqual(@as(u4, 0b1011), white_queen_side.castle_rights); var black_king_side = BoardState.empty(); black_king_side.turn = .black; black_king_side.castle_rights = 0b1111; black_king_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); - try black_king_side.move(63, 55, null); + black_king_side.move(63, 55, null); try std.testing.expectEqual(@as(u4, 0b1101), black_king_side.castle_rights); var black_queen_side = BoardState.empty(); black_queen_side.turn = .black; black_queen_side.castle_rights = 0b1111; black_queen_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); - try black_queen_side.move(56, 48, null); + black_queen_side.move(56, 48, null); try std.testing.expectEqual(@as(u4, 0b1110), black_queen_side.castle_rights); } @@ -816,7 +847,7 @@ test "rook move from non-original square preserves castling rights" { state.castle_rights = 0b1111; state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .rook)); - try state.move(27, 35, null); + state.move(27, 35, null); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); } @@ -826,28 +857,28 @@ test "capturing rooks on original squares clears matching castling rights" { white_king_side.castle_rights = 0b1111; white_king_side.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); white_king_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); - try white_king_side.move(15, 7, null); + white_king_side.move(15, 7, null); try std.testing.expectEqual(@as(u4, 0b0111), white_king_side.castle_rights); var white_queen_side = BoardState.empty(); white_queen_side.castle_rights = 0b1111; white_queen_side.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); white_queen_side.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); - try white_queen_side.move(8, 0, null); + white_queen_side.move(8, 0, null); try std.testing.expectEqual(@as(u4, 0b1011), white_queen_side.castle_rights); var black_king_side = BoardState.empty(); black_king_side.castle_rights = 0b1111; black_king_side.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); black_king_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); - try black_king_side.move(55, 63, null); + black_king_side.move(55, 63, null); try std.testing.expectEqual(@as(u4, 0b1101), black_king_side.castle_rights); var black_queen_side = BoardState.empty(); black_queen_side.castle_rights = 0b1111; black_queen_side.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); black_queen_side.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(0)), piece.encode(.black, .rook)); - try black_queen_side.move(48, 56, null); + black_queen_side.move(48, 56, null); try std.testing.expectEqual(@as(u4, 0b1110), black_queen_side.castle_rights); } @@ -857,7 +888,7 @@ test "capturing non-rook on original rook square preserves castling rights" { state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .knight)); - try state.move(15, 7, null); + state.move(15, 7, null); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); } @@ -870,7 +901,7 @@ test "white double pawn push sets en passant and updates turn halfmove fullmove" state.castle_rights = 0b1111; state.setSquare((@as(u6, @intCast(1)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); - try state.move(12, 28, null); + state.move(12, 28, null); try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 20), state.en_passant); try std.testing.expectEqual(@as(u8, 0), state.halfmove); @@ -887,7 +918,7 @@ test "black double pawn push sets en passant and increments fullmove" { state.castle_rights = 0b1111; state.setSquare((@as(u6, @intCast(6)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); - try state.move(51, 35, null); + state.move(51, 35, null); try std.testing.expectEqual((@as(u7, 1) << 6) | @as(u7, 43), state.en_passant); try std.testing.expectEqual(@as(u8, 0), state.halfmove); @@ -921,7 +952,7 @@ test "white en passant capture removes captured pawn behind target" { state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); state.en_passant = (@as(u7, 1) << 6) | @as(u7, 43); - try state.move(36, 43, null); + state.move(36, 43, null); try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(43)); try std.testing.expectEqual(@as(u4, 0), state.getSquare(36)); @@ -938,7 +969,7 @@ test "black en passant capture removes captured pawn behind target" { state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .pawn)); state.en_passant = (@as(u7, 1) << 6) | @as(u7, 20); - try state.move(27, 20, null); + state.move(27, 20, null); try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(20)); try std.testing.expectEqual(@as(u4, 0), state.getSquare(27)); @@ -954,7 +985,7 @@ test "white pawn promotes to each legal promotion piece" { var state = BoardState.empty(); state.setSquare(52, piece.encode(.white, .pawn)); // e7 - try state.move(52, 60, promotion_type); // e8 + state.move(52, 60, promotion_type); // e8 try std.testing.expectEqual(piece.encode(.white, promotion_type), state.getSquare(60)); try std.testing.expectEqual(@as(u4, 0), state.getSquare(52)); @@ -972,7 +1003,7 @@ test "black pawn promotes to each legal promotion piece" { state.fullmove = 7; state.setSquare(11, piece.encode(.black, .pawn)); // d2 - try state.move(11, 3, promotion_type); // d1 + state.move(11, 3, promotion_type); // d1 try std.testing.expectEqual(piece.encode(.black, promotion_type), state.getSquare(3)); try std.testing.expectEqual(@as(u4, 0), state.getSquare(11)); @@ -987,7 +1018,7 @@ test "promotion capture replaces captured piece with promoted piece" { white_state.setSquare(52, piece.encode(.white, .pawn)); // e7 white_state.setSquare(59, piece.encode(.black, .rook)); // d8 - try white_state.move(52, 59, .knight); // exd8=N + white_state.move(52, 59, .knight); // exd8=N try std.testing.expectEqual(piece.encode(.white, .knight), white_state.getSquare(59)); try std.testing.expectEqual(@as(u4, 0), white_state.getSquare(52)); @@ -998,7 +1029,7 @@ test "promotion capture replaces captured piece with promoted piece" { black_state.setSquare(11, piece.encode(.black, .pawn)); // d2 black_state.setSquare(4, piece.encode(.white, .rook)); // e1 - try black_state.move(11, 4, .bishop); // dxe1=B + black_state.move(11, 4, .bishop); // dxe1=B try std.testing.expectEqual(piece.encode(.black, .bishop), black_state.getSquare(4)); try std.testing.expectEqual(@as(u4, 0), black_state.getSquare(11)); @@ -1009,7 +1040,7 @@ test "pawn reaching last rank without promotion type promotes to queen by defaul var white_state = BoardState.empty(); white_state.setSquare(52, piece.encode(.white, .pawn)); - try white_state.move(52, 60, null); + white_state.move(52, 60, null); try std.testing.expectEqual(piece.encode(.white, .queen), white_state.getSquare(60)); @@ -1017,7 +1048,7 @@ test "pawn reaching last rank without promotion type promotes to queen by defaul black_state.turn = .black; black_state.setSquare(11, piece.encode(.black, .pawn)); - try black_state.move(11, 3, null); + black_state.move(11, 3, null); try std.testing.expectEqual(piece.encode(.black, .queen), black_state.getSquare(3)); } @@ -1026,7 +1057,7 @@ test "promotion type is ignored for non-promotion pawn move" { var state = BoardState.empty(); state.setSquare(12, piece.encode(.white, .pawn)); // e2 - try state.move(12, 20, .queen); // e3, not a promotion square + state.move(12, 20, .queen); // e3, not a promotion square try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(20)); try std.testing.expectEqual(@as(u4, 0), state.getSquare(12)); @@ -1078,7 +1109,7 @@ fn squaresMask(squares: []const bitboard.Square) bitboard.Bitboard { } fn expectPawnMoves(state: *BoardState, from: bitboard.Square, expected: []const bitboard.Square) !void { - try std.testing.expectEqual(squaresMask(expected), state.getValidMoves(from)); + try std.testing.expectEqual(squaresMask(expected), state.getPseudoLegalMoves(from)); } fn expectLegalMoves(state: *BoardState, from: bitboard.Square, expected: []const bitboard.Square) !void { @@ -1591,7 +1622,7 @@ test "castling move execution moves the rook and clears castling rights" { state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(7)), piece.encode(.white, .rook)); - try state.move(4, 6, null); + state.move(4, 6, null); try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(6)); try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(5)); @@ -1600,6 +1631,142 @@ test "castling move execution moves the rook and clears castling rights" { try std.testing.expectEqual(@as(u4, 0b0000), state.castle_rights & 0b1100); } +fn setUpStartingPosition(state: *BoardState) void { + state.* = BoardState.empty(); + state.turn = .white; + state.castle_rights = 0b1111; + state.fullmove = 1; + + state.setSquare(0, piece.encode(.white, .rook)); + state.setSquare(1, piece.encode(.white, .knight)); + state.setSquare(2, piece.encode(.white, .bishop)); + state.setSquare(3, piece.encode(.white, .queen)); + state.setSquare(4, piece.encode(.white, .king)); + state.setSquare(5, piece.encode(.white, .bishop)); + state.setSquare(6, piece.encode(.white, .knight)); + state.setSquare(7, piece.encode(.white, .rook)); + for (8..16) |square| { + state.setSquare(@intCast(square), piece.encode(.white, .pawn)); + } + + state.setSquare(56, piece.encode(.black, .rook)); + state.setSquare(57, piece.encode(.black, .knight)); + state.setSquare(58, piece.encode(.black, .bishop)); + state.setSquare(59, piece.encode(.black, .queen)); + state.setSquare(60, piece.encode(.black, .king)); + state.setSquare(61, piece.encode(.black, .bishop)); + state.setSquare(62, piece.encode(.black, .knight)); + state.setSquare(63, piece.encode(.black, .rook)); + for (48..56) |square| { + state.setSquare(@intCast(square), piece.encode(.black, .pawn)); + } +} + +test "isCheckmate detects Fool's mate" { + var state = BoardState.empty(); + setUpStartingPosition(&state); + + state.move(13, 21, null); // 1. f3 + state.move(52, 36, null); // ... e5 + state.move(14, 30, null); // 2. g4 + state.move(59, 31, null); // ... Qh4# + + try std.testing.expect(state.isInCheck(.white)); + try std.testing.expect(state.isCheckmate(.white)); + try std.testing.expect(!state.isStalemate(.white)); +} + +test "isCheckmate detects Scholar's mate" { + var state = BoardState.empty(); + setUpStartingPosition(&state); + + state.move(12, 28, null); // 1. e4 + state.move(52, 36, null); // ... e5 + state.move(5, 26, null); // 2. Bc4 + state.move(57, 42, null); // ... Nc6 + state.move(3, 31, null); // 3. Qh5 + state.move(62, 45, null); // ... Nf6 + state.move(31, 53, null); // 4. Qxf7# + + try std.testing.expect(state.isInCheck(.black)); + try std.testing.expect(state.isCheckmate(.black)); + try std.testing.expect(!state.isStalemate(.black)); +} + +test "isStalemate detects known queen and king stalemate FEN position" { + var state = BoardState.empty(); + state.turn = .black; + state.fullmove = 1; + state.setSquare(63, piece.encode(.black, .king)); // h8 + state.setSquare(53, piece.encode(.white, .queen)); // f7 + state.setSquare(46, piece.encode(.white, .king)); // g6 + + try std.testing.expect(!state.isInCheck(.black)); + try std.testing.expect(!state.isCheckmate(.black)); + try std.testing.expect(state.isStalemate(.black)); +} + +test "promotion gives immediate check" { + var state = BoardState.empty(); + state.setSquare(0, piece.encode(.white, .king)); + state.setSquare(4, piece.encode(.black, .king)); // e1 + state.setSquare(52, piece.encode(.white, .pawn)); // e7 + + state.move(52, 60, .queen); // e8=Q+ + + try std.testing.expectEqual(piece.encode(.white, .queen), state.getSquare(60)); + try std.testing.expect(state.isInCheck(.black)); + try std.testing.expect(!state.isCheckmate(.black)); +} + +test "promotion choice can decide checkmate" { + var queen_state = BoardState.empty(); + queen_state.setSquare(46, piece.encode(.white, .king)); // g6 + queen_state.setSquare(53, piece.encode(.white, .pawn)); // f7 + queen_state.setSquare(63, piece.encode(.black, .king)); // h8 + + queen_state.move(53, 61, .queen); // f8=Q# + + try std.testing.expectEqual(piece.encode(.white, .queen), queen_state.getSquare(61)); + try std.testing.expect(queen_state.isInCheck(.black)); + try std.testing.expect(queen_state.isCheckmate(.black)); + + var knight_state = BoardState.empty(); + knight_state.setSquare(46, piece.encode(.white, .king)); // g6 + knight_state.setSquare(53, piece.encode(.white, .pawn)); // f7 + knight_state.setSquare(63, piece.encode(.black, .king)); // h8 + + knight_state.move(53, 61, .knight); // f8=N, not checkmate + + try std.testing.expectEqual(piece.encode(.white, .knight), knight_state.getSquare(61)); + try std.testing.expect(!knight_state.isInCheck(.black)); + try std.testing.expect(!knight_state.isCheckmate(.black)); +} + +test "promotion capture piece attacks as selected promotion type" { + var knight_state = BoardState.empty(); + knight_state.setSquare(0, piece.encode(.white, .king)); + knight_state.setSquare(52, piece.encode(.black, .king)); // e7 + knight_state.setSquare(54, piece.encode(.white, .pawn)); // g7 + knight_state.setSquare(62, piece.encode(.black, .rook)); // g8 + + knight_state.move(54, 62, .knight); // gxg8=N+ + + try std.testing.expectEqual(piece.encode(.white, .knight), knight_state.getSquare(62)); + try std.testing.expect(knight_state.isInCheck(.black)); + + var bishop_state = BoardState.empty(); + bishop_state.setSquare(0, piece.encode(.white, .king)); + bishop_state.setSquare(35, piece.encode(.black, .king)); // d5 + bishop_state.setSquare(49, piece.encode(.white, .pawn)); // b7 + bishop_state.setSquare(56, piece.encode(.black, .rook)); // a8 + + bishop_state.move(49, 56, .bishop); // bxa8=B+ + + try std.testing.expectEqual(piece.encode(.white, .bishop), bishop_state.getSquare(56)); + try std.testing.expect(bishop_state.isInCheck(.black)); +} + test "isCheckmate detects corner queen and king mate" { var state = BoardState.empty(); state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .king)); diff --git a/src/chess/fen.zig b/src/chess/fen.zig index cd10084..704e3f7 100644 --- a/src/chess/fen.zig +++ b/src/chess/fen.zig @@ -575,7 +575,7 @@ test "formatFen includes promoted white pieces" { state.fullmove = 1; state.setSquare(52, piece.encode(.white, .pawn)); - try state.move(52, 60, case.promotion_type); + state.move(52, 60, case.promotion_type); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); @@ -601,7 +601,7 @@ test "formatFen includes promoted black pieces" { state.fullmove = 7; state.setSquare(11, piece.encode(.black, .pawn)); - try state.move(11, 3, case.promotion_type); + state.move(11, 3, case.promotion_type); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); diff --git a/src/chess/game.zig b/src/chess/game.zig new file mode 100644 index 0000000..8346de0 --- /dev/null +++ b/src/chess/game.zig @@ -0,0 +1,134 @@ +const std = @import("std"); + +const board = @import("board.zig"); +const piece = @import("piece.zig"); +const bitboard = @import("bitboard.zig"); +const fen = @import("fen.zig"); + +const Game = struct { + initial_state: board.BoardState, + state: board.BoardState, + moves: std.ArrayList(MoveRecord), + time_control: TimeControl, + clocks: [2]u32, + status: GameStatus, + + pub fn deinit(self: *Game, allocator: std.mem.Allocator) void { + self.moves.deinit(allocator); + } + + pub fn makeMove(self: *Game, from: bitboard.Square, to: bitboard.Square, promotion: ?piece.PieceType) void { + self.state.move(from, to, promotion); + } + + pub fn legalMoves(self: *Game, square: bitboard.Square) bitboard.Bitboard { + if (!self.state.isCheckmate(self.state.turn) and !self.state.isStalemate(self.state.turn)) { + return self.state.getLegalMoves(square); + } + return 0; + } +}; + +const MoveRecord = struct { + from: u6, + to: u6, + promotion: u4, + time_remaining: u32, + captured_piece: u4, + previous_castling_rights: u4, + previous_en_passant: u7, + previous_halfmove: u8, + previous_fullmove: u32, +}; + +const TimeControl = enum { + tentwo, + fiveone, +}; + +const GameStatus = enum { + not_started, + playing, + check, + stalemate, + checkmate_black, + checkmate_white, +}; + +pub fn initStartingPosition(time_control: TimeControl) Game { + const state = board.BoardState.initStartingPosition(); + return .{ + .initial_state = state, + .state = state, + .moves = .empty, + .time_control = time_control, + .status = .not_started, + .clocks = [_]u32{0} ** 2, + }; +} + +pub fn initFromFen(fen_string: []const u8, time_control: TimeControl) !Game { + const state = try fen.parseFen(fen_string); + return .{ + .initial_state = state, + .state = state, + .moves = .empty, + .time_control = time_control, + .status = .not_started, + .clocks = [_]u32{0} ** 2, + }; +} + +test "initStartingPosition creates independent current and initial states" { + var game = initStartingPosition(.tentwo); + defer game.deinit(std.testing.allocator); + + const expected = board.BoardState.initStartingPosition(); + try std.testing.expectEqualDeep(expected, game.initial_state); + try std.testing.expectEqualDeep(expected, game.state); + try std.testing.expectEqual(@as(usize, 0), game.moves.items.len); + try std.testing.expectEqual(TimeControl.tentwo, game.time_control); + try std.testing.expectEqual(GameStatus.not_started, game.status); + try std.testing.expectEqual(@as(u32, 0), game.clocks[0]); + try std.testing.expectEqual(@as(u32, 0), game.clocks[1]); + + game.makeMove(12, 28, null); // e2-e4 + + try std.testing.expectEqualDeep(expected, game.initial_state); + try std.testing.expectEqual(piece.encode(.white, .pawn), game.state.getSquare(28)); + try std.testing.expectEqual(@as(u4, 0), game.state.getSquare(12)); +} + +test "initFromFen stores parsed position as initial and current state" { + const fen_string = "7k/5Q2/6K1/8/8/8/8/8 b - - 0 1"; + var game = try initFromFen(fen_string, .fiveone); + defer game.deinit(std.testing.allocator); + + const expected = try fen.parseFen(fen_string); + try std.testing.expectEqualDeep(expected, game.initial_state); + try std.testing.expectEqualDeep(expected, game.state); + try std.testing.expectEqual(@as(usize, 0), game.moves.items.len); + try std.testing.expectEqual(TimeControl.fiveone, game.time_control); + try std.testing.expectEqual(GameStatus.not_started, game.status); +} + +test "legalMoves returns legal destinations while game is active" { + var game = initStartingPosition(.tentwo); + defer game.deinit(std.testing.allocator); + + const e2: bitboard.Square = 12; + const e3: bitboard.Square = 20; + const e4: bitboard.Square = 28; + + try std.testing.expectEqual(bitboard.bit(e3) | bitboard.bit(e4), game.legalMoves(e2)); +} + +test "legalMoves returns no destinations after checkmate or stalemate" { + var mate_game = try initFromFen("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", .tentwo); + defer mate_game.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(bitboard.Bitboard, 0), mate_game.legalMoves(4)); // white king e1 + + var stalemate_game = try initFromFen("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", .tentwo); + defer stalemate_game.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(bitboard.Bitboard, 0), stalemate_game.legalMoves(63)); // black king h8 +} diff --git a/src/main.zig b/src/main.zig index d57b0b9..4bc9733 100644 --- a/src/main.zig +++ b/src/main.zig @@ -114,6 +114,17 @@ fn promotionColorForMove(state: chess_board.BoardState, move: board_input.MoveRe }; } +fn isGameOverForTurn(state: chess_board.BoardState) bool { + var state_copy = state; + return state_copy.isCheckmate(state_copy.turn) or state_copy.isStalemate(state_copy.turn); +} + +fn legalMovesIfGameActive(state: chess_board.BoardState, square: bitboard.Square) bitboard.Bitboard { + if (isGameOverForTurn(state)) return 0; + var state_copy = state; + return state_copy.getLegalMoves(square); +} + fn gameStatusForTurn(state: chess_board.BoardState) GameStatus { var state_copy = state; const side_to_move = state_copy.turn; @@ -681,7 +692,7 @@ pub fn main(init: std.process.Init) !void { .none => {}, .drag_started => |held_square| { valid_moves = 0; - valid_moves = current_state.getLegalMoves(held_square); + valid_moves = legalMovesIfGameActive(current_state, held_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -771,7 +782,7 @@ pub fn main(init: std.process.Init) !void { board, promotion.move.to, )) |promotion_type| { - try current_state.move(promotion.move.from, promotion.move.to, promotion_type); + current_state.move(promotion.move.from, promotion.move.to, promotion_type); } } pending_promotion = null; @@ -1006,7 +1017,7 @@ pub fn main(init: std.process.Init) !void { log.debug("selected square file={} rank={}", .{ selected_coord.file, selected_coord.rank }); hovered_square = selected_coord; valid_moves = 0; - valid_moves = current_state.getLegalMoves(selected_square); + valid_moves = legalMovesIfGameActive(current_state, selected_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -1042,7 +1053,7 @@ pub fn main(init: std.process.Init) !void { if (promotionColorForMove(current_state, move)) |promotion_color| { pending_promotion = .{ .move = move, .color = promotion_color }; } else { - try current_state.move(move.from, move.to, null); + current_state.move(move.from, move.to, null); } } else { log.debug("invalid click move requested; clearing selection", .{}); @@ -1087,7 +1098,7 @@ pub fn main(init: std.process.Init) !void { hovered_square = selected_coord_after_release; valid_moves = 0; if (selected_after_release) |selected_square| { - valid_moves = current_state.getLegalMoves(selected_square); + valid_moves = legalMovesIfGameActive(current_state, selected_square); } try rebuildBoardAndPieceRenderResources( vc, @@ -1119,7 +1130,7 @@ pub fn main(init: std.process.Init) !void { const selected_coord = board_input.squareToCoord(selected_square); hovered_square = selected_coord; valid_moves = 0; - valid_moves = current_state.getLegalMoves(selected_square); + valid_moves = legalMovesIfGameActive(current_state, selected_square); try rebuildBoardAndPieceRenderResources( vc, ldc, @@ -1180,7 +1191,7 @@ pub fn main(init: std.process.Init) !void { if (promotionColorForMove(current_state, move)) |promotion_color| { pending_promotion = .{ .move = move, .color = promotion_color }; } else { - try current_state.move(move.from, move.to, null); + current_state.move(move.from, move.to, null); } } else { log.debug("invalid drag move requested; clearing selection", .{});