refactor: NCS-4 curry MoveValidator public API and update all call sites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-31 16:35:32 +02:00
parent 87806d516f
commit 38936b2e10
5 changed files with 57 additions and 58 deletions
@@ -43,10 +43,10 @@ object GameController:
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then
if !MoveValidator.isLegal(board, history)(from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(board, from, to)
val castleOpt = if MoveValidator.isCastle(board)(from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
@@ -13,21 +13,23 @@ object GameRules:
board.pieces
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
.exists { kingSq =>
val targets = MoveValidator.legalTargets(board)
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
targets(sq).contains(kingSq)
}
}
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
val targets = MoveValidator.legalTargets(board, history)
board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
targets(from)
.filter { to =>
val newBoard =
if MoveValidator.isCastle(board, from, to) then
if MoveValidator.isCastle(board)(from, to) then
board.withCastle(color, MoveValidator.castleSide(from, to))
else
board.withMove(from, to)._1
@@ -11,11 +11,11 @@ object MoveValidator:
* - cannot capture own pieces
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
*/
def isLegal(board: Board, from: Square, to: Square): Boolean =
legalTargets(board, from).contains(to)
def isLegal(board: Board)(from: Square, to: Square): Boolean =
legalTargets(board)(from).contains(to)
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
def legalTargets(board: Board, from: Square): Set[Square] =
def legalTargets(board: Board)(from: Square): Set[Square] =
board.pieceAt(from) match
case None => Set.empty
case Some(piece) =>
@@ -70,9 +70,9 @@ object MoveValidator:
.toSet
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
val oneStep = squareAt(fi, ri + dir)
@@ -116,17 +116,17 @@ object MoveValidator:
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
board.pieces.exists { case (from, piece) =>
piece.color == attackerColor && legalTargets(board, from).contains(sq)
piece.color == attackerColor && legalTargets(board)(from).contains(sq)
}
def isCastle(board: Board, from: Square, to: Square): Boolean =
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(board: Board, history: GameHistory, color: Color): Set[Square] =
def castlingTargets(board: Board, history: GameHistory)(color: Color): Set[Square] =
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank)
@@ -151,14 +151,14 @@ object MoveValidator:
kingsideSq.toSet ++ queensideSq.toSet
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
def legalTargets(board: Board, history: GameHistory)(from: Square): Set[Square] =
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
legalTargets(board)(from) ++ castlingTargets(board, history)(piece.color)
case Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color)
case _ =>
legalTargets(board, from)
legalTargets(board)(from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color)
@@ -171,5 +171,5 @@ object MoveValidator:
.toSet
existing ++ epCapture
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
def isLegal(board: Board, history: GameHistory)(from: Square, to: Square): Boolean =
legalTargets(board, history)(from).contains(to)
@@ -106,7 +106,7 @@ object PgnParser:
val reachable: Set[Square] =
board.pieces.collect {
case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board, from).contains(toSquare) => from
MoveValidator.legalTargets(board)(from).contains(toSquare) => from
}.toSet
val candidates: Set[Square] =
@@ -14,36 +14,36 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
// ──── Empty square ───────────────────────────────────────────────────
test("legalTargets returns empty set when no piece at from square"):
MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty
MoveValidator.legalTargets(Board.initial)(sq(File.E, Rank.R4)) shouldBe empty
// ──── isLegal delegates to legalTargets ──────────────────────────────
test("isLegal returns true for a valid pawn move"):
MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true
MoveValidator.isLegal(Board.initial)(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true
test("isLegal returns false for an invalid move"):
MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false
MoveValidator.isLegal(Board.initial)(sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false
// ──── Pawn – White ───────────────────────────────────────────────────
test("white pawn on starting rank can move forward one square"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3))
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3))
test("white pawn on starting rank can move forward two squares"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4))
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4))
test("white pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
MoveValidator.legalTargets(b, sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5)
test("white pawn is blocked by piece directly in front, and cannot jump over it"):
val b = board(
sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R3) -> Piece.BlackPawn
)
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R2))
targets should not contain sq(File.E, Rank.R3)
targets should not contain sq(File.E, Rank.R4)
@@ -52,7 +52,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R4) -> Piece.BlackPawn
)
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R2))
targets should contain(sq(File.E, Rank.R3))
targets should not contain sq(File.E, Rank.R4)
@@ -61,15 +61,15 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.D, Rank.R3) -> Piece.BlackPawn
)
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3))
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3))
test("white pawn cannot capture diagonally when no enemy piece is present"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3)
test("white pawn at A-file does not generate diagonal to the left off the board"):
val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn)
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2))
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R2))
targets should contain(sq(File.A, Rank.R3))
targets should contain(sq(File.A, Rank.R4))
targets.size shouldBe 2
@@ -78,50 +78,50 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("black pawn on starting rank can move forward one and two squares"):
val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn)
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R7))
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R7))
targets should contain(sq(File.E, Rank.R6))
targets should contain(sq(File.E, Rank.R5))
test("black pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn)
MoveValidator.legalTargets(b, sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4)
test("black pawn can capture diagonally when enemy piece is present"):
val b = board(
sq(File.E, Rank.R7) -> Piece.BlackPawn,
sq(File.F, Rank.R6) -> Piece.WhitePawn
)
MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6))
MoveValidator.legalTargets(b)(sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6))
// ──── Knight ─────────────────────────────────────────────────────────
test("knight in center has 8 possible moves"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)).size shouldBe 8
test("knight in corner has only 2 possible moves"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight)
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2
MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)).size shouldBe 2
test("knight cannot land on own piece"):
val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.WhiteRook
)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5)
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5)
test("knight can capture enemy piece"):
val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.BlackRook
)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5))
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5))
// ──── Bishop ─────────────────────────────────────────────────────────
test("bishop slides diagonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop)
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5))
targets should contain(sq(File.H, Rank.R8))
targets should contain(sq(File.C, Rank.R3))
@@ -132,7 +132,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.WhiteRook
)
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5))
targets should not contain sq(File.F, Rank.R6)
targets should not contain sq(File.G, Rank.R7)
@@ -142,7 +142,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.BlackRook
)
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5))
targets should contain(sq(File.F, Rank.R6))
targets should not contain sq(File.G, Rank.R7)
@@ -151,7 +151,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("rook slides orthogonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))
targets should contain(sq(File.D, Rank.R8))
targets should contain(sq(File.D, Rank.R1))
targets should contain(sq(File.A, Rank.R4))
@@ -162,7 +162,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.WhitePawn
)
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R1))
targets should contain(sq(File.B, Rank.R1))
targets should not contain sq(File.C, Rank.R1)
targets should not contain sq(File.D, Rank.R1)
@@ -172,7 +172,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.BlackPawn
)
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R1))
targets should contain(sq(File.B, Rank.R1))
targets should contain(sq(File.C, Rank.R1))
targets should not contain sq(File.D, Rank.R1)
@@ -181,7 +181,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("queen combines rook and bishop movement for 27 squares from d4"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))
targets should contain(sq(File.D, Rank.R8))
targets should contain(sq(File.H, Rank.R4))
targets should contain(sq(File.H, Rank.R8))
@@ -192,66 +192,63 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("king moves one step in all 8 directions from center"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)).size shouldBe 8
test("king at corner has only 3 reachable squares"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing)
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3
MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)).size shouldBe 3
test("king cannot capture own piece"):
val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook
)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4)
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4)
test("king can capture enemy piece"):
val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing,
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))
// ──── Pawn en passant targets ──────────────────────────────────────
test("white pawn includes ep target in legal moves after black double push"):
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
MoveValidator.legalTargets(b, h)(sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
test("white pawn does not include ep target without a preceding double push"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h)(sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
test("black pawn includes ep target in legal moves after white double push"):
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn
)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
MoveValidator.legalTargets(b, h)(sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
test("pawn on wrong file does not get ep target from adjacent double push"):
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
val b = board(
sq(File.A, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
MoveValidator.legalTargets(b, h)(sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
// ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
MoveValidator.legalTargets(b, h)(sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b)(sq(File.D, Rank.R4))