diff --git a/docs/superpowers/specs/2026-03-30-50-move-rule-design.md b/docs/superpowers/specs/2026-03-30-50-move-rule-design.md new file mode 100644 index 0000000..d5252d8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-50-move-rule-design.md @@ -0,0 +1,98 @@ +# 50-Move Rule — Design Spec +**Branch:** feat/NCS-11 +**Date:** 2026-03-30 + +--- + +## Overview + +Implement the 50-move rule: a player may claim a draw if no pawn move or capture has occurred in the last 50 half-moves (plies). The rule is **not** enforced automatically — the eligible player must actively claim it via a TUI menu option. + +--- + +## Architecture + +### Approach +Thread `halfMoveClock: Int` explicitly through `processMove` and `gameLoop` (Approach A). This mirrors the existing `halfMoveClock` field in `GameState` (already parsed from FEN) and requires no changes to `GameHistory` or `HistoryMove`. + +--- + +## Section 1: Data & Signatures + +### `MoveResult` changes (`GameController.scala`) + +```scala +case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], + newHalfMoveClock: Int, newTurn: Color) extends MoveResult +case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], + newHalfMoveClock: Int, newTurn: Color) extends MoveResult +case object DrawClaimed extends MoveResult +``` + +`Stalemate` remains unchanged (automatic draw, no clock involved). + +### `processMove` signature + +```scala +def processMove(board: Board, history: GameHistory, turn: Color, + halfMoveClock: Int, raw: String): MoveResult +``` + +### `gameLoop` signature + +```scala +def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit +``` + +### Clock update logic (inside `processMove`, after a legal move) + +```scala +val movedPiece = board.pieceAt(from).get +val isReset = movedPiece.pieceType == PieceType.Pawn || captured.isDefined || isEP +val newClock = if isReset then 0 else halfMoveClock + 1 +``` + +--- + +## Section 2: TUI Menu / `gameLoop` Behaviour + +When `halfMoveClock >= 50` at the start of a player's turn, `gameLoop` shows a special prompt **before** asking for a move: + +``` +[50-move rule] You may claim a draw, or continue playing. + 1. Claim draw + 2. Continue +``` + +- **Option 1:** `processMove` is called with `raw = "draw"` → returns `DrawClaimed` → prints `"Draw claimed by 50-move rule."` then restarts with the initial board. +- **Option 2:** Falls through to the normal move prompt for that same turn. The clock is **not** reset by choosing to continue — it only resets on a pawn move or capture. +- If `halfMoveClock < 50`: no menu shown, normal move prompt only. +- If `raw = "draw"` arrives in `processMove` with `halfMoveClock < 50`: treated as `InvalidFormat("draw")`. + +--- + +## Section 3: Testing + +All tests in `GameControllerTest` (`AnyFunSuite with Matchers`, pure unit tests on `processMove`): + +| Test | Expected result | +|------|----------------| +| `raw = "draw"`, `halfMoveClock = 50` | `DrawClaimed` | +| `raw = "draw"`, `halfMoveClock = 49` | `InvalidFormat("draw")` | +| After a pawn move | `newHalfMoveClock == 0` | +| After a capture | `newHalfMoveClock == 0` | +| After an en-passant capture | `newHalfMoveClock == 0` | +| After a quiet piece move, clock = 10 | `newHalfMoveClock == 11` | +| `Moved` result carries updated clock | clock present in result | +| `MovedInCheck` result carries updated clock | clock present in result | + +No changes needed to `GameRulesTest`, `FenParserTest`, or `FenExporterTest`. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `halfMoveClock` param to `processMove` and `gameLoop`; add clock update logic; add draw claim handling; update `MoveResult` cases | +| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add tests for all clock and draw claim scenarios |