WIP: feat: NCS-4 Currying #11

Closed
lq64 wants to merge 3 commits from feat/NCS-4 into main
7 changed files with 156 additions and 91 deletions
@@ -0,0 +1,82 @@
# Currying Public API — Design Spec
**Branch:** feat/NCS-11
**Date:** 2026-03-31
---
## Overview
Refactor the public methods of `MoveValidator` and `GameRules` to use multiple parameter groups (currying), separating `(board[, history])` as the context group from the computation parameters. This is a pure style refactoring — no behaviour changes, no new tests required.
---
## Motivation
Currying clarifies intent: `(board, history)` is "the world being operated on"; the remaining parameters are "what varies." It also enables partial application at call sites where board/history are fixed across a loop (e.g. `isInCheck`, `legalMoves`).
---
## Section 1: Methods Being Curried
### `MoveValidator` (public API only)
| Before | After |
|--------|-------|
| `legalTargets(board, from)` | `legalTargets(board)(from)` |
| `legalTargets(board, history, from)` | `legalTargets(board, history)(from)` |
| `isLegal(board, from, to)` | `isLegal(board)(from, to)` |
| `isLegal(board, history, from, to)` | `isLegal(board, history)(from, to)` |
| `isCastle(board, from, to)` | `isCastle(board)(from, to)` |
| `castlingTargets(board, history, color)` | `castlingTargets(board, history)(color)` |
| `castleSide(from, to)` | unchanged — no board parameter |
Private helpers (`isOwnPiece`, `isEnemyPiece`, `slide`, `pawnTargets`, `knightTargets`, `kingTargets`) are **not** changed.
### `GameRules` (all public methods)
| Before | After |
|--------|-------|
| `isInCheck(board, color)` | `isInCheck(board)(color)` |
| `legalMoves(board, history, color)` | `legalMoves(board, history)(color)` |
| `gameStatus(board, history, color)` | `gameStatus(board, history)(color)` |
---
## Section 2: Call Sites Updated
| File | Methods affected |
|------|-----------------|
| `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` | Internal calls to `legalTargets`, `castlingTargets`, `isCastle` |
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | `MoveValidator.legalTargets`, `MoveValidator.isCastle`, `isInCheck` |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | `MoveValidator.isLegal`, `MoveValidator.isCastle`, `GameRules.gameStatus` |
| `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` | `legalTargets`, `isLegal`, `castlingTargets` |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | `isInCheck`, `legalMoves`, `gameStatus` |
`EnPassantCalculator`, `CastlingRightsCalculator`, and their tests are **not** touched.
---
## Section 3: Concrete Style Gain
In `GameRules.isInCheck` and `legalMoves`, `board` is passed to `legalTargets` on every loop iteration today. After currying, it is factored out as a single partial application:
```scala
// Before
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
}
// After
val targets = MoveValidator.legalTargets(board)
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
targets(sq).contains(kingSq)
}
```
---
## Section 4: Testing
Pure refactoring — no new tests. All existing tests must pass after call sites are updated. The test suite is the regression guard.
@@ -43,10 +43,10 @@ object GameController:
case Some(piece) if piece.color != turn => case Some(piece) if piece.color != turn =>
MoveResult.WrongColor MoveResult.WrongColor
case Some(_) => case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then if !MoveValidator.isLegal(board, history)(from, to) then
MoveResult.IllegalMove MoveResult.IllegalMove
else else
val castleOpt = if MoveValidator.isCastle(board, from, to) val castleOpt = if MoveValidator.isCastle(board)(from, to)
then Some(MoveValidator.castleSide(from, to)) then Some(MoveValidator.castleSide(from, to))
else None else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to) val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
@@ -59,7 +59,7 @@ object GameController:
(b.removed(capturedSq), board.pieceAt(capturedSq)) (b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap) else (b, cap)
val newHistory = history.addMove(from, to, castleOpt) val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match GameRules.gameStatus(newBoard, newHistory)(turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Mated => MoveResult.Checkmate(turn)
@@ -9,38 +9,40 @@ enum PositionStatus:
object GameRules: object GameRules:
/** True if `color`'s king is under attack on this board. */ /** True if `color`'s king is under attack on this board. */
def isInCheck(board: Board, color: Color): Boolean = def isInCheck(board: Board)(color: Color): Boolean =
board.pieces board.pieces
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq } .collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
.exists { kingSq => .exists { kingSq =>
val targets = MoveValidator.legalTargets(board)
board.pieces.exists { case (sq, piece) => board.pieces.exists { case (sq, piece) =>
piece.color != color && 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. */ /** 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)] = def legalMoves(board: Board, history: GameHistory)(color: Color): Set[(Square, Square)] =
val targets = MoveValidator.legalTargets(board, history)
board.pieces board.pieces
.collect { case (from, piece) if piece.color == color => from } .collect { case (from, piece) if piece.color == color => from }
.flatMap { from => .flatMap { from =>
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling targets(from)
.filter { to => .filter { to =>
val newBoard = val newBoard =
if MoveValidator.isCastle(board, from, to) then if MoveValidator.isCastle(board)(from, to) then
board.withCastle(color, MoveValidator.castleSide(from, to)) board.withCastle(color, MoveValidator.castleSide(from, to))
else else
board.withMove(from, to)._1 board.withMove(from, to)._1
!isInCheck(newBoard, color) !isInCheck(newBoard)(color)
} }
.map(to => from -> to) .map(to => from -> to)
} }
.toSet .toSet
/** Position status for the side whose turn it is (`color`). */ /** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus = def gameStatus(board: Board, history: GameHistory)(color: Color): PositionStatus =
val moves = legalMoves(board, history, color) val moves = legalMoves(board, history)(color)
val inCheck = isInCheck(board, color) val inCheck = isInCheck(board)(color)
if moves.isEmpty && inCheck then PositionStatus.Mated if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck else if inCheck then PositionStatus.InCheck
@@ -11,11 +11,11 @@ object MoveValidator:
* - cannot capture own pieces * - cannot capture own pieces
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces * - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
*/ */
def isLegal(board: Board, from: Square, to: Square): Boolean = def isLegal(board: Board)(from: Square, to: Square): Boolean =
legalTargets(board, from).contains(to) legalTargets(board)(from).contains(to)
/** All squares a piece on `from` can legally move to (same rules as isLegal). */ /** 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 board.pieceAt(from) match
case None => Set.empty case None => Set.empty
case Some(piece) => case Some(piece) =>
@@ -70,9 +70,9 @@ object MoveValidator:
.toSet .toSet
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] = private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal val fi = from.file.ordinal
val ri = from.rank.ordinal val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1 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 startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
val oneStep = squareAt(fi, ri + dir) val oneStep = squareAt(fi, ri + dir)
@@ -116,24 +116,24 @@ object MoveValidator:
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean = private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
board.pieces.exists { case (from, piece) => 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) && board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
math.abs(to.file.ordinal - from.file.ordinal) == 2 math.abs(to.file.ordinal - from.file.ordinal) == 2
def castleSide(from: Square, to: Square): CastleSide = def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside 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 rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank) val kingSq = Square(File.E, rank)
val enemy = color.opposite val enemy = color.opposite
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) || if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(board, color) then Set.empty GameRules.isInCheck(board)(color) then Set.empty
else else
val kingsideSq = Option.when( val kingsideSq = Option.when(
rights.kingSide && rights.kingSide &&
@@ -151,14 +151,14 @@ object MoveValidator:
kingsideSq.toSet ++ queensideSq.toSet 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 board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King => 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 => case Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color) pawnTargets(board, history, from, piece.color)
case _ => case _ =>
legalTargets(board, from) legalTargets(board)(from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] = private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color) val existing = pawnTargets(board, from, color)
@@ -171,5 +171,5 @@ object MoveValidator:
.toSet .toSet
existing ++ epCapture existing ++ epCapture
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = def isLegal(board: Board, history: GameHistory)(from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to) legalTargets(board, history)(from).contains(to)
@@ -106,7 +106,7 @@ object PgnParser:
val reachable: Set[Square] = val reachable: Set[Square] =
board.pieces.collect { board.pieces.collect {
case (from, piece) if piece.color == color && case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board, from).contains(toSquare) => from MoveValidator.legalTargets(board)(from).contains(toSquare) => from
}.toSet }.toSet
val candidates: Set[Square] = val candidates: Set[Square] =
@@ -13,38 +13,34 @@ class GameRulesTest extends AnyFunSuite with Matchers:
/** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */
private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] = private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] =
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color) GameRules.legalMoves(Board(entries.toMap), GameHistory.empty)(color)
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus = private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color) GameRules.gameStatus(Board(entries.toMap), GameHistory.empty)(color)
// ──── isInCheck ────────────────────────────────────────────────────── // ──── isInCheck ──────────────────────────────────────────────────────
test("isInCheck: king attacked by enemy rook on same rank"): test("isInCheck: king attacked by enemy rook on same rank"):
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.BlackRook sq(File.A, Rank.R1) -> Piece.BlackRook
) )
GameRules.isInCheck(b, Color.White) shouldBe true GameRules.isInCheck(b)(Color.White) shouldBe true
test("isInCheck: king not attacked"): test("isInCheck: king not attacked"):
// Black Rook A3 does not cover E1
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R3) -> Piece.BlackRook sq(File.A, Rank.R3) -> Piece.BlackRook
) )
GameRules.isInCheck(b, Color.White) shouldBe false GameRules.isInCheck(b)(Color.White) shouldBe false
test("isInCheck: no king on board returns false"): test("isInCheck: no king on board returns false"):
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook) val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
GameRules.isInCheck(b, Color.White) shouldBe false GameRules.isInCheck(b)(Color.White) shouldBe false
// ──── legalMoves ───────────────────────────────────────────────────── // ──── legalMoves ─────────────────────────────────────────────────────
test("legalMoves: move that exposes own king to rook is excluded"): 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 moves = testLegalMoves( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook, sq(File.E, Rank.R4) -> Piece.WhiteRook,
@@ -53,7 +49,6 @@ class GameRulesTest extends AnyFunSuite with Matchers:
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
test("legalMoves: move that blocks check is included"): 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 moves = testLegalMoves( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook, sq(File.A, Rank.R5) -> Piece.WhiteRook,
@@ -64,8 +59,6 @@ class GameRulesTest extends AnyFunSuite with Matchers:
// ──── gameStatus ────────────────────────────────────────────────────── // ──── gameStatus ──────────────────────────────────────────────────────
test("gameStatus: checkmate returns Mated"): test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
testGameStatus( testGameStatus(
sq(File.H, Rank.R8) -> Piece.WhiteQueen, sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R6) -> Piece.WhiteKing,
@@ -73,8 +66,6 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Mated )(Color.Black) shouldBe PositionStatus.Mated
test("gameStatus: stalemate returns Drawn"): test("gameStatus: stalemate returns Drawn"):
// White Qb6, Kc6; Black Ka8
// Black king has no legal moves and is not in check (spec-verified position)
testGameStatus( testGameStatus(
sq(File.B, Rank.R6) -> Piece.WhiteQueen, sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.C, Rank.R6) -> Piece.WhiteKing,
@@ -82,14 +73,13 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Drawn )(Color.Black) shouldBe PositionStatus.Drawn
test("gameStatus: king in check with legal escape returns InCheck"): 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
testGameStatus( testGameStatus(
sq(File.A, Rank.R8) -> Piece.WhiteRook, sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing sq(File.E, Rank.R8) -> Piece.BlackKing
)(Color.Black) shouldBe PositionStatus.InCheck )(Color.Black) shouldBe PositionStatus.InCheck
test("gameStatus: normal starting position returns Normal"): test("gameStatus: normal starting position returns Normal"):
GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal GameRules.gameStatus(Board.initial, GameHistory.empty)(Color.White) shouldBe PositionStatus.Normal
test("legalMoves: includes castling destination when available"): test("legalMoves: includes castling destination when available"):
val b = board( val b = board(
@@ -97,7 +87,7 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
) )
GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) GameRules.legalMoves(b, GameHistory.empty)(Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
test("legalMoves: excludes castling when king is in check"): test("legalMoves: excludes castling when king is in check"):
val b = board( val b = board(
@@ -106,13 +96,9 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R8) -> Piece.BlackRook, sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
) )
GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) GameRules.legalMoves(b, GameHistory.empty)(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"): 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 b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
@@ -120,11 +106,9 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.F, Rank.R2) -> Piece.BlackRook, sq(File.F, Rank.R2) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
) )
// No history means castling rights are intact GameRules.gameStatus(b, GameHistory.empty)(Color.White) shouldBe PositionStatus.Normal
GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
test("CastleSide.withCastle correctly positions pieces for Queenside castling"): test("CastleSide.withCastle correctly positions pieces for Queenside castling"):
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
@@ -14,36 +14,36 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
// ──── Empty square ─────────────────────────────────────────────────── // ──── Empty square ───────────────────────────────────────────────────
test("legalTargets returns empty set when no piece at from 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 ────────────────────────────── // ──── isLegal delegates to legalTargets ──────────────────────────────
test("isLegal returns true for a valid pawn move"): 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"): 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 ─────────────────────────────────────────────────── // ──── Pawn – White ───────────────────────────────────────────────────
test("white pawn on starting rank can move forward one square"): test("white pawn on starting rank can move forward one square"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) 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"): test("white pawn on starting rank can move forward two squares"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) 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"): test("white pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) 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"): test("white pawn is blocked by piece directly in front, and cannot jump over it"):
val b = board( val b = board(
sq(File.E, Rank.R2) -> Piece.WhitePawn, sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R3) -> Piece.BlackPawn 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.R3)
targets should not contain sq(File.E, Rank.R4) 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.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R4) -> Piece.BlackPawn 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 contain(sq(File.E, Rank.R3))
targets should not contain sq(File.E, Rank.R4) 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.E, Rank.R2) -> Piece.WhitePawn,
sq(File.D, Rank.R3) -> Piece.BlackPawn 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"): test("white pawn cannot capture diagonally when no enemy piece is present"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) 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"): 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 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.R3))
targets should contain(sq(File.A, Rank.R4)) targets should contain(sq(File.A, Rank.R4))
targets.size shouldBe 2 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"): test("black pawn on starting rank can move forward one and two squares"):
val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn) 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.R6))
targets should contain(sq(File.E, Rank.R5)) targets should contain(sq(File.E, Rank.R5))
test("black pawn not on starting rank cannot move two squares"): test("black pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn) 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"): test("black pawn can capture diagonally when enemy piece is present"):
val b = board( val b = board(
sq(File.E, Rank.R7) -> Piece.BlackPawn, sq(File.E, Rank.R7) -> Piece.BlackPawn,
sq(File.F, Rank.R6) -> Piece.WhitePawn 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 ───────────────────────────────────────────────────────── // ──── Knight ─────────────────────────────────────────────────────────
test("knight in center has 8 possible moves"): test("knight in center has 8 possible moves"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight) 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"): test("knight in corner has only 2 possible moves"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight) 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"): test("knight cannot land on own piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight, sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.WhiteRook 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"): test("knight can capture enemy piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight, sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.BlackRook 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 ───────────────────────────────────────────────────────── // ──── Bishop ─────────────────────────────────────────────────────────
test("bishop slides diagonally across an empty board"): test("bishop slides diagonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop) 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.E, Rank.R5))
targets should contain(sq(File.H, Rank.R8)) targets should contain(sq(File.H, Rank.R8))
targets should contain(sq(File.C, Rank.R3)) 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.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.WhiteRook 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 contain(sq(File.E, Rank.R5))
targets should not contain sq(File.F, Rank.R6) targets should not contain sq(File.F, Rank.R6)
targets should not contain sq(File.G, Rank.R7) 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.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.BlackRook 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.E, Rank.R5))
targets should contain(sq(File.F, Rank.R6)) targets should contain(sq(File.F, Rank.R6))
targets should not contain sq(File.G, Rank.R7) 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"): test("rook slides orthogonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) 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.R8))
targets should contain(sq(File.D, Rank.R1)) targets should contain(sq(File.D, Rank.R1))
targets should contain(sq(File.A, Rank.R4)) 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.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.WhitePawn 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 contain(sq(File.B, Rank.R1))
targets should not contain sq(File.C, Rank.R1) targets should not contain sq(File.C, Rank.R1)
targets should not contain sq(File.D, 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.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.BlackPawn 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.B, Rank.R1))
targets should contain(sq(File.C, Rank.R1)) targets should contain(sq(File.C, Rank.R1))
targets should not contain sq(File.D, 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"): test("queen combines rook and bishop movement for 27 squares from d4"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen) 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.D, Rank.R8))
targets should contain(sq(File.H, Rank.R4)) targets should contain(sq(File.H, Rank.R4))
targets should contain(sq(File.H, Rank.R8)) 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"): test("king moves one step in all 8 directions from center"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing) 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"): test("king at corner has only 3 reachable squares"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing) 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"): test("king cannot capture own piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing, sq(File.D, Rank.R4) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook 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"): test("king can capture enemy piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing, sq(File.D, Rank.R4) -> Piece.WhiteKing,
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))
// ──── Pawn en passant targets ────────────────────────────────────── // ──── Pawn en passant targets ──────────────────────────────────────
test("white pawn includes ep target in legal moves after black double push"): 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( val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) 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"): test("white pawn does not include ep target without a preceding double push"):
val b = board( val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push 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) 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"): 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( val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn, sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn sq(File.E, Rank.R4) -> Piece.WhitePawn
) )
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) 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"): 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( val b = board(
sq(File.A, Rank.R5) -> Piece.WhitePawn, sq(File.A, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) 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 ───── // ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
test("legalTargets with history delegates to geometry-only 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 b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) 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))