From ffe663a62e1b5ef7c9901c1c33944c7636ff979d Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 24 Mar 2026 12:36:02 +0100 Subject: [PATCH] feat: add castling logic to MoveValidator (castlingTargets + context-aware overloads) Co-Authored-By: Claude Sonnet 4.6 --- .../nowchess/chess/logic/MoveValidator.scala | 55 ++++++ .../chess/logic/MoveValidatorTest.scala | 167 ++++++++++++++++++ 2 files changed, 222 insertions(+) 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/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