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..d734200 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) 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..ff619c1 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 @@ -13,21 +13,23 @@ object GameRules: board.pieces .collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq } .exists { kingSq => + val targets = MoveValidator.legalTargets(board) board.pieces.exists { case (sq, piece) => piece.color != color && - MoveValidator.legalTargets(board, sq).contains(kingSq) + targets(sq).contains(kingSq) } } /** All (from, to) moves for `color` that do not leave their own king in check. */ def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] = + val targets = MoveValidator.legalTargets(board, history) board.pieces .collect { case (from, piece) if piece.color == color => from } .flatMap { from => - MoveValidator.legalTargets(board, history, from) // context-aware: includes castling + targets(from) .filter { to => val newBoard = - if MoveValidator.isCastle(board, from, to) then + if MoveValidator.isCastle(board)(from, to) then board.withCastle(color, MoveValidator.castleSide(from, to)) else board.withMove(from, to)._1 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..9c33ae0 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,17 +116,17 @@ object MoveValidator: private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean = board.pieces.exists { case (from, piece) => - piece.color == attackerColor && legalTargets(board, from).contains(sq) + piece.color == attackerColor && legalTargets(board)(from).contains(sq) } - def isCastle(board: Board, from: Square, to: Square): Boolean = + def isCastle(board: Board)(from: Square, to: Square): Boolean = board.pieceAt(from).exists(_.pieceType == PieceType.King) && math.abs(to.file.ordinal - from.file.ordinal) == 2 def castleSide(from: Square, to: Square): CastleSide = if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside - def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] = + def castlingTargets(board: Board, history: GameHistory)(color: Color): Set[Square] = val rights = CastlingRightsCalculator.deriveCastlingRights(history, color) val rank = if color == Color.White then Rank.R1 else Rank.R8 val kingSq = Square(File.E, rank) @@ -151,14 +151,14 @@ object MoveValidator: kingsideSq.toSet ++ queensideSq.toSet - def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] = + def legalTargets(board: Board, history: GameHistory)(from: Square): Set[Square] = board.pieceAt(from) match case Some(piece) if piece.pieceType == PieceType.King => - legalTargets(board, from) ++ castlingTargets(board, history, piece.color) + legalTargets(board)(from) ++ castlingTargets(board, history)(piece.color) case Some(piece) if piece.pieceType == PieceType.Pawn => pawnTargets(board, history, from, piece.color) case _ => - legalTargets(board, from) + legalTargets(board)(from) private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] = val existing = pawnTargets(board, from, color) @@ -171,5 +171,5 @@ object MoveValidator: .toSet existing ++ epCapture - def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = - legalTargets(board, history, from).contains(to) + def isLegal(board: Board, history: GameHistory)(from: Square, to: Square): Boolean = + legalTargets(board, history)(from).contains(to) 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/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))