240 lines
9.0 KiB
Markdown
240 lines
9.0 KiB
Markdown
# 50-Move Rule — Design Spec
|
|
**Branch:** feat/NCS-11
|
|
**Date:** 2026-03-31
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Implement the FIDE 50-move rule: when 100 consecutive half-moves (plies) have been played without a pawn move or capture, the player whose turn it is may claim a draw by typing `draw`. The engine notifies observers when the threshold is reached so the UI can prompt the player.
|
|
|
|
---
|
|
|
|
## Motivation
|
|
|
|
The 50-move rule prevents games from continuing indefinitely in positions where neither side can force checkmate. Under FIDE rules it is a player-claimed draw, not automatic.
|
|
|
|
---
|
|
|
|
## Section 1: Data Model — `GameHistory`
|
|
|
|
`GameHistory` gains one new field:
|
|
|
|
```scala
|
|
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0)
|
|
```
|
|
|
|
The default value `0` means all existing construction sites compile unchanged.
|
|
|
|
### Clock update rule
|
|
|
|
The clock resets to 0 on any pawn move or capture; otherwise it increments by 1.
|
|
|
|
The main `addMove` overload gains two optional boolean flags:
|
|
|
|
```scala
|
|
def addMove(
|
|
from: Square,
|
|
to: Square,
|
|
castleSide: Option[CastleSide] = None,
|
|
promotionPiece: Option[PromotionPiece] = None,
|
|
wasPawnMove: Boolean = false,
|
|
wasCapture: Boolean = false
|
|
): GameHistory =
|
|
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
|
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
|
|
```
|
|
|
|
The base `addMove(HistoryMove)` overload is made **private**; all public call sites route through the flagged overload above.
|
|
|
|
The no-argument overload `addMove(from, to)` used in tests and en passant history recording defaults both flags to `false` (clock increments) and remains for backward compatibility.
|
|
|
|
---
|
|
|
|
## Section 2: Clock Update in `GameController`
|
|
|
|
### `applyNormalMove`
|
|
|
|
Two flags are derived from already-available data before calling `history.addMove`:
|
|
|
|
```scala
|
|
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
|
|
val wasCapture = captured.isDefined // computed earlier in the same method
|
|
val newHistory = history.addMove(from, to, castleOpt,
|
|
wasPawnMove = wasPawnMove, wasCapture = wasCapture)
|
|
```
|
|
|
|
En passant moves are pawn captures, so both flags are `true` — the clock resets.
|
|
|
|
### `completePromotion`
|
|
|
|
Pawn promotion is always a pawn move, so `wasPawnMove = true`:
|
|
|
|
```scala
|
|
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
|
```
|
|
|
|
---
|
|
|
|
## Section 3: Claim Mechanism and New Events
|
|
|
|
### New events (`Observer.scala`)
|
|
|
|
```scala
|
|
/** Fired after any move where the 50-move rule threshold is reached (halfMoveClock >= 100). */
|
|
case class FiftyMoveRuleAvailableEvent(
|
|
board: Board,
|
|
history: GameHistory,
|
|
turn: Color
|
|
) extends GameEvent
|
|
|
|
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
|
case class DrawClaimedEvent(
|
|
board: Board,
|
|
history: GameHistory,
|
|
turn: Color
|
|
) extends GameEvent
|
|
```
|
|
|
|
### Claim handling in `GameEngine.processUserInput`
|
|
|
|
A new `"draw"` case is added before the move-parsing fallthrough:
|
|
|
|
```scala
|
|
case "draw" =>
|
|
if currentHistory.halfMoveClock >= 100 then
|
|
currentBoard = Board.initial
|
|
currentHistory = GameHistory.empty
|
|
currentTurn = Color.White
|
|
invoker.clear()
|
|
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
|
else
|
|
notifyObservers(InvalidMoveEvent(
|
|
currentBoard, currentHistory, currentTurn,
|
|
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
|
))
|
|
```
|
|
|
|
The game state resets to initial (same pattern as `Checkmate` and `Stalemate`). The command invoker is cleared so undo/redo history does not survive the draw claim.
|
|
|
|
### Availability notification in `GameEngine`
|
|
|
|
After any move that results in `Moved` or `MovedInCheck`, the engine checks whether the threshold has been crossed:
|
|
|
|
```scala
|
|
if newHistory.halfMoveClock >= 100 then
|
|
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
|
```
|
|
|
|
This fires immediately after the `MoveExecutedEvent` (or `CheckDetectedEvent`) for that move.
|
|
|
|
---
|
|
|
|
## Section 4: FEN Integration
|
|
|
|
`FenExporter.gameStateToFen` and `FenParser.parseFen` already handle `halfMoveClock` at the `GameState` level — no changes to those files are needed.
|
|
|
|
The bridge between `GameHistory.halfMoveClock` and `GameState.halfMoveClock` is a caller responsibility:
|
|
|
|
**FEN export (writing):** When constructing a `GameState` for FEN export, pass `halfMoveClock = history.halfMoveClock`. Since `GameEngine` already exposes `def history: GameHistory`, this works automatically once the field is populated:
|
|
|
|
```scala
|
|
GameState(
|
|
piecePlacement = FenExporter.boardToFen(engine.board),
|
|
activeColor = engine.turn,
|
|
...,
|
|
halfMoveClock = engine.history.halfMoveClock,
|
|
...
|
|
)
|
|
```
|
|
|
|
**FEN import (reading):** When loading from a parsed `GameState`, initialise the engine with a `GameHistory` carrying the parsed clock:
|
|
|
|
```scala
|
|
val gs = FenParser.parseFen(fenString).get
|
|
new GameEngine(
|
|
initialBoard = FenParser.parseBoard(gs.piecePlacement).get,
|
|
initialHistory = GameHistory(halfMoveClock = gs.halfMoveClock),
|
|
initialTurn = gs.activeColor
|
|
)
|
|
```
|
|
|
|
A round-trip test is added to `FenExporterTest` / `FenParserTest` verifying that a non-zero clock survives export → import.
|
|
|
|
---
|
|
|
|
## Section 5: PGN Integration
|
|
|
|
`PgnExporter.exportGame` currently hardcodes `" *"` as the game termination marker. PGN standard requires the marker to match the `Result` header (`1-0`, `0-1`, `1/2-1/2`, or `*`).
|
|
|
|
### Change to `PgnExporter`
|
|
|
|
Replace the hardcoded `" *"` with the value from the `Result` header:
|
|
|
|
```scala
|
|
val termination = headers.getOrElse("Result", "*")
|
|
moveLines.mkString(" ") + s" $termination"
|
|
```
|
|
|
|
### Draw claim result
|
|
|
|
When `DrawClaimedEvent` is handled by a caller that exports PGN, it should pass:
|
|
|
|
```scala
|
|
Map("Result" -> "1/2-1/2", ...)
|
|
```
|
|
|
|
The move text will then end with `1/2-1/2`, which is correct per PGN standard for a drawn game.
|
|
|
|
A test is added to `PgnExporterTest` verifying that `exportGame` with `"Result" -> "1/2-1/2"` produces a move text ending in `1/2-1/2`.
|
|
|
|
---
|
|
|
|
## Section 6: Files Changed
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala` | Add `halfMoveClock` field; extend `addMove` with `wasPawnMove`/`wasCapture` flags; make base overload private |
|
|
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Compute and pass flags in `applyNormalMove` and `completePromotion` |
|
|
| `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` | Add `FiftyMoveRuleAvailableEvent` and `DrawClaimedEvent` |
|
|
| `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` | Handle `"draw"` input; fire `FiftyMoveRuleAvailableEvent` after eligible moves |
|
|
| `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` | Derive termination marker from `Result` header instead of hardcoding `*` |
|
|
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` | New test suite for clock update rules |
|
|
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Tests for clock values in `applyNormalMove` and `completePromotion` |
|
|
| `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala` | Tests for `"draw"` command and `FiftyMoveRuleAvailableEvent` |
|
|
| `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` | Test for `1/2-1/2` termination marker |
|
|
| `modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala` | Round-trip test: non-zero `halfMoveClock` survives FEN export → import |
|
|
|
|
`EnPassantCalculator`, `CastlingRightsCalculator`, `MoveValidator`, `GameRules`, and their tests are **not** touched.
|
|
|
|
---
|
|
|
|
## Section 7: Testing
|
|
|
|
### `GameHistoryTest`
|
|
- Clock starts at 0
|
|
- Clock increments on a normal (non-pawn, non-capture) move
|
|
- Clock resets to 0 on a pawn move (`wasPawnMove = true`)
|
|
- Clock resets to 0 on a capture (`wasCapture = true`)
|
|
- Clock resets to 0 when both flags are true (en passant)
|
|
- Clock carries correctly across multiple sequential moves
|
|
|
|
### `GameControllerTest`
|
|
- `applyNormalMove` with a non-pawn, non-capture produces `history.halfMoveClock = 1`
|
|
- `applyNormalMove` with a pawn move produces `history.halfMoveClock = 0`
|
|
- `applyNormalMove` with a capture produces `history.halfMoveClock = 0`
|
|
- `completePromotion` always produces `history.halfMoveClock = 0`
|
|
|
|
### `GameEngineTest`
|
|
- `processUserInput("draw")` fires `DrawClaimedEvent` and resets state when `halfMoveClock >= 100`
|
|
- `processUserInput("draw")` fires `InvalidMoveEvent` when `halfMoveClock < 100`
|
|
- A successful non-pawn, non-capture move that brings the clock to exactly 100 fires `FiftyMoveRuleAvailableEvent`
|
|
- A successful move that does not reach 100 does not fire `FiftyMoveRuleAvailableEvent`
|
|
|
|
### `PgnExporterTest`
|
|
- `exportGame` with `"Result" -> "1/2-1/2"` produces move text ending in `1/2-1/2`
|
|
- `exportGame` with no `Result` header still produces `*` as before (backward-compatible)
|
|
|
|
### `FenExporterTest`
|
|
- Round-trip: a `GameHistory` with `halfMoveClock = 42` exported to FEN and re-parsed yields `halfMoveClock = 42`
|