38a68549f5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1050 lines
43 KiB
Markdown
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
|