From 381c3f06a1dccff29cc1d7a361c0326e880306a6 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 22:42:20 +0200 Subject: [PATCH] docs: add 50-move rule design spec for NCS-11 --- .../specs/2026-03-31-50-move-rule-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-31-50-move-rule-design.md diff --git a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md new file mode 100644 index 0000000..5d972a2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md @@ -0,0 +1,169 @@ +# 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: 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/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` | + +`EnPassantCalculator`, `CastlingRightsCalculator`, `MoveValidator`, `GameRules`, and their tests are **not** touched. + +--- + +## Section 5: 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`