diff --git a/docs/superpowers/plans/2026-03-30-50-move-rule.md b/docs/superpowers/plans/2026-03-30-50-move-rule.md deleted file mode 100644 index 5546f6e..0000000 --- a/docs/superpowers/plans/2026-03-30-50-move-rule.md +++ /dev/null @@ -1,573 +0,0 @@ -# 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" -``` diff --git a/docs/superpowers/specs/2026-03-30-50-move-rule-design.md b/docs/superpowers/specs/2026-03-30-50-move-rule-design.md deleted file mode 100644 index d5252d8..0000000 --- a/docs/superpowers/specs/2026-03-30-50-move-rule-design.md +++ /dev/null @@ -1,98 +0,0 @@ -# 50-Move Rule — Design Spec -**Branch:** feat/NCS-11 -**Date:** 2026-03-30 - ---- - -## Overview - -Implement the 50-move rule: a player may claim a draw if no pawn move or capture has occurred in the last 50 half-moves (plies). The rule is **not** enforced automatically — the eligible player must actively claim it via a TUI menu option. - ---- - -## Architecture - -### Approach -Thread `halfMoveClock: Int` explicitly through `processMove` and `gameLoop` (Approach A). This mirrors the existing `halfMoveClock` field in `GameState` (already parsed from FEN) and requires no changes to `GameHistory` or `HistoryMove`. - ---- - -## Section 1: Data & Signatures - -### `MoveResult` changes (`GameController.scala`) - -```scala -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 object DrawClaimed extends MoveResult -``` - -`Stalemate` remains unchanged (automatic draw, no clock involved). - -### `processMove` signature - -```scala -def processMove(board: Board, history: GameHistory, turn: Color, - halfMoveClock: Int, raw: String): MoveResult -``` - -### `gameLoop` signature - -```scala -def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit -``` - -### Clock update logic (inside `processMove`, after a legal move) - -```scala -val movedPiece = board.pieceAt(from).get -val isReset = movedPiece.pieceType == PieceType.Pawn || captured.isDefined || isEP -val newClock = if isReset then 0 else halfMoveClock + 1 -``` - ---- - -## Section 2: TUI Menu / `gameLoop` Behaviour - -When `halfMoveClock >= 50` at the start of a player's turn, `gameLoop` shows a special prompt **before** asking for a move: - -``` -[50-move rule] You may claim a draw, or continue playing. - 1. Claim draw - 2. Continue -``` - -- **Option 1:** `processMove` is called with `raw = "draw"` → returns `DrawClaimed` → prints `"Draw claimed by 50-move rule."` then restarts with the initial board. -- **Option 2:** Falls through to the normal move prompt for that same turn. The clock is **not** reset by choosing to continue — it only resets on a pawn move or capture. -- If `halfMoveClock < 50`: no menu shown, normal move prompt only. -- If `raw = "draw"` arrives in `processMove` with `halfMoveClock < 50`: treated as `InvalidFormat("draw")`. - ---- - -## Section 3: Testing - -All tests in `GameControllerTest` (`AnyFunSuite with Matchers`, pure unit tests on `processMove`): - -| Test | Expected result | -|------|----------------| -| `raw = "draw"`, `halfMoveClock = 50` | `DrawClaimed` | -| `raw = "draw"`, `halfMoveClock = 49` | `InvalidFormat("draw")` | -| After a pawn move | `newHalfMoveClock == 0` | -| After a capture | `newHalfMoveClock == 0` | -| After an en-passant capture | `newHalfMoveClock == 0` | -| After a quiet piece move, clock = 10 | `newHalfMoveClock == 11` | -| `Moved` result carries updated clock | clock present in result | -| `MovedInCheck` result carries updated clock | clock present in result | - -No changes needed to `GameRulesTest`, `FenParserTest`, or `FenExporterTest`. - ---- - -## Files Changed - -| File | Change | -|------|--------| -| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `halfMoveClock` param to `processMove` and `gameLoop`; add clock update logic; add draw claim handling; update `MoveResult` cases | -| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add tests for all clock and draw claim scenarios |