feat: NCS-9 add EnPassantCalculator with target derivation and capture logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
object EnPassantCalculator:
|
||||
|
||||
/** Returns the en passant target square if the last move was a double pawn push.
|
||||
* The target is the square the pawn passed through (e.g. e2→e4 yields e3).
|
||||
*/
|
||||
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
|
||||
history.moves.lastOption.flatMap: move =>
|
||||
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
|
||||
val isDoublePush = math.abs(rankDiff) == 2
|
||||
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
|
||||
if isDoublePush && isPawn then
|
||||
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
|
||||
Some(Square(move.to.file, Rank.values(midRankIdx)))
|
||||
else None
|
||||
|
||||
/** True if moving from→to is an en passant capture. */
|
||||
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
|
||||
enPassantTarget(board, history).contains(to) &&
|
||||
math.abs(to.file.ordinal - from.file.ordinal) == 1
|
||||
|
||||
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
|
||||
* White captures upward → captured pawn is one rank below `to`.
|
||||
* Black captures downward → captured pawn is one rank above `to`.
|
||||
*/
|
||||
def capturedPawnSquare(to: Square, color: Color): Square =
|
||||
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
|
||||
Square(to.file, Rank.values(capturedRankIdx))
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
// ──── enPassantTarget ────────────────────────────────────────────────
|
||||
|
||||
test("enPassantTarget returns None for empty history"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was a single pawn push"):
|
||||
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was not a pawn"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
|
||||
|
||||
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
|
||||
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
|
||||
|
||||
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
|
||||
|
||||
// ──── capturedPawnSquare ─────────────────────────────────────────────
|
||||
|
||||
test("capturedPawnSquare for white capturing on e6 returns e5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
|
||||
|
||||
test("capturedPawnSquare for black capturing on e3 returns e4"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
|
||||
|
||||
test("capturedPawnSquare for white capturing on d6 returns d5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
|
||||
|
||||
// ──── isEnPassant ────────────────────────────────────────────────────
|
||||
|
||||
test("isEnPassant returns true for valid white en passant capture"):
|
||||
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
|
||||
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))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("isEnPassant returns true for valid black en passant capture"):
|
||||
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
|
||||
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))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("isEnPassant returns false when no en passant target in history"):
|
||||
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
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when piece at from is not a pawn"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when to does not match ep target"):
|
||||
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))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when from square is empty"):
|
||||
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
Reference in New Issue
Block a user