# 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