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

24 KiB
Raw Blame History

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):

  // ──── 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

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:

    GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)

With:

    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:

  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:

  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:

      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:

      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:

      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:

      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:

      case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black

With:

      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:

      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:

      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:

      case MoveResult.Moved(newBoard, _, _, _) =>

With:

      case MoveResult.Moved(newBoard, _, _, _, _) =>
  • Step 7: Fix pattern match — "e1g1 revokes both white castling rights" (line ~252)

Replace:

      case MoveResult.Moved(_, newHistory, _, _) =>

With:

      case MoveResult.Moved(_, newHistory, _, _, _) =>
  • Step 8: Fix pattern matches — "moving rook from h1 revokes white kingside right" (lines ~265269)

Replace:

      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:

      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:

      case MoveResult.Moved(_, newHistory, _, _) =>

With:

      case MoveResult.Moved(_, newHistory, _, _, _) =>
  • Step 10: Fix pattern matches — "enemy capture on h1 revokes white kingside right" (lines ~291294)

Replace:

      case MoveResult.Moved(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.White).kingSide shouldBe false
      case MoveResult.MovedInCheck(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.White).kingSide shouldBe false

With:

      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:

      case MoveResult.Moved(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
      case MoveResult.MovedInCheck(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None

With:

      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:

      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:

      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:

      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:

      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:

      case MoveResult.Moved(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.White).queenSide shouldBe false
      case MoveResult.MovedInCheck(_, newHistory, _, _) =>
        castlingRights(newHistory, Color.White).queenSide shouldBe false

With:

      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:

      case MoveResult.Moved(newBoard, _, captured, _) =>

With:

      case MoveResult.Moved(newBoard, _, captured, _, _) =>
  • Step 16: Fix pattern match — "en passant capture by black removes the captured white pawn" (line ~400)

Replace:

      case MoveResult.Moved(newBoard, _, captured, _) =>

With:

      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
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"