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

43 KiB

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:

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
./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:

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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameContextTest" 2>&1 | tail -20

Expected: 9 tests, 9 passed.

  • Step 1.5: Commit
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:

import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{GameContext, CastleSide}
  // ──── 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
./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:

  // ── 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
./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
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:

import de.nowchess.chess.logic.GameContext

Add a private helper and update the two legalMoves tests:

  /** 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:

  // 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
./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):

  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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest" 2>&1 | tail -20

Expected: all existing tests pass.

  • Step 3.5: Commit
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:

import de.nowchess.api.game.CastlingRights

Append to the file:

  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
./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:

  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
./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
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:

  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
./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
  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
./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
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:

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.initialval 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
./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 newBoardnewCtx:

  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):

  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

./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
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:

  // ──── 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
./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:

              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
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20

Expected: all tests pass.

  • Step 7.5: Commit
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:

  // ──── 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
./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:

  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:

                  val newCtx = applyRightsRevocation(
                    ctx.copy(board = newBoard), turn, from, to, castleOpt
                  )
  • Step 8.4: Run — expect all controller tests to pass
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20

Expected: all tests pass.

  • Step 8.5: Commit
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

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
./gradlew :modules:core:build 2>&1 | tail -20

Expected: BUILD SUCCESSFUL

  • Step 9.3: Commit
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