feat: add castling logic to MoveValidator (castlingTargets + context-aware overloads)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user