docs(docs): 50 Move rule
Build & Test (NowChessSystems) TeamCity build failed

Removed docs from this repo and added them to designated repo
This commit is contained in:
LQ63
2026-03-30 13:03:57 +02:00
parent c20b71e302
commit a687567624
2 changed files with 0 additions and 671 deletions
@@ -1,573 +0,0 @@
# 50-Move Rule Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a player-claimable 50-move draw rule to the chess engine, tracked via a `halfMoveClock` parameter threaded through `processMove` and `gameLoop`.
**Architecture:** Add `halfMoveClock: Int` to `processMove` and `gameLoop` signatures. Reset the clock on pawn moves, captures, and en-passant; increment on all other moves. When the clock reaches 50, show a TUI menu before asking for a move; the player may claim the draw or continue.
**Tech Stack:** Scala 3.5.x · ScalaTest (`AnyFunSuite with Matchers`)
---
## File Map
| File | Change |
|------|--------|
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `halfMoveClock` param; clock update logic; draw claim; TUI menu; `DrawClaimed` result |
| `modules/core/src/main/scala/de/nowchess/chess/Main.scala` | Pass `halfMoveClock = 0` to `gameLoop` |
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add default param to helpers; fix all existing pattern matches; add new clock/draw tests |
---
## Task 1: Write new failing tests
**Files:**
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
These tests will **not compile** yet — that is expected. Writing them first establishes exactly what the implementation must satisfy.
- [ ] **Step 1: Append new test block to `GameControllerTest.scala`**
Add the following after the last existing test (after line 403):
```scala
// ──── processMove: 50-move rule draw claim ───────────────────────────────
test("processMove: 'draw' with halfMoveClock = 50 returns DrawClaimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 50, "draw") shouldBe MoveResult.DrawClaimed
test("processMove: 'draw' with halfMoveClock = 49 returns InvalidFormat"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 49, "draw") shouldBe MoveResult.InvalidFormat("draw")
// ──── processMove: halfMoveClock update ──────────────────────────────────
test("processMove: pawn move resets halfMoveClock to 0"):
GameController.processMove(Board.initial, GameHistory.empty, Color.White, 10, "e2e4") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 15, "a5d5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: en passant capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
GameController.processMove(b, h, Color.White, 20, "e5d6") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: quiet piece move increments halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 10, "a1a5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 11
case other => fail(s"Expected Moved, got $other")
test("processMove: MovedInCheck carries updated halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 7, "a1a8") match
case MoveResult.MovedInCheck(_, _, _, newClock, _) => newClock shouldBe 8
case other => fail(s"Expected MovedInCheck, got $other")
// ──── gameLoop: 50-move rule menu ────────────────────────────────────────
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 50 and draw claimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("1\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 50)
output should include("50-move rule")
output should include("Draw claimed by 50-move rule.")
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 50 and player continues"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("2\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 50)
output should include("50-move rule")
output should include("White's turn")
test("gameLoop: no 50-move rule menu when halfMoveClock < 50"):
val output = captureOutput:
withInput("quit\n"):
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 49)
output should not include "50-move rule"
```
- [ ] **Step 2: Verify compilation fails**
Run: `./gradlew :modules:core:test 2>&1 | head -30`
Expected: compilation error — `processMove` and `gameLoop` signatures don't match yet.
---
## Task 2: Update `MoveResult`, `processMove`, `gameLoop`, and `Main`
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
- Modify: `modules/core/src/main/scala/de/nowchess/chess/Main.scala`
- [ ] **Step 1: Replace `GameController.scala` with the updated implementation**
```scala
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
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 class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
case object DrawClaimed extends MoveResult
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
object GameController:
/** Pure function: interprets one raw input line against the current game context.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case "draw" =>
if halfMoveClock >= 50 then MoveResult.DrawClaimed
else MoveResult.InvalidFormat("draw")
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(piece) =>
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val isReset = piece.pieceType == PieceType.Pawn || captured.isDefined || isEP
val newClock = if isReset then 0 else halfMoveClock + 1
val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit =
println()
print(Renderer.render(board))
val input =
if halfMoveClock >= 50 then
println(s"[50-move rule] ${turn.label} may claim a draw, or continue playing.")
println(" 1. Claim draw")
println(" 2. Continue")
Option(StdIn.readLine()).getOrElse("2").trim match
case "1" => "draw"
case _ =>
println(s"${turn.label}'s turn. Enter move: ")
Option(StdIn.readLine()).getOrElse("quit").trim
else
println(s"${turn.label}'s turn. Enter move: ")
Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, halfMoveClock, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.DrawClaimed =>
println("Draw claimed by 50-move rule.")
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.Moved(newBoard, newHistory, captured, newClock, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
```
- [ ] **Step 2: Update `Main.scala`**
Replace line 11:
```scala
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
```
With:
```scala
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
```
---
## Task 3: Fix existing tests to compile with new signatures
**Files:**
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
The `MoveResult.Moved` and `MoveResult.MovedInCheck` case classes now have 5 fields instead of 4. All existing pattern matches and the two private helpers must be updated.
- [ ] **Step 1: Update the two private helper methods (lines 1418)**
Replace:
```scala
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
GameController.gameLoop(board, history, turn)
```
With:
```scala
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String, halfMoveClock: Int = 0): MoveResult =
GameController.processMove(board, history, turn, halfMoveClock, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int = 0): Unit =
GameController.gameLoop(board, history, turn, halfMoveClock)
```
- [ ] **Step 2: Fix pattern match — "legal pawn move returns Moved" (line ~51)**
Replace:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None
newTurn shouldBe Color.Black
```
With:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None
newTurn shouldBe Color.Black
```
- [ ] **Step 3: Fix pattern match — "legal capture returns Moved" (line ~65)**
Replace:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
captured shouldBe Some(Piece.BlackPawn)
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
newTurn shouldBe Color.Black
```
With:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
captured shouldBe Some(Piece.BlackPawn)
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
newTurn shouldBe Color.Black
```
- [ ] **Step 4: Fix pattern match — "legal move that delivers check returns MovedInCheck" (line ~136)**
Replace:
```scala
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
```
With:
```scala
case MoveResult.MovedInCheck(_, _, _, _, newTurn) => newTurn shouldBe Color.Black
```
- [ ] **Step 5: Fix pattern match — "e1g1 returns Moved with king on g1 and rook on f1" (line ~222)**
Replace:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
captured shouldBe None
newTurn shouldBe Color.Black
```
With:
```scala
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
captured shouldBe None
newTurn shouldBe Color.Black
```
- [ ] **Step 6: Fix pattern match — "e1c1 returns Moved with king on c1 and rook on d1" (line ~238)**
Replace:
```scala
case MoveResult.Moved(newBoard, _, _, _) =>
```
With:
```scala
case MoveResult.Moved(newBoard, _, _, _, _) =>
```
- [ ] **Step 7: Fix pattern match — "e1g1 revokes both white castling rights" (line ~252)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
```
- [ ] **Step 8: Fix pattern matches — "moving rook from h1 revokes white kingside right" (lines ~265269)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
```
- [ ] **Step 9: Fix pattern match — "moving king from e1 revokes both white rights" (line ~278)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
```
- [ ] **Step 10: Fix pattern matches — "enemy capture on h1 revokes white kingside right" (lines ~291294)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
```
- [ ] **Step 11: Fix pattern matches — "moving king from e8 revokes both black rights" (lines ~320323)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
```
- [ ] **Step 12: Fix pattern matches — "moving rook from a8 revokes black queenside right" (lines ~329337)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
```
- [ ] **Step 13: Fix pattern matches — "moving rook from h8 revokes black kingside right" (lines ~346353)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
```
- [ ] **Step 14: Fix pattern matches — "enemy capture on a1 revokes white queenside right" (lines ~362367)**
Replace:
```scala
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
```
With:
```scala
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
```
- [ ] **Step 15: Fix pattern match — "en passant capture removes the captured pawn" (line ~382)**
Replace:
```scala
case MoveResult.Moved(newBoard, _, captured, _) =>
```
With:
```scala
case MoveResult.Moved(newBoard, _, captured, _, _) =>
```
- [ ] **Step 16: Fix pattern match — "en passant capture by black removes the captured white pawn" (line ~400)**
Replace:
```scala
case MoveResult.Moved(newBoard, _, captured, _) =>
```
With:
```scala
case MoveResult.Moved(newBoard, _, captured, _, _) =>
```
- [ ] **Step 17: Run all tests**
Run: `./gradlew :modules:core:test`
Expected: all tests pass (including the new ones added in Task 1).
- [ ] **Step 18: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \
modules/core/src/main/scala/de/nowchess/chess/Main.scala \
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "feat: NCS-11 implement 50-move rule with player claim via TUI menu"
```
@@ -1,98 +0,0 @@
# 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 |