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
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.chess.logic.{GameContext, CastleSide}
|
||||||
|
|
||||||
object MoveValidator:
|
object MoveValidator:
|
||||||
|
|
||||||
@@ -110,3 +111,57 @@ object MoveValidator:
|
|||||||
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
||||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
||||||
.toSet
|
.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
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
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.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -209,3 +211,168 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
sq(File.E, Rank.R4) -> Piece.BlackRook
|
sq(File.E, Rank.R4) -> Piece.BlackRook
|
||||||
)
|
)
|
||||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
|
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