diff --git a/docs/superpowers/plans/2026-03-24-castling.md b/docs/superpowers/plans/2026-03-24-castling.md new file mode 100644 index 0000000..24991b7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-castling.md @@ -0,0 +1,1049 @@ +# Castling 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:** Implement legal castling in the NowChess TUI engine by introducing a `GameContext` wrapper that threads castling-rights state through the engine. + +**Architecture:** A new `GameContext(board, whiteCastling, blackCastling)` in `modules/core` replaces `Board` in all engine signatures. `MoveValidator` gains context-aware overloads that include castling targets. `GameRules` and `GameController` are updated to pass `GameContext` through the whole move-processing pipeline. + +**Tech Stack:** Scala 3.5, ScalaTest (`AnyFunSuite with Matchers`), Gradle (`./gradlew :modules:core:test`) + +**TDD discipline:** Every task follows the same cycle — write one failing test, confirm it fails, write the minimum code to make it pass, confirm it passes, commit. Never write implementation before a failing test. + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` | `CastleSide` enum, `GameContext` case class, `withCastle` Board extension | +| **Create** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala` | Tests for `GameContext` methods and `withCastle` | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` | Add `castlingTargets`, `isCastle`, `castleSide`, `isAttackedBy`; add context-aware `legalTargets(ctx,from)` and `isLegal(ctx,from,to)` overloads | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` | New castling scenario tests | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | Update `legalMoves` and `gameStatus` to accept `GameContext` | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | Update existing tests; add castling and false-stalemate tests | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Update `MoveResult`, `processMove`, `gameLoop` to use `GameContext`; add castle detection, execution, and rights revocation | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Update existing tests; add castling and rights-revocation tests | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` | Use `GameContext.initial` | + +--- + +## Task 1: Create `GameContext` + +**Files:** +- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` +- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala` + +- [ ] **Step 1.1: Write failing tests** + +Create `GameContextTest.scala`: + +```scala +package de.nowchess.chess.logic + +import de.nowchess.api.board.* +import de.nowchess.api.game.CastlingRights +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameContextTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) + + test("GameContext.initial has Board.initial and CastlingRights.Both for both sides"): + GameContext.initial.board shouldBe Board.initial + GameContext.initial.whiteCastling shouldBe CastlingRights.Both + GameContext.initial.blackCastling shouldBe CastlingRights.Both + + test("castlingFor returns white rights for Color.White"): + GameContext.initial.castlingFor(Color.White) shouldBe CastlingRights.Both + + test("castlingFor returns black rights for Color.Black"): + GameContext.initial.castlingFor(Color.Black) shouldBe CastlingRights.Both + + test("withUpdatedRights updates white castling without touching black"): + val ctx = GameContext.initial.withUpdatedRights(Color.White, CastlingRights.None) + ctx.whiteCastling shouldBe CastlingRights.None + ctx.blackCastling shouldBe CastlingRights.Both + + test("withUpdatedRights updates black castling without touching white"): + val ctx = GameContext.initial.withUpdatedRights(Color.Black, CastlingRights.None) + ctx.blackCastling shouldBe CastlingRights.None + ctx.whiteCastling shouldBe CastlingRights.Both + + // ── withCastle ─────────────────────────────────────────────────────────────── + + test("withCastle: white kingside — king e1→g1, rook h1→f1"): + val b = board( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook + ) + val after = b.withCastle(Color.White, CastleSide.Kingside) + after.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) + after.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) + after.pieceAt(sq(File.E, Rank.R1)) shouldBe None + after.pieceAt(sq(File.H, Rank.R1)) shouldBe None + + test("withCastle: white queenside — king e1→c1, rook a1→d1"): + val b = board( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook + ) + val after = b.withCastle(Color.White, CastleSide.Queenside) + after.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) + after.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) + after.pieceAt(sq(File.E, Rank.R1)) shouldBe None + after.pieceAt(sq(File.A, Rank.R1)) shouldBe None + + test("withCastle: black kingside — king e8→g8, rook h8→f8"): + val b = board( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R8) -> Piece.BlackRook + ) + val after = b.withCastle(Color.Black, CastleSide.Kingside) + after.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing) + after.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook) + after.pieceAt(sq(File.E, Rank.R8)) shouldBe None + after.pieceAt(sq(File.H, Rank.R8)) shouldBe None + + test("withCastle: black queenside — king e8→c8, rook a8→d8"): + val b = board( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.A, Rank.R8) -> Piece.BlackRook + ) + val after = b.withCastle(Color.Black, CastleSide.Queenside) + after.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing) + after.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook) + after.pieceAt(sq(File.E, Rank.R8)) shouldBe None + after.pieceAt(sq(File.A, Rank.R8)) shouldBe None +``` + +- [ ] **Step 1.2: Run — expect compilation failure** + +```bash +./gradlew :modules:core:test 2>&1 | tail -20 +``` +Expected: compilation error — `GameContext` / `CastleSide` not found. + +- [ ] **Step 1.3: Implement `GameContext.scala`** + +Create `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala`: + +```scala +package de.nowchess.chess.logic + +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.CastlingRights + +enum CastleSide: + case Kingside, Queenside + +case class GameContext( + board: Board, + whiteCastling: CastlingRights, + blackCastling: CastlingRights +): + def castlingFor(color: Color): CastlingRights = + if color == Color.White then whiteCastling else blackCastling + + def withUpdatedRights(color: Color, rights: CastlingRights): GameContext = + if color == Color.White then copy(whiteCastling = rights) + else copy(blackCastling = rights) + +object GameContext: + /** Convenience constructor for test boards: no castling rights on either side. */ + def apply(board: Board): GameContext = + GameContext(board, CastlingRights.None, CastlingRights.None) + + val initial: GameContext = + GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both) + +extension (b: Board) + def withCastle(color: Color, side: CastleSide): Board = + val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match + case (Color.White, CastleSide.Kingside) => + (Square(File.E, Rank.R1), Square(File.G, Rank.R1), + Square(File.H, Rank.R1), Square(File.F, Rank.R1)) + case (Color.White, CastleSide.Queenside) => + (Square(File.E, Rank.R1), Square(File.C, Rank.R1), + Square(File.A, Rank.R1), Square(File.D, Rank.R1)) + case (Color.Black, CastleSide.Kingside) => + (Square(File.E, Rank.R8), Square(File.G, Rank.R8), + Square(File.H, Rank.R8), Square(File.F, Rank.R8)) + case (Color.Black, CastleSide.Queenside) => + (Square(File.E, Rank.R8), Square(File.C, Rank.R8), + Square(File.A, Rank.R8), Square(File.D, Rank.R8)) + val king = Piece(color, PieceType.King) + val rook = Piece(color, PieceType.Rook) + Board(b.pieces.removed(kingFrom).removed(rookFrom) + .updated(kingTo, king).updated(rookTo, rook)) +``` + +- [ ] **Step 1.4: Run — expect all GameContext tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameContextTest" 2>&1 | tail -20 +``` +Expected: 9 tests, 9 passed. + +- [ ] **Step 1.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala \ + modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala +git commit -m "feat: add GameContext, CastleSide, and Board.withCastle" +``` + +--- + +## Task 2: Extend `MoveValidator` with castling logic + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` + +- [ ] **Step 2.1: Write failing castling tests** + +Add the following to the bottom of `MoveValidatorTest.scala`. Also add these imports at the top of the file: +```scala +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.{GameContext, CastleSide} +``` + +```scala + // ──── castlingTargets ──────────────────────────────────────────────── + + private def ctxWithRights( + entries: (Square, Piece)* + )(white: CastlingRights = CastlingRights.Both, + black: CastlingRights = CastlingRights.Both + ): GameContext = + GameContext(Board(entries.toMap), white, black) + + test("castlingTargets: white kingside available when all conditions met"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.G, Rank.R1)) + + test("castlingTargets: white queenside available when all conditions met"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.C, Rank.R1)) + + test("castlingTargets: black kingside available when all conditions met"): + val ctx = ctxWithRights( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )() + MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.G, Rank.R8)) + + test("castlingTargets: black queenside available when all conditions met"): + val ctx = ctxWithRights( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.A, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )() + MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.C, Rank.R8)) + + test("castlingTargets: blocked when transit square is occupied"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.F, Rank.R1) -> Piece.WhiteBishop, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) + + test("castlingTargets: blocked when king is in check"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.E, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty + + test("castlingTargets: blocked when transit square f1 is attacked"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.F, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) + + test("castlingTargets: blocked when landing square g1 is attacked"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.G, Rank.R8) -> Piece.BlackRook, + sq(File.A, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) + + test("castlingTargets: blocked when kingSide right is false"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )(white = CastlingRights(kingSide = false, queenSide = true)) + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) + + test("castlingTargets: blocked when queenSide right is false"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )(white = CastlingRights(kingSide = true, queenSide = false)) + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.C, Rank.R1) + + test("castlingTargets: blocked when relevant rook is not on home square"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.G, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1) + + // ──── context-aware legalTargets includes castling ──────────────────── + + test("legalTargets(ctx, from): king on e1 includes g1 when castling available"): + val ctx = ctxWithRights( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.legalTargets(ctx, sq(File.E, Rank.R1)) should contain(sq(File.G, Rank.R1)) + + test("legalTargets(ctx, from): non-king pieces unchanged by context"): + val ctx = ctxWithRights( + sq(File.D, Rank.R4) -> Piece.WhiteBishop, + sq(File.H, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )() + MoveValidator.legalTargets(ctx, sq(File.D, Rank.R4)) shouldBe + MoveValidator.legalTargets(ctx.board, sq(File.D, Rank.R4)) +``` + +- [ ] **Step 2.2: Run — expect compilation failure** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 +``` +Expected: compilation error — `castlingTargets` / `legalTargets(ctx, …)` not found. + +- [ ] **Step 2.3: Implement castling logic in `MoveValidator.scala`** + +Append the following methods inside `object MoveValidator`, after the existing `kingTargets` method: + +```scala + // ── Castling helpers ──────────────────────────────────────────────────────── + + private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean = + board.pieces.exists { case (from, piece) => + piece.color == attackerColor && legalTargets(board, from).contains(sq) + } + + def isCastle(board: Board, from: Square, to: Square): Boolean = + board.pieceAt(from).exists(_.pieceType == PieceType.King) && + math.abs(to.file.ordinal - from.file.ordinal) == 2 + + def castleSide(from: Square, to: Square): CastleSide = + if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside + + def castlingTargets(ctx: GameContext, color: Color): Set[Square] = + val rights = ctx.castlingFor(color) + val rank = if color == Color.White then Rank.R1 else Rank.R8 + val kingSq = Square(File.E, rank) + val enemy = color.opposite + + if ctx.board.pieceAt(kingSq) != Some(Piece(color, PieceType.King)) then return Set.empty + if GameRules.isInCheck(ctx.board, color) then return Set.empty + + var result = Set.empty[Square] + + if rights.kingSide then + val rookSq = Square(File.H, rank) + val transit = List(Square(File.F, rank), Square(File.G, rank)) + if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) && + transit.forall(s => ctx.board.pieceAt(s).isEmpty) && + !transit.exists(s => isAttackedBy(ctx.board, s, enemy)) then + result += Square(File.G, rank) + + if rights.queenSide then + val rookSq = Square(File.A, rank) + val emptySquares = List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)) + val transitSqs = List(Square(File.D, rank), Square(File.C, rank)) + if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) && + emptySquares.forall(s => ctx.board.pieceAt(s).isEmpty) && + !transitSqs.exists(s => isAttackedBy(ctx.board, s, enemy)) then + result += Square(File.C, rank) + + result + + def legalTargets(ctx: GameContext, from: Square): Set[Square] = + ctx.board.pieceAt(from) match + case Some(piece) if piece.pieceType == PieceType.King => + legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color) + case _ => + legalTargets(ctx.board, from) + + def isLegal(ctx: GameContext, from: Square, to: Square): Boolean = + legalTargets(ctx, from).contains(to) +``` + +- [ ] **Step 2.4: Run — expect all MoveValidator tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 +``` +Expected: all tests pass (existing + 13 new). + +- [ ] **Step 2.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala \ + modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +git commit -m "feat: add castling logic to MoveValidator (castlingTargets + context-aware overloads)" +``` + +--- + +## Task 3: Migrate `GameRules.legalMoves` to `GameContext` + +Only the signature and internal call changes here. Castling inclusion comes in Task 4. + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` + +- [ ] **Step 3.1: Update the two existing `legalMoves` call sites in `GameRulesTest.scala` to use `GameContext`** + +Add import at the top: +```scala +import de.nowchess.chess.logic.GameContext +``` + +Add a private helper and update the two legalMoves tests: +```scala + /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ + private def ctx(entries: (Square, Piece)*): GameContext = GameContext(Board(entries.toMap)) +``` + +Change: +```scala + // legalMoves test 1 + val moves = GameRules.legalMoves(b, Color.White) // old + // → replace `b` with the ctx helper: + val moves = GameRules.legalMoves(ctx( // new + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.E, Rank.R4) -> Piece.WhiteRook, + sq(File.E, Rank.R8) -> Piece.BlackRook + ), Color.White) +``` + +Similarly update the second legalMoves test. The `board(...)` helper is still used for `isInCheck` tests (they keep `Board`). Do not touch `gameStatus` tests yet. + +- [ ] **Step 3.2: Run — expect compilation failure** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: compilation error — `legalMoves` does not accept `GameContext`. + +- [ ] **Step 3.3: Update `legalMoves` signature in `GameRules.scala`** + +Change the signature and internal call (no castling logic yet — use the board-only `legalTargets`): + +```scala + def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] = + ctx.board.pieces + .collect { case (from, piece) if piece.color == color => from } + .flatMap { from => + MoveValidator.legalTargets(ctx.board, from) // board-only for now + .filter { to => + val (newBoard, _) = ctx.board.withMove(from, to) + !isInCheck(newBoard, color) + } + .map(to => from -> to) + } + .toSet +``` + +- [ ] **Step 3.4: Run — expect all existing GameRules tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: all existing tests pass. + +- [ ] **Step 3.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ + modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +git commit -m "refactor: migrate GameRules.legalMoves signature to GameContext" +``` + +--- + +## Task 4: Include castling in `GameRules.legalMoves` + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` + +- [ ] **Step 4.1: Write two failing castling tests in `GameRulesTest.scala`** + +Add import at the top: +```scala +import de.nowchess.api.game.CastlingRights +``` + +Append to the file: +```scala + test("legalMoves: includes castling destination when available"): + val c = GameContext( + board = board( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + ), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameRules.legalMoves(c, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) + + test("legalMoves: excludes castling when king is in check"): + val c = GameContext( + board = board( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.E, Rank.R8) -> Piece.BlackRook, + sq(File.A, Rank.R8) -> Piece.BlackKing + ), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameRules.legalMoves(c, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) +``` + +- [ ] **Step 4.2: Run — expect the two new tests to fail** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: 2 failures — castling destination not included in `legalMoves`. + +- [ ] **Step 4.3: Update `legalMoves` to use context-aware `legalTargets` and handle castle board simulation** + +In `GameRules.scala`, replace the `MoveValidator.legalTargets(ctx.board, from)` call with the context-aware overload, and use `withCastle` when simulating castle moves: + +```scala + def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] = + ctx.board.pieces + .collect { case (from, piece) if piece.color == color => from } + .flatMap { from => + MoveValidator.legalTargets(ctx, from) // context-aware: includes castling + .filter { to => + val newBoard = + if MoveValidator.isCastle(ctx.board, from, to) then + ctx.board.withCastle(color, MoveValidator.castleSide(from, to)) + else + ctx.board.withMove(from, to)._1 + !isInCheck(newBoard, color) + } + .map(to => from -> to) + } + .toSet +``` + +- [ ] **Step 4.4: Run — expect all GameRules tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: all tests pass (existing + 2 new). + +- [ ] **Step 4.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ + modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +git commit -m "feat: include castling moves in GameRules.legalMoves" +``` + +--- + +## Task 5: Migrate `GameRules.gameStatus` to `GameContext` and add false-stalemate test + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` + +- [ ] **Step 5.1: Update existing `gameStatus` call sites and add false-stalemate test in `GameRulesTest.scala`** + +Change all four existing `GameRules.gameStatus(b, ...)` calls to `GameRules.gameStatus(ctx(...), ...)` using the `ctx` helper (which wraps with no castling rights — appropriate for these non-castling positions). + +Then append the new false-stalemate test: + +```scala + test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"): + // White King e1, Rook h1 (kingside castling available). + // Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both, + // f1 attacked by f2. King cannot move to any adjacent square without entering + // an attacked square or an enemy piece. Only legal move: castle to g1. + val c = GameContext( + board = board( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.D, Rank.R2) -> Piece.BlackRook, + sq(File.F, Rank.R2) -> Piece.BlackRook, + sq(File.A, Rank.R8) -> Piece.BlackKing + ), + whiteCastling = CastlingRights(kingSide = true, queenSide = false), + blackCastling = CastlingRights.None + ) + GameRules.gameStatus(c, Color.White) shouldBe PositionStatus.Normal +``` + +- [ ] **Step 5.2: Run — expect compilation failure on `gameStatus(b, ...)` + the new test failing** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: compilation errors and/or test failures. + +- [ ] **Step 5.3: Update `gameStatus` in `GameRules.scala`** + +```scala + def gameStatus(ctx: GameContext, color: Color): PositionStatus = + val moves = legalMoves(ctx, color) + val inCheck = isInCheck(ctx.board, color) + if moves.isEmpty && inCheck then PositionStatus.Mated + else if moves.isEmpty then PositionStatus.Drawn + else if inCheck then PositionStatus.InCheck + else PositionStatus.Normal +``` + +- [ ] **Step 5.4: Run — expect all GameRules tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20 +``` +Expected: all tests pass (existing + 3 new). + +- [ ] **Step 5.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala \ + modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +git commit -m "feat: migrate GameRules.gameStatus to GameContext; add false-stalemate test" +``` + +--- + +## Task 6: Migrate `GameController` signatures (no castling logic yet) + +Thread `GameContext` through the signatures. No castle detection or rights revocation — just the type migration. All existing tests must stay green. + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` + +- [ ] **Step 6.1: Update `GameControllerTest.scala` to use `GameContext`** + +Add imports: +```scala +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.{GameContext, CastleSide} +``` + +Make these changes throughout the test file — **do not add any new tests yet**: + +1. `val initial = Board.initial` → `val initial = GameContext.initial` +2. Every `Board(Map(...))` test board → `GameContext(Board(Map(...)))` (no-rights convenience constructor) +3. `GameController.processMove(board, ...)` → `GameController.processMove(ctx, ...)` +4. `GameController.gameLoop(Board.initial, ...)` → `GameController.gameLoop(GameContext.initial, ...)` +5. `MoveResult.Moved(newBoard, ...)` → `MoveResult.Moved(newCtx, ...)`; then access board as `newCtx.board` +6. `MoveResult.MovedInCheck(newBoard, ...)` → `MoveResult.MovedInCheck(newCtx, ...)` + +- [ ] **Step 6.2: Run — expect compilation failures** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 +``` +Expected: compilation errors — `processMove` / `gameLoop` still take `Board`. + +- [ ] **Step 6.3: Migrate `GameController.scala` signatures (no castling logic)** + +Update imports, `MoveResult` variants, `processMove`, and `gameLoop`: + +**`MoveResult` changes** — rename `newBoard` → `newCtx`: +```scala + case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult + case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult +``` + +**`processMove`** — replace `(board: Board, turn: Color, raw: String)` with `(ctx: GameContext, turn: Color, raw: String)`. The internal logic stays the same but uses `ctx.board` and returns `ctx.copy(board = newBoard)`: + +```scala + def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult = + raw.trim match + case "quit" | "q" => MoveResult.Quit + case trimmed => + Parser.parseMove(trimmed) match + case None => MoveResult.InvalidFormat(trimmed) + case Some((from, to)) => + ctx.board.pieceAt(from) match + case None => MoveResult.NoPiece + case Some(piece) if piece.color != turn => MoveResult.WrongColor + case Some(_) => + if !MoveValidator.isLegal(ctx, from, to) then + MoveResult.IllegalMove + else + val (newBoard, captured) = ctx.board.withMove(from, to) + val newCtx = ctx.copy(board = newBoard) + GameRules.gameStatus(newCtx, turn.opposite) match + case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite) + case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite) + case PositionStatus.Mated => MoveResult.Checkmate(turn) + case PositionStatus.Drawn => MoveResult.Stalemate +``` + +**`gameLoop`** — replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`: +- `Renderer.render(board)` → `Renderer.render(ctx.board)` +- recursive calls use `newCtx` +- reset on game-over uses `GameContext.initial` + +- [ ] **Step 6.4: Run — expect all existing controller tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.*" 2>&1 | tail -20 +``` +Expected: all previously passing tests still pass. Note: castling inputs like `e1g1` still return `IllegalMove` at this point — that is correct and expected (castle logic is added in Task 7). + +- [ ] **Step 6.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ + modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +git commit -m "refactor: migrate GameController to GameContext (signatures only)" +``` + +--- + +## Task 7: Add castling execution to `processMove` + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` + +- [ ] **Step 7.1: Write two failing castling tests in `GameControllerTest.scala`** + +Append: +```scala + // ──── castling execution ───────────────────────────────────────────── + + test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") match + case MoveResult.Moved(newCtx, captured, newTurn) => + newCtx.board.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) + newCtx.board.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) + newCtx.board.pieceAt(sq(File.E, Rank.R1)) shouldBe None + newCtx.board.pieceAt(sq(File.H, Rank.R1)) shouldBe None + captured shouldBe None + newTurn shouldBe Color.Black + case other => fail(s"Expected Moved, got $other") + + test("processMove: e1c1 returns Moved with king on c1 and rook on d1"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1c1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.board.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) + newCtx.board.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) + case other => fail(s"Expected Moved, got $other") +``` + +- [ ] **Step 7.2: Run — expect the two new tests to fail** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 +``` +Expected: 2 failures — `e1g1` and `e1c1` return `IllegalMove` (castle not yet executed). + +- [ ] **Step 7.3: Add castle detection and execution to `processMove` in `GameController.scala`** + +In the `Some(_) =>` branch of `processMove`, replace `ctx.board.withMove(from, to)` with castle-aware logic: + +```scala + case Some(_) => + if !MoveValidator.isLegal(ctx, from, to) then + MoveResult.IllegalMove + else + val castleOpt = if MoveValidator.isCastle(ctx.board, from, to) + then Some(MoveValidator.castleSide(from, to)) + else None + val (newBoard, captured) = castleOpt match + case Some(side) => (ctx.board.withCastle(turn, side), None) + case None => ctx.board.withMove(from, to) + val newCtx = ctx.copy(board = newBoard) + GameRules.gameStatus(newCtx, turn.opposite) match + case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite) + case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite) + case PositionStatus.Mated => MoveResult.Checkmate(turn) + case PositionStatus.Drawn => MoveResult.Stalemate +``` + +- [ ] **Step 7.4: Run — expect all tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 +``` +Expected: all tests pass. + +- [ ] **Step 7.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ + modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +git commit -m "feat: add castling execution to processMove" +``` + +--- + +## Task 8: Add rights revocation to `processMove` + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` +- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` + +- [ ] **Step 8.1: Write failing rights-revocation tests** + +Append to `GameControllerTest.scala`: + +```scala + // ──── rights revocation ────────────────────────────────────────────── + + test("processMove: e1g1 revokes both white castling rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved, got $other") + + test("processMove: moving rook from h1 revokes white kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "h1h4") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + newCtx.whiteCastling.queenSide shouldBe true + case other => fail(s"Expected Moved, got $other") + + test("processMove: moving king from e1 revokes both white rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1e2") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved, got $other") + + test("processMove: enemy capture on h1 revokes white kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R2) -> Piece.BlackRook, + sq(File.A, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.Black, "h2h1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + case other => fail(s"Expected Moved, got $other") + + test("processMove: castle attempt when rights revoked returns IllegalMove"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove + + test("processMove: castle attempt when rook not on home square returns IllegalMove"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.G, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove +``` + +- [ ] **Step 8.2: Run — expect the revocation tests to fail** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 +``` +Expected: revocation tests fail (rights unchanged in `newCtx`). Castle-attempt tests may already pass. + +- [ ] **Step 8.3: Add `applyRightsRevocation` to `GameController.scala`** + +Add a private helper and call it from `processMove`: + +```scala + private def applyRightsRevocation( + ctx: GameContext, + turn: Color, + from: Square, + to: Square, + castle: Option[CastleSide] + ): GameContext = + // Step 1: Revoke all rights for a castling move (idempotent with step 2) + val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None)) + + // Step 2: Source-square revocation + val ctx1 = from match + case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None) + case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None) + case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false)) + case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false)) + case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false)) + case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false)) + case _ => ctx0 + + // Step 3: Destination-square revocation (enemy captures a rook on its home square) + to match + case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false)) + case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false)) + case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false)) + case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false)) + case _ => ctx1 +``` + +In `processMove`, replace `val newCtx = ctx.copy(board = newBoard)` with: + +```scala + val newCtx = applyRightsRevocation( + ctx.copy(board = newBoard), turn, from, to, castleOpt + ) +``` + +- [ ] **Step 8.4: Run — expect all controller tests to pass** + +```bash +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 +``` +Expected: all tests pass. + +- [ ] **Step 8.5: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ + modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +git commit -m "feat: add castling rights revocation to processMove" +``` + +--- + +## Task 9: Update `Main` and verify full build + +**Files:** +- Modify: `modules/core/src/main/scala/de/nowchess/chess/Main.scala` + +- [ ] **Step 9.1: Update `Main.scala`** + +```scala +package de.nowchess.chess + +import de.nowchess.api.board.Color +import de.nowchess.chess.controller.GameController +import de.nowchess.chess.logic.GameContext + +object Main { + def main(args: Array[String]): Unit = + println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") + GameController.gameLoop(GameContext.initial, Color.White) +} +``` + +- [ ] **Step 9.2: Run the full build** + +```bash +./gradlew :modules:core:build 2>&1 | tail -20 +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 9.3: Commit** + +```bash +git add modules/core/src/main/scala/de/nowchess/chess/Main.scala +git commit -m "feat: update Main to use GameContext.initial" +``` + +--- + +## Done + +All nine tasks complete. The engine now supports legal castling: + +- White/Black kingside (`e1g1` / `e8g8`) and queenside (`e1c1` / `e8c8`) +- All six legality conditions enforced (rights flags, home squares, empty transit, king not in check, transit squares not attacked) +- Rights revoked on king moves, rook moves, castle moves, and enemy rook captures +- Stalemate/checkmate detection correctly includes castling as a legal move