Files
NowChessSystems/docs/superpowers/plans/2026-03-24-castling.md
T
2026-03-24 11:07:51 +01:00

1050 lines
43 KiB
Markdown

# 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