docs: fix test positions in chess check/checkmate/stalemate spec

This commit is contained in:
2026-03-23 20:16:56 +01:00
parent 2b2f64695e
commit c354b77f88
@@ -27,40 +27,53 @@ GameRules
```
#### `isInCheck(board, color)`
Finds the king square for `color`, then checks whether any enemy piece's `MoveValidator.legalTargets` contains that square. Returns `true` if the king is under attack.
Finds the king square for `color` by scanning `board.pieces` for a `Piece(color, PieceType.King)`. If no king is found (constructed/test boards), returns `false`.
Then checks whether any enemy piece's `MoveValidator.legalTargets` contains that square. This works correctly for all piece types, including the king: `kingTargets` returns the squares the king can move to, which are identical to the squares the king attacks, so using `legalTargets` for attack detection is correct by design.
Returns `true` if the king square is covered by at least one enemy piece.
#### `legalMoves(board, color)`
For every piece of `color` on the board, collect `MoveValidator.legalTargets`. Filter each candidate move by applying it to the board (`board.withMove`) and verifying `isInCheck` is `false` on the resulting board. Returns the full set of `(from, to)` pairs that are truly legal.
1. Filter `board.pieces` to entries where `piece.color == color`.
2. For each such `(from, piece)`, call `MoveValidator.legalTargets(board, from)` to get geometric candidates.
3. For each candidate `to`, apply `board.withMove(from, to)` to get `newBoard`.
4. Keep only moves where `isInCheck(newBoard, color)` is `false` (i.e., the move does not leave own king in check).
5. Return the full set of `(from, to)` pairs that survive this filter.
#### `gameStatus(board, color)`
Returns a `PositionStatus` enum value:
- `Checkmate``legalMoves` is empty **and** king is in check → the side to move loses
- `Stalemate``legalMoves` is empty **and** king is **not** in check → draw
Returns a `PositionStatus` enum value based on `legalMoves(board, color)` and `isInCheck(board, color)`:
- `Mated``legalMoves` is empty **and** king is in check → the side to move has been checkmated
- `Drawn``legalMoves` is empty **and** king is **not** in check → stalemate (draw)
- `InCheck``legalMoves` is non-empty **and** king is in check → game continues under check
- `Normal` — otherwise
#### Local `PositionStatus` enum
Defined inside `GameRules.scala` (or its companion package):
Defined in `GameRules.scala`. Names are intentionally distinct from `MoveResult` variants to avoid unqualified-name collisions in `GameController.scala`:
```scala
enum PositionStatus:
case Normal, InCheck, Checkmate, Stalemate
case Normal, InCheck, Mated, Drawn
```
---
### Modified: `MoveResult` (in `GameController.scala`)
Two new variants are added; existing variants are unchanged:
Three new variants; existing variants are unchanged:
| Variant | When used |
|---|---|
| `MovedInCheck(newBoard, captured, newTurn)` | Move was legal; opponent is now in check |
| `Checkmate(winner: Color)` | Move was legal; opponent has no legal reply → winner is the side that just moved |
| `Stalemate` | Move was legal; opponent has no legal reply and is not in check → draw |
| `MovedInCheck(newBoard, captured, newTurn)` | Move was legal; opponent is now in check but has legal replies |
| `Checkmate(winner: Color)` | Move was legal; opponent is `Mated``winner` is the side that just moved |
| `Stalemate` | Move was legal; opponent is `Drawn` (no legal reply, not in check) |
`Moved` continues to be used for all other successful moves.
`Moved` continues to be used when `gameStatus` returns `Normal`.
---
@@ -69,24 +82,29 @@ Two new variants are added; existing variants are unchanged:
After computing `(newBoard, captured)` from `board.withMove`:
1. Call `GameRules.gameStatus(newBoard, newTurn)`.
2. Map the result to the appropriate `MoveResult` variant.
2. Map to the appropriate `MoveResult`:
```
Normal → Moved(newBoard, captured, newTurn)
InCheck → MovedInCheck(newBoard, captured, newTurn)
Checkmate → Checkmate(turn) // turn = the side that just moved
Stalemate → Stalemate
PositionStatus.Normal → Moved(newBoard, captured, newTurn)
PositionStatus.InCheck → MovedInCheck(newBoard, captured, newTurn)
PositionStatus.Mated → Checkmate(turn) // turn = the side that just moved
PositionStatus.Drawn → Stalemate
```
---
### Modified: `GameController.gameLoop`
Two new terminal branches:
**New terminal branches** (both print a message then restart):
- `Checkmate(winner)` → print `"Checkmate! {winner} wins."`, then recurse with `(Board.initial, Color.White)`
- `Checkmate(winner)` → print `"Checkmate! {winner.label} wins."`, then recurse with `(Board.initial, Color.White)`
- `Stalemate` → print `"Stalemate! The game is a draw."`, then recurse with `(Board.initial, Color.White)`
- `MovedInCheck` → print `"{newTurn} is in check!"`, then recurse normally with the new board and turn
**New non-terminal branch:**
- `MovedInCheck(newBoard, captured, newTurn)` → print the same optional capture message as `Moved` (when `captured.isDefined`), then print `"{newTurn.label} is in check!"`, then recurse with `(newBoard, newTurn)`
**Restart vs. exit:** Checkmate and stalemate restart the game automatically (no prompt). This is intentionally asymmetric with `Quit`, which exits. `Quit` is an explicit user request to stop; Checkmate/Stalemate are natural game endings that should roll into a new game.
---
@@ -94,36 +112,47 @@ Two new terminal branches:
All tests are unit tests extending `AnyFunSuite with Matchers with JUnitSuiteLike`.
### `GameRulesTest`
### `GameRulesTest` — new file
| Scenario | Method under test |
|---|---|
| King is attacked by an enemy rook | `isInCheck` true |
| King is not attacked | `isInCheck` false |
| Move that exposes king is filtered out | `legalMoves` excludes it |
| Checkmate position (e.g. back-rank mate) | `gameStatus` → Checkmate |
| Stalemate position | `gameStatus` → Stalemate |
| In-check position with at least one escape | `gameStatus` → InCheck |
| Normal position | `gameStatus` → Normal |
| Scenario | Method | Expected |
|---|---|---|
| King attacked by enemy rook on same rank | `isInCheck` | `true` |
| King not attacked (only own pieces nearby) | `isInCheck` | `false` |
| No king on board (constructed board) | `isInCheck` | `false` |
| Move that exposes own king to rook is excluded | `legalMoves` | does not contain that move |
| Move that blocks check is included | `legalMoves` | contains the blocking move |
| Checkmate: White Qh8, Ka6; Black Ka8 — Black king is in check (Qh8 along rank 8), cannot escape to a7 (Ka6), b7 (Ka6), or b8 (Qh8) | `gameStatus` | `Mated` |
| Stalemate: White Qb6, Kc6; Black Ka8 — Black king has no legal moves (a7/b7/b8 all controlled by Qb6), not in check | `gameStatus` | `Drawn` |
| King in check with at least one escape square | `gameStatus` | `InCheck` |
| Normal midgame position, not in check, has moves | `gameStatus` | `Normal` |
### `GameControllerTest` additions
### `GameControllerTest` additions — new `processMove` cases
| Scenario | Expected `MoveResult` |
|---|---|
| Move leaves opponent in check | `MovedInCheck` |
| Move results in checkmate | `Checkmate(winner)` |
| Move leaves opponent in check (has escape) | `MovedInCheck` |
| Move results in checkmate | `Checkmate(winner)` where winner is the side that moved |
| Move results in stalemate | `Stalemate` |
### `GameControllerTest` additions — new `gameLoop` cases
| Scenario | Expected output / behavior |
|---|---|
| `gameLoop` receives `Checkmate(White)` | Prints "Checkmate! White wins." and continues (new game) |
| `gameLoop` receives `Stalemate` | Prints "Stalemate! The game is a draw." and continues (new game) |
| `gameLoop` receives `MovedInCheck` with a capture | Prints capture message AND check message |
| `gameLoop` receives `MovedInCheck` without a capture | Prints check message only |
---
## Development Workflow (TDD)
1. Create `GameRules` with empty/stub method bodies (compile but return placeholder values).
2. Write all `GameRulesTest` tests — they should fail.
3. Implement `GameRules` logic until tests pass.
4. Add new `MoveResult` variants and stub `processMove` changes.
5. Write new `GameControllerTest` cases — they should fail.
6. Implement `processMove` and `gameLoop` changes until tests pass.
1. Create `GameRules.scala` with empty/stub method bodies that compile but return placeholder values (`false`, `Set.empty`, `PositionStatus.Normal`).
2. Write all `GameRulesTest` tests — they should **fail**.
3. Implement `GameRules` logic until `GameRulesTest` is green.
4. Add new `MoveResult` variants to `GameController.scala`; update `processMove` to call `GameRules.gameStatus` (stub the match arms initially).
5. Write new `GameControllerTest` cases — they should **fail**.
6. Implement `processMove` match arms and `gameLoop` new branches until all tests pass.
7. Run `./gradlew :modules:core:test` — full green build required.
---