feat: implement legal castling (#1)
Build & Test (NowChessSystems) TeamCity build finished

## Summary

  - Introduces `GameContext` wrapper (board + castling rights) threading through the entire engine pipeline
  - Extends `MoveValidator` with `castlingTargets`, context-aware `legalTargets`/`isLegal` overloads, and helpers (`isCastle`, `castleSide`)
  - Updates `GameRules.legalMoves` and `gameStatus` to use `GameContext`, preventing false stalemate when castling is the only legal move
  - Adds castle detection and atomic execution (`withCastle`) to `GameController.processMove`, plus full rights revocation via source- and
  destination-square tables

  ## Test Plan

  - [ ] 142 tests passing, 100% statement and branch coverage on `modules/core`
  - [ ] White/Black kingside (e1g1/e8g8) and queenside (e1c1/e8c8) castling moves execute correctly
  - [ ] All six legality conditions enforced (rights flags, home squares, empty transit, king not in check, transit/landing squares not attacked)
  - [ ] Rights revoked on king moves, own rook moves, castle moves, and enemy rook captures
  - [ ] False stalemate correctly prevented when castling is the only escape

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #1
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #1.
This commit is contained in:
2026-03-24 17:55:00 +01:00
committed by Janis
parent d0289e16f4
commit 00d326c1ba
12 changed files with 2054 additions and 87 deletions
@@ -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")
@@ -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
@@ -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
@@ -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