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 =>
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)
@@ -59,7 +59,7 @@ object GameController:
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
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.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
@@ -9,38 +9,40 @@ enum PositionStatus:
object GameRules:
/** 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
.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)] =
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
!isInCheck(newBoard, color)
!isInCheck(newBoard)(color)
}
.map(to => from -> to)
}
.toSet
/** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
val moves = legalMoves(board, history, color)
val inCheck = isInCheck(board, color)
def gameStatus(board: Board, history: GameHistory)(color: Color): PositionStatus =
val moves = legalMoves(board, history)(color)
val inCheck = isInCheck(board)(color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
@@ -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,24 +116,24 @@ 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)
val enemy = color.opposite
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(board, color) then Set.empty
GameRules.isInCheck(board)(color) then Set.empty
else
val kingsideSq = Option.when(
rights.kingSide &&
@@ -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] =
@@ -13,38 +13,34 @@ class GameRulesTest extends AnyFunSuite with Matchers:
/** 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)] =
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 =
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty)(color)
// ──── isInCheck ──────────────────────────────────────────────────────
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(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
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"):
// Black Rook A3 does not cover E1
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
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"):
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 ─────────────────────────────────────────────────────
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(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
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))
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(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
@@ -64,8 +59,6 @@ class GameRulesTest extends AnyFunSuite with Matchers:
// ──── gameStatus ──────────────────────────────────────────────────────
test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
testGameStatus(
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
@@ -73,8 +66,6 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Mated
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(
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
@@ -82,14 +73,13 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Drawn
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(
sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing
)(Color.Black) shouldBe PositionStatus.InCheck
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"):
val b = board(
@@ -97,7 +87,7 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteRook,
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"):
val b = board(
@@ -106,13 +96,9 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R8) -> Piece.BlackRook,
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"):
// 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(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
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.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"):
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook,
@@ -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))