diff --git a/docs/superpowers/plans/2026-03-24-castling.md b/docs/superpowers/plans/2026-03-24-castling.md new file mode 100644 index 0000000..24991b7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-castling.md @@ -0,0 +1,1049 @@ +# 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 diff --git a/docs/superpowers/specs/2026-03-24-castling-design.md b/docs/superpowers/specs/2026-03-24-castling-design.md new file mode 100644 index 0000000..7825cff --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-castling-design.md @@ -0,0 +1,255 @@ +# Castling Implementation Design + +**Date:** 2026-03-24 +**Status:** Approved (rev 2) +**Branch:** castling + +--- + +## Context + +The NowChessSystems chess engine currently operates on a raw `Board` (opaque `Map[Square, Piece]`) paired with a `Color` for turn tracking. Castling requires tracking whether the king and rooks have previously moved — state that does not exist in the current engine layer. The `CastlingRights` and `MoveType.Castle*` types are already defined in the `api` module but are not wired into the engine. + +--- + +## Approach: `GameContext` Wrapper (Option B) + +Introduce a thin `GameContext` wrapper in `modules/core` that bundles `Board` with castling rights for both sides. This is the single seam through which the engine learns about castling availability without pulling in the full FEN-structured `GameState` type. + +--- + +## Section 1 — `GameContext` Type + +**Location:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` + +```scala +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) +``` + +`GameContext.initial` wraps `Board.initial` with `CastlingRights.Both` for both sides. + +`gameLoop` and `processMove` replace `(board: Board, turn: Color)` with `(ctx: GameContext, turn: Color)`. All `MoveResult` variants that previously carried `newBoard: Board` now carry `newCtx: GameContext`. The `gameLoop` render call becomes `Renderer.render(ctx.board)`, and all `gameLoop` pattern match arms that destructure `MoveResult.Moved(newBoard, ...)` or `MoveResult.MovedInCheck(newBoard, ...)` must be updated to destructure `newCtx` and pass it to the recursive `gameLoop` call. + +--- + +## Section 2 — `CastleSide` and Board Extension for Castle Moves + +### `CastleSide` enum + +`CastleSide` is a two-value engine-internal enum defined in `core` (not in `api`). It is co-located in `GameContext.scala` — there is no separate `CastleSide.scala` file. + +```scala +enum CastleSide: + case Kingside, Queenside +``` + +### `withCastle` extension + +`Board.withMove(from, to)` moves a single piece. Castling moves two pieces atomically. To avoid a circular dependency (`api` must not import from `core`), `withCastle` is **not** added to `Board` in the `api` module. Instead it is defined as an extension method in `core`, co-located with `GameContext`: + +```scala +// inside GameContext.scala or a BoardCastleOps.scala in core +extension (b: Board) + def withCastle(color: Color, side: CastleSide): Board = ... +``` + +Post-castle square assignments: +- **Kingside White:** King e1→g1, Rook h1→f1 +- **Queenside White:** King e1→c1, Rook a1→d1 +- **Kingside Black:** King e8→g8, Rook h8→f8 +- **Queenside Black:** King e8→c8, Rook a8→d8 + +--- + +## Section 3 — `MoveValidator` Castling Logic + +### Signature change + +`legalTargets` and `isLegal` are extended to accept `GameContext` when the caller has full game context. To avoid breaking `GameRules.isInCheck` (which uses `legalTargets` with only a `Board` for attacked-square detection), the implementation retains a **board-only private helper** for sliding/jump/normal king targets, and a **public overload** that additionally unions castling targets when a `GameContext` is provided: + +```scala +// board-only (used internally by isInCheck) +def legalTargets(board: Board, from: Square): Set[Square] + +// context-aware (used by legalMoves and processMove) +def legalTargets(ctx: GameContext, from: Square): Set[Square] +``` + +The `GameContext` overload delegates to the `Board` overload for all piece types except King, where it additionally unions `castlingTargets(ctx, color)`. + +`isLegal` is likewise overloaded: + +```scala +// board-only (retained for callers that have no castling context) +def isLegal(board: Board, from: Square, to: Square): Boolean + +// context-aware (used by processMove) +def isLegal(ctx: GameContext, from: Square, to: Square): Boolean +``` + +The context-aware `isLegal(ctx, from, to)` calls `legalTargets(ctx, from).contains(to)` — using the context-aware overload — so castling targets are included in the legality check. + +### `castlingTargets` method + +```scala +def castlingTargets(ctx: GameContext, color: Color): Set[Square] +``` + +For each side (kingside, queenside), checks all six conditions in order (failing fast): + +1. `CastlingRights` flag is `true` for that side (`ctx.castlingFor(color)`) +2. King is on its home square (e1 for White, e8 for Black) +3. Relevant rook is on its home square (h-file for kingside, a-file for queenside) +4. All squares between king and rook are empty +5. King is **not currently in check** — calls `GameRules.isInCheck(ctx.board, color)` using the board-only path (no castling recursion) +6. Each square the king **passes through and lands on** is not attacked — checks that no enemy `legalTargets(board, enemySq)` (board-only) covers those squares + +Transit and landing squares: +- **Kingside:** f-file, g-file (White: f1, g1; Black: f8, g8) +- **Queenside:** d-file, c-file (White: d1, c1; Black: d8, c8). Note: b1/b8 must be empty (condition 4) but the king does not pass through them, so they are not checked for attacks. + +--- + +## Section 4 — `GameRules` Changes + +`GameRules.legalMoves` must accept `GameContext` (not just `Board`) so it can enumerate castling moves as part of the legal move set. This is required for correct stalemate and checkmate detection — a position where the only legal move is to castle must not be evaluated as stalemate. + +```scala +def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] +``` + +Internally it calls `MoveValidator.legalTargets(ctx, from)` (the context-aware overload) for all pieces of `color`, then filters to moves that do not leave the king in check. + +`isInCheck` retains its `(board: Board, color: Color)` signature — it does not need castling context. + +`gameStatus` is updated to accept `GameContext`: + +```scala +def gameStatus(ctx: GameContext, color: Color): PositionStatus +``` + +--- + +## Section 5 — `GameController` Changes + +### Move detection and execution + +`processMove` identifies a castling move by the king occupying its home square and moving exactly two files laterally: +- White: e1→g1 (kingside) or e1→c1 (queenside) +- Black: e8→g8 (kingside) or e8→c8 (queenside) + +Legality is confirmed via `MoveValidator.isLegal(ctx, from, to)` (the context-aware overload, which includes castling targets). When a castling move is legal and executed: +1. Call `ctx.board.withCastle(color, side)` to move both pieces atomically. +2. Revoke **both** castling rights for the moving color in the new `GameContext`. + +### Rights revocation rules (applied on every move) + +After every move `(from, to)` is applied, revoke rights based on both the **source square** and the **destination square**. Both tables are checked independently and all triggered revocations are applied. + +**Source square → revocation** (piece leaves its home square): + +| Source square | Rights revoked | +|---------------|---------------| +| `e1` | Both White castling rights | +| `e8` | Both Black castling rights | +| `a1` | White queenside | +| `h1` | White kingside | +| `a8` | Black queenside | +| `h8` | Black kingside | + +**Destination square → revocation** (a piece — including an enemy piece — arrives on a rook home square, meaning a capture removed the rook): + +| Destination square | Rights revoked | +|--------------------|---------------| +| `a1` | White queenside | +| `h1` | White kingside | +| `a8` | Black queenside | +| `h8` | Black kingside | + +This covers the following cases: +- **King normal move** — source square e1/e8 fires; both rights revoked. +- **King castle move** — the castle-specific step 2 revokes both rights for the moving color. Additionally, the source-square table fires (king departs e1/e8), revoking the same rights a second time. This double-revocation is idempotent and harmless. The king's destination (g1/c1/g8/c8) does not appear in the destination table, so no extra revocation fires there. +- **Own rook move** — source square a1/h1/a8/h8 fires. +- **Enemy capture on a rook home square** — destination square a1/h1/a8/h8 fires, revoking the side that lost the rook. + +`processMove` also calls `GameRules.gameStatus(newCtx, turn.opposite)` — note this call passes the full `GameContext`, not just a `Board`, because `gameStatus` now accepts `GameContext`. + +The revocation is applied to the `GameContext` that results from the move, before it is returned in `MoveResult`. + +### Signatures + +```scala +def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult +def gameLoop(ctx: GameContext, turn: Color): Unit +``` + +`MoveResult.Moved` and `MoveResult.MovedInCheck` carry `newCtx: GameContext` instead of `newBoard: Board`. All `gameLoop` pattern match arms are updated to use `newCtx`. The render call uses `newCtx.board`. + +On checkmate/stalemate reset, `GameContext.initial` is used. + +--- + +## Section 6 — Move Notation + +The player types standard coordinate notation: +- `e1g1` → White kingside castle +- `e1c1` → White queenside castle +- `e8g8` → Black kingside castle +- `e8c8` → Black queenside castle + +No parser changes required. The controller identifies castling by the king moving 2 files from the home square. + +--- + +## Section 7 — Testing + +### `MoveValidatorTest` +- Castling target (g1) is returned when all kingside conditions are met (White) +- Castling target (c1) is returned when all queenside conditions are met (White) +- Castling targets returned for Black kingside (g8) and queenside (c8) +- Castling blocked when transit square is occupied (piece between king and rook) +- Castling blocked when king is in check (condition 5) +- Castling blocked when **transit square** is attacked (e.g., f1 attacked for White kingside) +- Castling blocked when **landing square** is attacked (e.g., g1 attacked for White kingside) +- Castling blocked when `kingSide = false` in `CastlingRights` +- Castling blocked when `queenSide = false` in `CastlingRights` +- Castling blocked when relevant rook is not on its home square + +### `GameControllerTest` +- `processMove` with `e1g1` returns `Moved` with king on g1, rook on f1, and both White castling rights revoked in `newCtx` +- `processMove` with `e1c1` returns `Moved` with king on c1, rook on d1, and both White castling rights revoked in `newCtx` +- `processMove` castle attempt after king has moved returns `IllegalMove` +- `processMove` castle attempt after rook has moved returns `IllegalMove` +- Normal rook move from h1 revokes White kingside right in the returned `newCtx` +- Normal king move from e1 revokes both White rights in the returned `newCtx` +- Enemy capture on h1 (e.g., Black rook captures White rook on h1) revokes White kingside right in the returned `newCtx` + +### `GameRulesTest` +- `legalMoves` includes castling destinations when available +- `legalMoves` excludes castling when king is in check +- `gameStatus` returns `Normal` (not `Drawn`) when the only legal move available is to castle — verifying that the `GameContext` signature change correctly prevents a false stalemate + +--- + +## Files to Create / Modify + +| Action | File | +|--------|------| +| **Create** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala` — includes `CastleSide` enum and `withCastle` Board extension | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` — add `castlingTargets`, board-only + context-aware `legalTargets`/`isLegal` overloads | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` — update `legalMoves` and `gameStatus` to accept `GameContext` | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` — use `GameContext`; castling detection, execution, rights revocation | +| **Modify** | `modules/core/src/main/scala/de/nowchess/chess/Main.scala` — use `GameContext.initial` | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` — new castling tests | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` — update signatures + new castling tests | +| **Modify** | `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` — update signatures + new castling tests | diff --git a/docs/unresolved.md b/docs/unresolved.md new file mode 100644 index 0000000..c8f6738 --- /dev/null +++ b/docs/unresolved.md @@ -0,0 +1,23 @@ +# Unresolved Issues + +## [2026-03-24] JUnitSuiteLike mixin not available for ScalaTest 3.2.19 with Scala 3 + +**Requirement / Bug:** +CLAUDE.md prescribes that all unit tests should extend `AnyFunSuite with Matchers with JUnitSuiteLike`. However, the `JUnitSuiteLike` trait cannot be resolved in the current build configuration. + +**Root Cause (if known):** +- ScalaTest 3.2.19 for Scala 3 does not provide `JUnitSuiteLike` in any public package. +- The `co.helmethair:scalatest-junit-runner:0.1.11` dependency does not expose this trait. +- There is no `org.scalatest:scalatest-junit_3` artifact available for version 3.2.19. +- The trait may have been removed or changed in the ScalaTest 3.x → Scala 3 migration. + +**Attempted Fixes:** +1. Tried importing from `org.scalatest.junit.JUnitSuiteLike` — not found +2. Tried importing from `org.scalatestplus.junit.JUnitSuiteLike` — not found +3. Tried importing from `co.helmethair.scalatest.junit.JUnitSuiteLike` — not found +4. Attempted to add `org.scalatest:scalatest-junit_3:3.2.19` dependency — artifact does not exist in Maven Central + +**Suggested Next Step:** +1. Either find the correct ScalaTest artifact/import for Scala 3 JUnit integration, or +2. Update CLAUDE.md to reflect the actual constraint that unit tests should extend `AnyFunSuite with Matchers` (without `JUnitSuiteLike`), or +3. Investigate whether a different test runner or configuration is needed to achieve JUnit integration with ScalaTest 3 in Scala 3 diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala index eee7624..234e025 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/Main.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/Main.scala @@ -1,10 +1,11 @@ package de.nowchess.chess -import de.nowchess.api.board.{Board, Color} +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(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) } diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 2f59cde..6ee256e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -1,8 +1,9 @@ package de.nowchess.chess.controller import scala.io.StdIn -import de.nowchess.api.board.{Board, Color, Piece} -import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus} +import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle} import de.nowchess.chess.view.Renderer // --------------------------------------------------------------------------- @@ -11,15 +12,15 @@ import de.nowchess.chess.view.Renderer sealed trait MoveResult object MoveResult: - case object Quit extends MoveResult - case class InvalidFormat(raw: String) extends MoveResult - case object NoPiece extends MoveResult - case object WrongColor extends MoveResult - case object IllegalMove extends MoveResult - case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult - case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult - case class Checkmate(winner: Color) extends MoveResult - case object Stalemate extends MoveResult + case object Quit extends MoveResult + case class InvalidFormat(raw: String) extends MoveResult + case object NoPiece extends MoveResult + case object WrongColor extends MoveResult + case object IllegalMove extends MoveResult + case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult + case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult + case class Checkmate(winner: Color) extends MoveResult + case object Stalemate extends MoveResult // --------------------------------------------------------------------------- // Controller @@ -27,10 +28,10 @@ object MoveResult: object GameController: - /** Pure function: interprets one raw input line against the current board state. + /** Pure function: interprets one raw input line against the current game context. * Has no I/O side effects — all output must be handled by the caller. */ - def processMove(board: Board, turn: Color, raw: String): MoveResult = + def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult = raw.trim match case "quit" | "q" => MoveResult.Quit @@ -39,61 +40,97 @@ object GameController: case None => MoveResult.InvalidFormat(trimmed) case Some((from, to)) => - board.pieceAt(from) match + ctx.board.pieceAt(from) match case None => MoveResult.NoPiece case Some(piece) if piece.color != turn => MoveResult.WrongColor case Some(_) => - if !MoveValidator.isLegal(board, from, to) then + if !MoveValidator.isLegal(ctx, from, to) then MoveResult.IllegalMove else - val (newBoard, captured) = board.withMove(from, to) - GameRules.gameStatus(newBoard, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite) + 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 = applyRightsRevocation( + ctx.copy(board = newBoard), turn, from, to, castleOpt + ) + 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 + 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 + /** Thin I/O shell: renders the board, reads a line, delegates to processMove, * prints the outcome, and recurses until the game ends. */ - def gameLoop(board: Board, turn: Color): Unit = + def gameLoop(ctx: GameContext, turn: Color): Unit = println() - print(Renderer.render(board)) + print(Renderer.render(ctx.board)) println(s"${turn.label}'s turn. Enter move: ") val input = Option(StdIn.readLine()).getOrElse("quit").trim - processMove(board, turn, input) match + processMove(ctx, turn, input) match case MoveResult.Quit => println("Game over. Goodbye!") case MoveResult.InvalidFormat(raw) => println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") - gameLoop(board, turn) + gameLoop(ctx, turn) case MoveResult.NoPiece => println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") - gameLoop(board, turn) + gameLoop(ctx, turn) case MoveResult.WrongColor => println(s"That is not your piece.") - gameLoop(board, turn) + gameLoop(ctx, turn) case MoveResult.IllegalMove => println(s"Illegal move.") - gameLoop(board, turn) - case MoveResult.Moved(newBoard, captured, newTurn) => + gameLoop(ctx, turn) + case MoveResult.Moved(newCtx, captured, newTurn) => val prevTurn = newTurn.opposite captured.foreach: cap => val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") - gameLoop(newBoard, newTurn) - case MoveResult.MovedInCheck(newBoard, captured, newTurn) => + gameLoop(newCtx, newTurn) + case MoveResult.MovedInCheck(newCtx, captured, newTurn) => val prevTurn = newTurn.opposite captured.foreach: cap => val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newTurn) + gameLoop(newCtx, newTurn) case MoveResult.Checkmate(winner) => println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, Color.White) + gameLoop(GameContext.initial, Color.White) case MoveResult.Stalemate => println("Stalemate! The game is a draw.") - gameLoop(Board.initial, Color.White) + gameLoop(GameContext.initial, Color.White) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala new file mode 100644 index 0000000..7bb2e7b --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameContext.scala @@ -0,0 +1,47 @@ +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)) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala index c788651..a59a872 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.logic import de.nowchess.api.board.* +import de.nowchess.chess.logic.GameContext enum PositionStatus: case Normal, InCheck, Mated, Drawn @@ -19,13 +20,17 @@ object GameRules: } /** All (from, to) moves for `color` that do not leave their own king in check. */ - def legalMoves(board: Board, color: Color): Set[(Square, Square)] = - board.pieces + 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(board, from) + MoveValidator.legalTargets(ctx, from) // context-aware: includes castling .filter { to => - val (newBoard, _) = board.withMove(from, 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) @@ -33,9 +38,9 @@ object GameRules: .toSet /** Position status for the side whose turn it is (`color`). */ - def gameStatus(board: Board, color: Color): PositionStatus = - val moves = legalMoves(board, color) - val inCheck = isInCheck(board, color) + 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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala index c858013..79c2e2d 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.logic import de.nowchess.api.board.* +import de.nowchess.chess.logic.{GameContext, CastleSide} object MoveValidator: @@ -110,3 +111,57 @@ object MoveValidator: (diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) => squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color)) .toSet + + // ── 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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 0f8a544..ac651aa 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -1,6 +1,8 @@ package de.nowchess.chess.controller import de.nowchess.api.board.* +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.{GameContext, CastleSide} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -9,7 +11,7 @@ import java.io.ByteArrayInputStream class GameControllerTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) - private val initial = Board.initial + private val initial = GameContext.initial // ──── processMove ──────────────────────────────────────────────────── @@ -39,24 +41,24 @@ class GameControllerTest extends AnyFunSuite with Matchers: test("processMove: legal pawn move returns Moved with updated board and flipped turn"): GameController.processMove(initial, Color.White, "e2e4") match - case MoveResult.Moved(newBoard, captured, newTurn) => - newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) - newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None + case MoveResult.Moved(newCtx, captured, newTurn) => + newCtx.board.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) + newCtx.board.pieceAt(sq(File.E, Rank.R2)) shouldBe None captured shouldBe None newTurn shouldBe Color.Black case other => fail(s"Expected Moved, got $other") test("processMove: legal capture returns Moved with the captured piece"): - val captureBoard = Board(Map( + val captureCtx = GameContext(Board(Map( sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.D, Rank.R6) -> Piece.BlackPawn, sq(File.H, Rank.R1) -> Piece.BlackKing, sq(File.H, Rank.R8) -> Piece.WhiteKing - )) - GameController.processMove(captureBoard, Color.White, "e5d6") match - case MoveResult.Moved(newBoard, captured, newTurn) => + ))) + GameController.processMove(captureCtx, Color.White, "e5d6") match + case MoveResult.Moved(newCtx, captured, newTurn) => captured shouldBe Some(Piece.BlackPawn) - newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) + newCtx.board.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) newTurn shouldBe Color.Black case other => fail(s"Expected Moved, got $other") @@ -68,33 +70,33 @@ class GameControllerTest extends AnyFunSuite with Matchers: test("gameLoop: 'quit' exits cleanly without exception"): withInput("quit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: EOF (null readLine) exits via quit fallback"): withInput(""): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: invalid format prints message and recurses until quit"): withInput("badmove\nquit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: NoPiece prints message and recurses until quit"): // E3 is empty in the initial position withInput("e3e4\nquit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: WrongColor prints message and recurses until quit"): // E7 has a Black pawn; it is White's turn withInput("e7e6\nquit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: IllegalMove prints message and recurses until quit"): withInput("e2e5\nquit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: legal non-capture move recurses with new board then quits"): withInput("e2e4\nquit\n"): - GameController.gameLoop(Board.initial, Color.White) + GameController.gameLoop(GameContext.initial, Color.White) test("gameLoop: capture move prints capture message then recurses and quits"): val captureBoard = Board(Map( @@ -104,7 +106,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: sq(File.H, Rank.R8) -> Piece.WhiteKing )) withInput("e5d6\nquit\n"): - GameController.gameLoop(captureBoard, Color.White) + GameController.gameLoop(GameContext(captureBoard), Color.White) // ──── helpers ──────────────────────────────────────────────────────── @@ -118,12 +120,12 @@ class GameControllerTest extends AnyFunSuite with Matchers: test("processMove: legal move that delivers check returns MovedInCheck"): // White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check // Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated - val b = Board(Map( + val ctx = GameContext(Board(Map( sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.C, Rank.R3) -> Piece.WhiteKing, sq(File.H, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "a1a8") match + ))) + GameController.processMove(ctx, Color.White, "a1a8") match case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black case other => fail(s"Expected MovedInCheck, got $other") @@ -131,24 +133,24 @@ class GameControllerTest extends AnyFunSuite with Matchers: // White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8) // After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position) // Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6 - val b = Board(Map( + val ctx = GameContext(Board(Map( sq(File.A, Rank.R1) -> Piece.WhiteQueen, sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "a1h8") match + ))) + GameController.processMove(ctx, Color.White, "a1h8") match case MoveResult.Checkmate(winner) => winner shouldBe Color.White case other => fail(s"Expected Checkmate(White), got $other") test("processMove: legal move that results in stalemate returns Stalemate"): // White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6 // After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position) - val b = Board(Map( + val ctx = GameContext(Board(Map( sq(File.B, Rank.R1) -> Piece.WhiteQueen, sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R8) -> Piece.BlackKing - )) - GameController.processMove(b, Color.White, "b1b6") match + ))) + GameController.processMove(ctx, Color.White, "b1b6") match case MoveResult.Stalemate => succeed case other => fail(s"Expected Stalemate, got $other") @@ -163,7 +165,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: )) val output = captureOutput: withInput("a1h8\nquit\n"): - GameController.gameLoop(b, Color.White) + GameController.gameLoop(GameContext(b), Color.White) output should include("Checkmate! White wins.") test("gameLoop: stalemate prints draw message and resets to new game"): @@ -174,7 +176,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: )) val output = captureOutput: withInput("b1b6\nquit\n"): - GameController.gameLoop(b, Color.White) + GameController.gameLoop(GameContext(b), Color.White) output should include("Stalemate! The game is a draw.") test("gameLoop: MovedInCheck without capture prints check message"): @@ -185,7 +187,7 @@ class GameControllerTest extends AnyFunSuite with Matchers: )) val output = captureOutput: withInput("a1a8\nquit\n"): - GameController.gameLoop(b, Color.White) + GameController.gameLoop(GameContext(b), Color.White) output should include("Black is in check!") test("gameLoop: MovedInCheck with capture prints both capture and check message"): @@ -198,6 +200,208 @@ class GameControllerTest extends AnyFunSuite with Matchers: )) val output = captureOutput: withInput("a1a8\nquit\n"): - GameController.gameLoop(b, Color.White) + GameController.gameLoop(GameContext(b), Color.White) output should include("captures") output should include("Black is in check!") + + // ──── 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") + + // ──── 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 MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + newCtx.whiteCastling.queenSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, 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 MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.kingSide shouldBe false + case other => fail(s"Expected Moved or MovedInCheck, 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 + + test("processMove: moving king from e8 revokes both black rights"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "e8e7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling shouldBe CastlingRights.None + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling shouldBe CastlingRights.None + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: moving rook from a8 revokes black queenside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.A, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "a8a7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling.queenSide shouldBe false + newCtx.blackCastling.kingSide shouldBe true + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling.queenSide shouldBe false + newCtx.blackCastling.kingSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: moving rook from h8 revokes black kingside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R8) -> Piece.BlackKing, + sq(File.H, Rank.R8) -> Piece.BlackRook, + sq(File.H, Rank.R1) -> Piece.WhiteKing + )), + whiteCastling = CastlingRights.None, + blackCastling = CastlingRights.Both + ) + GameController.processMove(ctx, Color.Black, "h8h7") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.blackCastling.kingSide shouldBe false + newCtx.blackCastling.queenSide shouldBe true + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.blackCastling.kingSide shouldBe false + newCtx.blackCastling.queenSide shouldBe true + case other => fail(s"Expected Moved or MovedInCheck, got $other") + + test("processMove: enemy capture on a1 revokes white queenside right"): + val ctx = GameContext( + board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.A, Rank.R2) -> Piece.BlackRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )), + whiteCastling = CastlingRights.Both, + blackCastling = CastlingRights.None + ) + GameController.processMove(ctx, Color.Black, "a2a1") match + case MoveResult.Moved(newCtx, _, _) => + newCtx.whiteCastling.queenSide shouldBe false + case MoveResult.MovedInCheck(newCtx, _, _) => + newCtx.whiteCastling.queenSide shouldBe false + case other => fail(s"Expected Moved or MovedInCheck, got $other") diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala new file mode 100644 index 0000000..812a7c9 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameContextTest.scala @@ -0,0 +1,81 @@ +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 + + 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 + + test("GameContext single-arg apply defaults to CastlingRights.None for both sides"): + val ctx = GameContext(Board.initial) + ctx.whiteCastling shouldBe CastlingRights.None + ctx.blackCastling shouldBe CastlingRights.None diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala index dfcd5f7..752f7ad 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala @@ -1,6 +1,8 @@ package de.nowchess.chess.logic import de.nowchess.api.board.* +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -9,6 +11,9 @@ class GameRulesTest 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) + /** 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)) + // ──── isInCheck ────────────────────────────────────────────────────── test("isInCheck: king attacked by enemy rook on same rank"): @@ -36,22 +41,20 @@ class GameRulesTest extends AnyFunSuite with Matchers: test("legalMoves: move that exposes own king to rook is excluded"): // White King E1, White Rook E4 (pinned on E-file), Black Rook E8 // Moving the White Rook off the E-file would expose the king - val b = board( + val moves = GameRules.legalMoves(ctx( sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R4) -> Piece.WhiteRook, sq(File.E, Rank.R8) -> Piece.BlackRook - ) - val moves = GameRules.legalMoves(b, Color.White) + ), Color.White) moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) test("legalMoves: move that blocks check is included"): // White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5 - val b = board( + val moves = GameRules.legalMoves(ctx( sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.A, Rank.R5) -> Piece.WhiteRook, sq(File.E, Rank.R8) -> Piece.BlackRook - ) - val moves = GameRules.legalMoves(b, Color.White) + ), Color.White) moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5)) // ──── gameStatus ────────────────────────────────────────────────────── @@ -59,30 +62,70 @@ class GameRulesTest extends AnyFunSuite with Matchers: test("gameStatus: checkmate returns Mated"): // White Qh8, Ka6; Black Ka8 // Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position) - val b = board( + GameRules.gameStatus(ctx( sq(File.H, Rank.R8) -> Piece.WhiteQueen, sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Mated + ), Color.Black) shouldBe PositionStatus.Mated test("gameStatus: stalemate returns Drawn"): // White Qb6, Kc6; Black Ka8 // Black king has no legal moves and is not in check (spec-verified position) - val b = board( + GameRules.gameStatus(ctx( sq(File.B, Rank.R6) -> Piece.WhiteQueen, sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Drawn + ), Color.Black) shouldBe PositionStatus.Drawn test("gameStatus: king in check with legal escape returns InCheck"): // White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7 - val b = board( + GameRules.gameStatus(ctx( sq(File.A, Rank.R8) -> Piece.WhiteRook, sq(File.E, Rank.R8) -> Piece.BlackKing - ) - GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.InCheck + ), Color.Black) shouldBe PositionStatus.InCheck test("gameStatus: normal starting position returns Normal"): - GameRules.gameStatus(Board.initial, Color.White) shouldBe PositionStatus.Normal + GameRules.gameStatus(GameContext(Board.initial), Color.White) shouldBe PositionStatus.Normal + + 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)) + + 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 diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index 878ce22..61ba893 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -1,6 +1,8 @@ package de.nowchess.chess.logic import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} +import de.nowchess.api.game.CastlingRights +import de.nowchess.chess.logic.{GameContext, CastleSide} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -209,3 +211,168 @@ class MoveValidatorTest extends AnyFunSuite with Matchers: sq(File.E, Rank.R4) -> Piece.BlackRook ) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) + + // ──── 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)) + + // ──── isCastle / castleSide / isLegal(ctx) ─────────────────────────── + + test("isCastle: returns true when king moves two files"): + val board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook + )) + MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true + + test("isCastle: returns false when king moves one file"): + val board = Board(Map( + sq(File.E, Rank.R1) -> Piece.WhiteKing + )) + MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.F, Rank.R1)) shouldBe false + + test("castleSide: returns Kingside when moving to higher file"): + MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe CastleSide.Kingside + + test("castleSide: returns Queenside when moving to lower file"): + MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.C, Rank.R1)) shouldBe CastleSide.Queenside + + test("isLegal(ctx): returns true for legal castling move"): + 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.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true + + test("isLegal(ctx): returns false for illegal castling move when rights revoked"): + 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.None) + MoveValidator.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe false + + test("castlingTargets: returns empty when king not on home square"): + val ctx = ctxWithRights( + sq(File.D, Rank.R1) -> Piece.WhiteKing, + sq(File.H, Rank.R1) -> Piece.WhiteRook, + sq(File.H, Rank.R8) -> Piece.BlackKing + )() + MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty