Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
legalMovescall sites inGameRulesTest.scalato useGameContext
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
legalMovessignature inGameRules.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
legalMovesto use context-awarelegalTargetsand 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
gameStatuscall sites and add false-stalemate test inGameRulesTest.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
gameStatusinGameRules.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.scalato useGameContext
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:
val initial = Board.initial→val initial = GameContext.initial- Every
Board(Map(...))test board →GameContext(Board(Map(...)))(no-rights convenience constructor) GameController.processMove(board, ...)→GameController.processMove(ctx, ...)GameController.gameLoop(Board.initial, ...)→GameController.gameLoop(GameContext.initial, ...)MoveResult.Moved(newBoard, ...)→MoveResult.Moved(newCtx, ...); then access board asnewCtx.boardMoveResult.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.scalasignatures (no castling logic)
Update imports, MoveResult variants, processMove, and gameLoop:
MoveResult changes — rename newBoard → newCtx:
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
processMoveinGameController.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
applyRightsRevocationtoGameController.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