docs: update castling design spec (rev 2 — spec review fixes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-24 10:47:26 +01:00
parent b6ab8ed6ac
commit 205ade8d88
@@ -1,7 +1,7 @@
# Castling Implementation Design # Castling Implementation Design
**Date:** 2026-03-24 **Date:** 2026-03-24
**Status:** Approved **Status:** Approved (rev 2)
**Branch:** castling **Branch:** castling
--- ---
@@ -38,71 +38,153 @@ case class GameContext(
`GameContext.initial` wraps `Board.initial` with `CastlingRights.Both` for both sides. `GameContext.initial` wraps `Board.initial` with `CastlingRights.Both` for both sides.
`gameLoop` and `processMove` replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`. All `MoveResult` variants that previously carried `newBoard: Board` now carry `newCtx: GameContext`. `gameLoop` and `processMove` replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`. All `MoveResult` variants that previously carried `newBoard: Board` now carry `newCtx: GameContext`. The `gameLoop` render call becomes `Renderer.render(ctx.board)`, and all `gameLoop` pattern match arms that destructure `MoveResult.Moved(newBoard, ...)` or `MoveResult.MovedInCheck(newBoard, ...)` must be updated to destructure `newCtx` and pass it to the recursive `gameLoop` call.
--- ---
## Section 2 — Board Extension for Castle Moves ## Section 2 — `CastleSide` and Board Extension for Castle Moves
`Board.withMove(from, to)` moves a single piece. Castling moves two pieces atomically. A new extension method is added to `Board`: ### `CastleSide` enum
`CastleSide` is a two-value engine-internal enum defined in `core` (not in `api`). It is co-located in `GameContext.scala` — there is no separate `CastleSide.scala` file.
```scala ```scala
def withCastle(color: Color, side: CastleSide): Board enum CastleSide:
case Kingside, Queenside
``` ```
### `withCastle` extension
`Board.withMove(from, to)` moves a single piece. Castling moves two pieces atomically. To avoid a circular dependency (`api` must not import from `core`), `withCastle` is **not** added to `Board` in the `api` module. Instead it is defined as an extension method in `core`, co-located with `GameContext`:
```scala
// inside GameContext.scala or a BoardCastleOps.scala in core
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board = ...
```
Post-castle square assignments:
- **Kingside White:** King e1→g1, Rook h1→f1 - **Kingside White:** King e1→g1, Rook h1→f1
- **Queenside White:** King e1→c1, Rook a1→d1 - **Queenside White:** King e1→c1, Rook a1→d1
- **Kingside Black:** King e8→g8, Rook h8→f8 - **Kingside Black:** King e8→g8, Rook h8→f8
- **Queenside Black:** King e8→c8, Rook a8→d8 - **Queenside Black:** King e8→c8, Rook a8→d8
`CastleSide` is a two-value enum (`Kingside | Queenside`) defined alongside `GameContext` in `core`.
--- ---
## Section 3 — `MoveValidator` Castling Logic ## Section 3 — `MoveValidator` Castling Logic
A new method `castlingTargets(ctx: GameContext, color: Color): Set[Square]` is added to `MoveValidator`. It returns the king destination squares for which castling is currently legal. For each side, it checks all six conditions in order (failing fast): ### Signature change
1. `CastlingRights` flag is `true` for that side `legalTargets` and `isLegal` are extended to accept `GameContext` when the caller has full game context. To avoid breaking `GameRules.isInCheck` (which uses `legalTargets` with only a `Board` for attacked-square detection), the implementation retains a **board-only private helper** for sliding/jump/normal king targets, and a **public overload** that additionally unions castling targets when a `GameContext` is provided:
```scala
// board-only (used internally by isInCheck)
def legalTargets(board: Board, from: Square): Set[Square]
// context-aware (used by legalMoves and processMove)
def legalTargets(ctx: GameContext, from: Square): Set[Square]
```
The `GameContext` overload delegates to the `Board` overload for all piece types except King, where it additionally unions `castlingTargets(ctx, color)`.
`isLegal` is likewise overloaded:
```scala
// board-only (retained for callers that have no castling context)
def isLegal(board: Board, from: Square, to: Square): Boolean
// context-aware (used by processMove)
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean
```
The context-aware `isLegal(ctx, from, to)` calls `legalTargets(ctx, from).contains(to)` — using the context-aware overload — so castling targets are included in the legality check.
### `castlingTargets` method
```scala
def castlingTargets(ctx: GameContext, color: Color): Set[Square]
```
For each side (kingside, queenside), checks all six conditions in order (failing fast):
1. `CastlingRights` flag is `true` for that side (`ctx.castlingFor(color)`)
2. King is on its home square (e1 for White, e8 for Black) 2. King is on its home square (e1 for White, e8 for Black)
3. Relevant rook is on its home square (h-file for kingside, a-file for queenside) 3. Relevant rook is on its home square (h-file for kingside, a-file for queenside)
4. All squares between king and rook are empty 4. All squares between king and rook are empty
5. King is **not currently in check** (`GameRules.isInCheck`) 5. King is **not currently in check** — calls `GameRules.isInCheck(ctx.board, color)` using the board-only path (no castling recursion)
6. Each square the king **passes through and lands on** is not attacked by any enemy piece 6. Each square the king **passes through and lands on** is not attacked — checks that no enemy `legalTargets(board, enemySq)` (board-only) covers those squares
Transit and landing squares: Transit and landing squares:
- Kingside: f1/g1 (White), f8/g8 (Black) - **Kingside:** f-file, g-file (White: f1, g1; Black: f8, g8)
- Queenside: d1/c1 (White), d8/c8 (Black) — b1/b8 must be empty but king does not pass through them - **Queenside:** d-file, c-file (White: d1, c1; Black: d8, c8). Note: b1/b8 must be empty (condition 4) but the king does not pass through them, so they are not checked for attacks.
`legalTargets(board, from)` is extended to accept `GameContext` instead of `Board`. When the piece on `from` is a King, the result unions normal king moves with `castlingTargets`. All other piece types are unaffected.
The public `isLegal(ctx, from, to)` delegate is updated accordingly.
--- ---
## Section 4 — `GameController` Changes ## Section 4 — `GameRules` Changes
### Move detection `GameRules.legalMoves` must accept `GameContext` (not just `Board`) so it can enumerate castling moves as part of the legal move set. This is required for correct stalemate and checkmate detection — a position where the only legal move is to castle must not be evaluated as stalemate.
`processMove` identifies a castling move by the king moving exactly two files laterally from its home square: ```scala
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)]
```
Internally it calls `MoveValidator.legalTargets(ctx, from)` (the context-aware overload) for all pieces of `color`, then filters to moves that do not leave the king in check.
`isInCheck` retains its `(board: Board, color: Color)` signature — it does not need castling context.
`gameStatus` is updated to accept `GameContext`:
```scala
def gameStatus(ctx: GameContext, color: Color): PositionStatus
```
---
## Section 5 — `GameController` Changes
### Move detection and execution
`processMove` identifies a castling move by the king occupying its home square and moving exactly two files laterally:
- White: e1→g1 (kingside) or e1→c1 (queenside) - White: e1→g1 (kingside) or e1→c1 (queenside)
- Black: e8→g8 (kingside) or e8→c8 (queenside) - Black: e8→g8 (kingside) or e8→c8 (queenside)
### Castle execution Legality is confirmed via `MoveValidator.isLegal(ctx, from, to)` (the context-aware overload, which includes castling targets). When a castling move is legal and executed:
1. Call `ctx.board.withCastle(color, side)` to move both pieces atomically.
When a castling move is detected and validated:
1. Call `board.withCastle(color, side)` to move both pieces atomically.
2. Revoke **both** castling rights for the moving color in the new `GameContext`. 2. Revoke **both** castling rights for the moving color in the new `GameContext`.
### Rights revocation on rook moves ### Rights revocation rules (applied on every move)
When any piece moves from a rook's home square, the corresponding right is revoked: After every move `(from, to)` is applied, revoke rights based on both the **source square** and the **destination square**. Both tables are checked independently and all triggered revocations are applied.
- `a1` move → revoke white queenside
- `h1` move → revoke white kingside
- `a8` move → revoke black queenside
- `h8` move → revoke black kingside
This is evaluated on every normal move in `processMove`, not just rook moves (a king capturing on a1 should also revoke queenside rights). **Source square → revocation** (piece leaves its home square):
| Source square | Rights revoked |
|---------------|---------------|
| `e1` | Both White castling rights |
| `e8` | Both Black castling rights |
| `a1` | White queenside |
| `h1` | White kingside |
| `a8` | Black queenside |
| `h8` | Black kingside |
**Destination square → revocation** (a piece — including an enemy piece — arrives on a rook home square, meaning a capture removed the rook):
| Destination square | Rights revoked |
|--------------------|---------------|
| `a1` | White queenside |
| `h1` | White kingside |
| `a8` | Black queenside |
| `h8` | Black kingside |
This covers the following cases:
- **King normal move** — source square e1/e8 fires; both rights revoked.
- **King castle move** — the castle-specific step 2 revokes both rights for the moving color. Additionally, the source-square table fires (king departs e1/e8), revoking the same rights a second time. This double-revocation is idempotent and harmless. The king's destination (g1/c1/g8/c8) does not appear in the destination table, so no extra revocation fires there.
- **Own rook move** — source square a1/h1/a8/h8 fires.
- **Enemy capture on a rook home square** — destination square a1/h1/a8/h8 fires, revoking the side that lost the rook.
`processMove` also calls `GameRules.gameStatus(newCtx, turn.opposite)` — note this call passes the full `GameContext`, not just a `Board`, because `gameStatus` now accepts `GameContext`.
The revocation is applied to the `GameContext` that results from the move, before it is returned in `MoveResult`.
### Signatures ### Signatures
@@ -111,13 +193,13 @@ def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult
def gameLoop(ctx: GameContext, turn: Color): Unit def gameLoop(ctx: GameContext, turn: Color): Unit
``` ```
`MoveResult.Moved`, `MovedInCheck` carry `newCtx: GameContext` instead of `newBoard: Board`. `MoveResult.Moved` and `MoveResult.MovedInCheck` carry `newCtx: GameContext` instead of `newBoard: Board`. All `gameLoop` pattern match arms are updated to use `newCtx`. The render call uses `newCtx.board`.
On checkmate/stalemate reset, `GameContext.initial` is used. On checkmate/stalemate reset, `GameContext.initial` is used.
--- ---
## Section 5 — Move Notation ## Section 6 — Move Notation
The player types standard coordinate notation: The player types standard coordinate notation:
- `e1g1` → White kingside castle - `e1g1` → White kingside castle
@@ -129,29 +211,33 @@ No parser changes required. The controller identifies castling by the king movin
--- ---
## Section 6 — Testing ## Section 7 — Testing
### `MoveValidatorTest` ### `MoveValidatorTest`
- Castling target is returned when all conditions are met (kingside White) - Castling target (g1) is returned when all kingside conditions are met (White)
- Castling target is returned when all conditions are met (queenside White) - Castling target (c1) is returned when all queenside conditions are met (White)
- Castling target is returned for Black kingside and queenside - Castling targets returned for Black kingside (g8) and queenside (c8)
- Castling blocked when transit square is occupied - Castling blocked when transit square is occupied (piece between king and rook)
- Castling blocked when king is in check - Castling blocked when king is in check (condition 5)
- Castling blocked when transit/landing square is attacked - Castling blocked when **transit square** is attacked (e.g., f1 attacked for White kingside)
- Castling blocked when **landing square** is attacked (e.g., g1 attacked for White kingside)
- Castling blocked when `kingSide = false` in `CastlingRights` - Castling blocked when `kingSide = false` in `CastlingRights`
- Castling blocked when `queenSide = false` in `CastlingRights` - Castling blocked when `queenSide = false` in `CastlingRights`
- Castling blocked when rook is not on its home square - Castling blocked when relevant rook is not on its home square
### `GameControllerTest` ### `GameControllerTest`
- `processMove` with `e1g1` returns `Moved` with king on g1 and rook on f1 - `processMove` with `e1g1` returns `Moved` with king on g1, rook on f1, and both White castling rights revoked in `newCtx`
- `processMove` with `e1c1` returns `Moved` with king on c1 and rook on d1 - `processMove` with `e1c1` returns `Moved` with king on c1, rook on d1, and both White castling rights revoked in `newCtx`
- `processMove` castle attempt after king has moved returns `IllegalMove` - `processMove` castle attempt after king has moved returns `IllegalMove`
- `processMove` castle attempt after rook has moved returns `IllegalMove` - `processMove` castle attempt after rook has moved returns `IllegalMove`
- Normal rook move from h1 revokes kingside rights in the returned context - Normal rook move from h1 revokes White kingside right in the returned `newCtx`
- Normal king move from e1 revokes both White rights in the returned `newCtx`
- Enemy capture on h1 (e.g., Black rook captures White rook on h1) revokes White kingside right in the returned `newCtx`
### `GameRulesTest` ### `GameRulesTest`
- `legalMoves` includes castling destinations when available - `legalMoves` includes castling destinations when available
- `legalMoves` excludes castling when king is in check - `legalMoves` excludes castling when king is in check
- `gameStatus` returns `Normal` (not `Drawn`) when the only legal move available is to castle — verifying that the `GameContext` signature change correctly prevents a false stalemate
--- ---
@@ -159,12 +245,11 @@ No parser changes required. The controller identifies castling by the king movin
| Action | File | | Action | File |
|--------|------| |--------|------|
| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` | | **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` — includes `CastleSide` enum and `withCastle` Board extension |
| **Modify** | `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — add `withCastle` | | **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` — add `castlingTargets`, board-only + context-aware `legalTargets`/`isLegal` overloads |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` — add `castlingTargets`, update signatures | | **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` — update `legalMoves` and `gameStatus` to accept `GameContext` |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` — update `legalMoves` signature | | **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` — use `GameContext`; castling detection, execution, rights revocation |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` — use `GameContext` |
| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` — use `GameContext.initial` | | **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` — use `GameContext.initial` |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` — new tests | | **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` — new castling tests |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` — update + new tests | | **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` — update signatures + new castling tests |
| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` — update + new tests | | **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` — update signatures + new castling tests |