diff --git a/docs/superpowers/specs/2026-03-31-currying-public-api-design.md b/docs/superpowers/specs/2026-03-31-currying-public-api-design.md new file mode 100644 index 0000000..2307923 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-currying-public-api-design.md @@ -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. diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 120b9e9..c556cda 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -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) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala index 6ef0549..24c1504 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala @@ -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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala index 22a8eee..c40904c 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala @@ -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) diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala index a362daf..53d459b 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala @@ -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] = diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala index 5f02f19..83a5657 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala @@ -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, diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index 6c819dd..5c565fb 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -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))