Compare commits

..

8 Commits

Author SHA1 Message Date
LQ63 a687567624 docs(docs): 50 Move rule
Build & Test (NowChessSystems) TeamCity build failed
Removed docs from this repo and added them to designated repo
2026-03-30 13:03:57 +02:00
LQ63 c20b71e302 fix: correct 50-move rule threshold to 100 half-moves (FIDE-compliant)
The halfMoveClock counts plies (half-moves). The FIDE 50-move rule requires
50 moves by each side = 100 plies, not 50. Changed both the processMove
and gameLoop checks from >= 50 to >= 100, and updated all tests accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:58:34 +02:00
LQ63 0eaeb06e2b feat: NCS-11 implement 50-move rule with player claim via TUI menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:51:43 +02:00
LQ63 fc42ccfeee docs: add 50-move rule implementation plan for NCS-11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:41:07 +02:00
LQ63 70f6ccf246 docs: add 50-move rule design spec for NCS-11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:32:53 +02:00
TeamCity 707c4826a4 ci: bump version with Build-21 2026-03-29 15:10:35 +00:00
lq64 919beb3b4b feat: NCS-9 En passant implementation (#8)
Build & Test (NowChessSystems) TeamCity build finished
- Add EnPassantCalculator to derive the en passant target square from GameHistory, detect en passant captures, and
  compute the captured pawn's square
  - Extend MoveValidator.legalTargets to include the en passant diagonal square in pawn legal targets
  - Extend GameController.processMove to remove the captured pawn from the board when an en passant capture is played

  Details

  En passant is derived purely from the last HistoryMove — no new state is introduced. If the last move was a double
  pawn push, the target square is the square the pawn passed through. The board mutation follows the same pattern as
  castling: board.withMove moves the capturing pawn, then board.removed removes the captured pawn from its actual square
   (which differs from the destination square).

  Test Plan

  - EnPassantCalculatorTest — 14 unit tests covering target derivation, captured square calculation, and capture
  detection for both colors
  - MoveValidatorTest — 5 new tests: ep target included/excluded based on history, adjacency filter, both colors, case _
   branch coverage
  - GameControllerTest — 2 integration tests: white and black en passant capture removes pawn from board and returns
  correct captured piece
  - 100% scoverage (line/branch/method) confirmed

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #8
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 17:06:50 +02:00
TeamCity ee79dc5b98 ci: bump version with Build-20 2026-03-29 12:06:38 +00:00
11 changed files with 437 additions and 55 deletions
+1
View File
@@ -1,3 +1,4 @@
## (2026-03-27)
## (2026-03-28)
## (2026-03-28)
## (2026-03-29)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=0
PATCH=3
PATCH=4
+33
View File
@@ -28,3 +28,36 @@
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
@@ -8,5 +8,5 @@ import de.nowchess.chess.logic.GameHistory
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
}
@@ -1,7 +1,7 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
@@ -11,15 +11,16 @@ import de.nowchess.chess.view.Renderer
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newHalfMoveClock: Int, newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newHalfMoveClock: Int, newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
case object DrawClaimed extends MoveResult
// ---------------------------------------------------------------------------
// Controller
@@ -30,10 +31,13 @@ object GameController:
/** Pure function: interprets one raw input line against the current game context.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
def processMove(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case "draw" =>
if halfMoveClock >= 100 then MoveResult.DrawClaimed
else MoveResult.InvalidFormat("draw")
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
@@ -44,62 +48,84 @@ object GameController:
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
case Some(piece) =>
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None => board.withMove(from, to)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val isReset = piece.pieceType == PieceType.Pawn || captured.isDefined || isEP
val newClock = if isReset then 0 else halfMoveClock + 1
val newHistory = history.addMove(from, to, castleOpt)
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.Normal => MoveResult.Moved(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int): Unit =
println()
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, input) match
val input =
if halfMoveClock >= 100 then
println(s"[50-move rule] ${turn.label} may claim a draw, or continue playing.")
println(" 1. Claim draw")
println(" 2. Continue")
Option(StdIn.readLine()).getOrElse("2").trim match
case "1" => "draw"
case _ =>
println(s"${turn.label}'s turn. Enter move: ")
Option(StdIn.readLine()).getOrElse("quit").trim
else
println(s"${turn.label}'s turn. Enter move: ")
Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, halfMoveClock, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.DrawClaimed =>
println("Draw claimed by 50-move rule.")
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, history, turn)
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, history, turn)
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, history, turn)
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, history, turn)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
gameLoop(board, history, turn, halfMoveClock)
case MoveResult.Moved(newBoard, newHistory, captured, newClock, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newClock, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newHistory, newTurn)
gameLoop(newBoard, newHistory, newTurn, newClock)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
gameLoop(Board.initial, GameHistory.empty, Color.White, 0)
@@ -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. e2e4 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))
@@ -155,8 +155,21 @@ object MoveValidator:
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
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)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color)
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val epCapture: Set[Square] =
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
.toSet
existing ++ epCapture
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
@@ -11,11 +11,11 @@ import java.io.ByteArrayInputStream
class GameControllerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw)
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String, halfMoveClock: Int = 0): MoveResult =
GameController.processMove(board, history, turn, halfMoveClock, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
GameController.gameLoop(board, history, turn)
private def gameLoop(board: Board, history: GameHistory, turn: Color, halfMoveClock: Int = 0): Unit =
GameController.gameLoop(board, history, turn, halfMoveClock)
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
@@ -48,7 +48,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
captured shouldBe None
@@ -63,7 +63,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.WhiteKing
))
processMove(board, GameHistory.empty, Color.White, "e5d6") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
captured shouldBe Some(Piece.BlackPawn)
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
newTurn shouldBe Color.Black
@@ -133,7 +133,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
case MoveResult.MovedInCheck(_, _, _, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other")
test("processMove: legal move that results in checkmate returns Checkmate"):
@@ -220,7 +220,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
case MoveResult.Moved(newBoard, newHistory, captured, _, newTurn) =>
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
@@ -236,7 +236,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1c1") match
case MoveResult.Moved(newBoard, _, _, _) =>
case MoveResult.Moved(newBoard, _, _, _, _) =>
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
case other => fail(s"Expected Moved, got $other")
@@ -250,7 +250,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other")
@@ -261,10 +261,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "h1h4") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -275,7 +275,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1e2") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other")
@@ -287,9 +287,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -316,9 +316,9 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -329,10 +329,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -344,10 +344,10 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -360,8 +360,140 @@ class GameControllerTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
case MoveResult.Moved(_, newHistory, _, _) =>
case MoveResult.Moved(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
case MoveResult.MovedInCheck(_, newHistory, _, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other")
// ──── en passant ────────────────────────────────────────────────────────
test("en passant capture removes the captured pawn from the board"):
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
val b = Board(Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
val result = GameController.processMove(b, h, Color.White, 0, "e5d6")
result match
case MoveResult.Moved(newBoard, _, captured, _, _) =>
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
captured shouldBe Some(Piece.BlackPawn)
case other => fail(s"Expected Moved but got $other")
test("en passant capture by black removes the captured white pawn"):
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
val b = Board(Map(
Square(File.D, Rank.R4) -> Piece.BlackPawn,
Square(File.E, Rank.R4) -> Piece.WhitePawn,
Square(File.E, Rank.R8) -> Piece.BlackKing,
Square(File.E, Rank.R1) -> Piece.WhiteKing
))
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val result = GameController.processMove(b, h, Color.Black, 0, "d4e3")
result match
case MoveResult.Moved(newBoard, _, captured, _, _) =>
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other")
// ──── processMove: 50-move rule draw claim ───────────────────────────────
test("processMove: 'draw' with halfMoveClock = 100 returns DrawClaimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 100, "draw") shouldBe MoveResult.DrawClaimed
test("processMove: 'draw' with halfMoveClock = 99 returns InvalidFormat"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 99, "draw") shouldBe MoveResult.InvalidFormat("draw")
// ──── processMove: halfMoveClock update ──────────────────────────────────
test("processMove: pawn move resets halfMoveClock to 0"):
GameController.processMove(Board.initial, GameHistory.empty, Color.White, 10, "e2e4") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 15, "a5d5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: en passant capture resets halfMoveClock to 0"):
val b = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
GameController.processMove(b, h, Color.White, 20, "e5d6") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: quiet piece move increments halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 10, "a1a5") match
case MoveResult.Moved(_, _, _, newClock, _) => newClock shouldBe 11
case other => fail(s"Expected Moved, got $other")
test("processMove: MovedInCheck carries updated halfMoveClock"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
GameController.processMove(b, GameHistory.empty, Color.White, 7, "a1a8") match
case MoveResult.MovedInCheck(_, _, _, newClock, _) => newClock shouldBe 8
case other => fail(s"Expected MovedInCheck, got $other")
// ──── gameLoop: 50-move rule menu ────────────────────────────────────────
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 100 and draw claimed"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("1\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 100)
output should include("50-move rule")
output should include("Draw claimed by 50-move rule.")
test("gameLoop: shows 50-move rule menu when halfMoveClock >= 100 and player continues"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("2\nquit\n"):
GameController.gameLoop(b, GameHistory.empty, Color.White, 100)
output should include("50-move rule")
output should include("White's turn")
test("gameLoop: no 50-move rule menu when halfMoveClock < 100"):
val output = captureOutput:
withInput("quit\n"):
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White, 99)
output should not include "50-move rule"
@@ -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
@@ -211,3 +211,47 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R4) -> Piece.BlackRook
)
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))
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)
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))
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)
// ──── 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))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=2
MINOR=4
PATCH=0