Files
NowChessSystems/docs/superpowers/plans/2026-03-30-50-move-rule.md
T
2026-03-30 12:41:07 +02:00

574 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```