Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3.6 KiB
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)
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
def processMove(board: Board, history: GameHistory, turn: Color,
halfMoveClock: Int, raw: String): MoveResult
gameLoop signature
def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit
Clock update logic (inside processMove, after a legal move)
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:
processMoveis called withraw = "draw"→ returnsDrawClaimed→ 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 inprocessMovewithhalfMoveClock < 50: treated asInvalidFormat("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 |