# 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 14–18)** 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 ~265–269)** 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 ~291–294)** 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 ~320–323)** 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 ~329–337)** 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 ~346–353)** 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 ~362–367)** 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" ```