From fc42ccfeee6282ea743ec8633c0c286cae11aee3 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 30 Mar 2026 12:41:07 +0200 Subject: [PATCH] docs: add 50-move rule implementation plan for NCS-11 Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-30-50-move-rule.md | 573 ++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-30-50-move-rule.md diff --git a/docs/superpowers/plans/2026-03-30-50-move-rule.md b/docs/superpowers/plans/2026-03-30-50-move-rule.md new file mode 100644 index 0000000..5546f6e --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-50-move-rule.md @@ -0,0 +1,573 @@ +# 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" +```