Compare commits

...

3 Commits

Author SHA1 Message Date
lq64 412ed986a9 feat: NCS-11 50-move rule (#9)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Implements the FIDE 50-move draw rule: a player may claim a draw if no pawn move or capture has occurred in the last
   50 full moves (100 half-moves)
  - Draw is not automatic — the eligible player must claim it via a TUI menu shown at the start of their turn
  - halfMoveClock: Int is threaded through processMove and gameLoop; resets on pawn move, capture, or en passant;
  increments on all other moves

  Changes

  - GameController.scala: extended MoveResult.Moved and MoveResult.MovedInCheck with newHalfMoveClock: Int; added
  MoveResult.DrawClaimed; added halfMoveClock parameter to processMove and gameLoop; TUI menu shown when clock ≥ 100
  - Main.scala: initial gameLoop call passes halfMoveClock = 0
  - GameControllerTest.scala: updated all existing pattern matches; added 10 new tests covering clock reset, clock
  increment, draw claim, and TUI menu behaviour

  Test plan

  - processMove: 'draw' with halfMoveClock = 100 → DrawClaimed
  - processMove: 'draw' with halfMoveClock = 99 → InvalidFormat
  - Pawn move / capture / en passant → clock resets to 0
  - Quiet piece move → clock increments by 1
  - MovedInCheck carries updated clock
  - TUI menu appears when clock ≥ 100; option 1 claims draw, option 2 continues
  - No TUI menu when clock < 100
  - All 197 tests passing

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-01 10:36:24 +02:00
TeamCity 8bbeead702 ci: bump version with Build-24 2026-04-01 07:17:44 +00:00
Janis e5e20c566e fix: update move validation to check for king safety (#13)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #13
2026-04-01 09:07:06 +02:00
17 changed files with 309 additions and 74 deletions
-1
View File
@@ -20,5 +20,4 @@ When invoked BEFORE scala-implementer (no implementation exists yet):
When invoked AFTER scala-implementer (implementation exists): When invoked AFTER scala-implementer (implementation exists):
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
To regenerate the report run the tests first. To regenerate the report run the tests first.
+1
View File
@@ -3,3 +3,4 @@
## (2026-03-28) ## (2026-03-28)
## (2026-03-29) ## (2026-03-29)
## (2026-03-31) ## (2026-03-31)
## (2026-04-01)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=0 MINOR=0
PATCH=5 PATCH=6
+20
View File
@@ -79,3 +79,23 @@
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032)) * 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)) * 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)) * update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-04-01)
### 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-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* 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))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
@@ -63,7 +63,8 @@ object GameController:
case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight case PromotionPiece.Knight => PieceType.Knight
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType)) val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
val newHistory = history.addMove(from, to, None, Some(piece)) // Promotion is always a pawn move → clock resets
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
toMoveResult(newBoard, newHistory, captured, turn) toMoveResult(newBoard, newHistory, captured, turn)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -75,7 +76,7 @@ object GameController:
case None => MoveResult.NoPiece case None => MoveResult.NoPiece
case Some(piece) if piece.color != turn => MoveResult.WrongColor case Some(piece) if piece.color != turn => MoveResult.WrongColor
case Some(_) => case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
else if MoveValidator.isPromotionMove(board, from, to) then else if MoveValidator.isPromotionMove(board, from, to) then
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn) MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
else applyNormalMove(board, history, turn, from, to) else applyNormalMove(board, history, turn, from, to)
@@ -91,7 +92,9 @@ object GameController:
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq)) (b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap) else (b, cap)
val newHistory = history.addMove(from, to, castleOpt) val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val wasCapture = captured.isDefined
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
toMoveResult(newBoard, newHistory, captured, turn) toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult = private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
@@ -67,6 +67,19 @@ class GameEngine(
case "redo" => case "redo" =>
performRedo() performRedo()
case "draw" =>
if currentHistory.halfMoveClock >= 100 then
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear()
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
else
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" => case "" =>
val event = InvalidMoveEvent( val event = InvalidMoveEvent(
currentBoard, currentBoard,
@@ -77,70 +90,65 @@ class GameEngine(
notifyObservers(event) notifyObservers(event)
case moveInput => case moveInput =>
// Try to parse as a move
Parser.parseMove(moveInput) match Parser.parseMove(moveInput) match
case None => case None =>
val event = InvalidMoveEvent( notifyObservers(InvalidMoveEvent(
currentBoard, currentBoard, currentHistory, currentTurn,
currentHistory,
currentTurn,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
) ))
notifyObservers(event)
case Some((from, to)) => case Some((from, to)) =>
// Create a move command with current state snapshot handleParsedMove(from, to, moveInput)
val cmd = MoveCommand(
from = from,
to = to,
previousBoard = Some(currentBoard),
previousHistory = Some(currentHistory),
previousTurn = Some(currentTurn)
)
// Execute the move through GameController
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
handleFailedMove(moveInput)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
// Move succeeded - store result and execute through invoker
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
// Move resulted in stalemate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
} }
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
val cmd = MoveCommand(
from = from,
to = to,
previousBoard = Some(currentBoard),
previousHistory = Some(currentHistory),
previousTurn = Some(currentTurn)
)
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
handleFailedMove(moveInput)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
/** Undo the last move. */ /** Undo the last move. */
def undo(): Unit = synchronized { def undo(): Unit = synchronized {
performUndo() performUndo()
@@ -11,21 +11,36 @@ case class HistoryMove(
promotionPiece: Option[PromotionPiece] = None promotionPiece: Option[PromotionPiece] = None
) )
/** Complete game history: ordered list of moves. */ /** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
case class GameHistory(moves: List[HistoryMove] = List.empty): *
* @param moves moves played so far, oldest first
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
*/
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
/** Add a raw HistoryMove record. Clock increments by 1.
* Use the coordinate overload when you know whether the move is a pawn move or capture.
*/
def addMove(move: HistoryMove): GameHistory = def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move) GameHistory(moves :+ move, halfMoveClock + 1)
def addMove(from: Square, to: Square): GameHistory =
addMove(HistoryMove(from, to, None))
/** Add a move by coordinates.
*
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
*
* If neither flag is set the clock increments by 1.
*/
def addMove( def addMove(
from: Square, from: Square,
to: Square, to: Square,
castleSide: Option[CastleSide] = None, castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory = ): GameHistory =
addMove(HistoryMove(from, to, castleSide, promotionPiece)) val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
object GameHistory: object GameHistory:
val empty: GameHistory = GameHistory() val empty: GameHistory = GameHistory()
@@ -22,7 +22,8 @@ object PgnExporter:
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr"
moveLines.mkString(" ") + " *" val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines else if moveText.isEmpty then headerLines
@@ -67,6 +67,20 @@ case class BoardResetEvent(
turn: Color turn: Color
) extends GameEvent ) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Observer trait: implement to receive game state updates. */ /** Observer trait: implement to receive game state updates. */
trait Observer: trait Observer:
def onGameEvent(event: GameEvent): Unit def onGameEvent(event: GameEvent): Unit
@@ -43,6 +43,30 @@ class GameControllerTest extends AnyFunSuite with Matchers:
// White pawn at E2 cannot jump three squares to E5 // White pawn at E2 cannot jump three squares to E5
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
test("processMove: move that leaves own king in check returns IllegalMove"):
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
// geometrically legal but does not resolve the check — must be rejected.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.D, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
test("processMove: move that resolves check is allowed"):
// White King E1 is in check from Black Rook E8 along the E-file.
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a5e5") match
case _: MoveResult.Moved => succeed
case other => fail(s"Expected Moved, got $other")
test("processMove: legal pawn move returns Moved with updated board and flipped turn"): test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
@@ -464,3 +488,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
PromotionPiece.Knight, Color.White PromotionPiece.Knight, Color.White
) )
result should be (MoveResult.Stalemate) result should be (MoveResult.Stalemate)
// ──── half-move clock propagation ────────────────────────────────────
test("processMove: non-pawn non-capture increments halfMoveClock"):
// g1f3 is a knight move — not a pawn, not a capture
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 1
case other => fail(s"Expected Moved, got $other")
test("processMove: pawn move resets halfMoveClock to 0"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
// White pawn on e5, Black pawn on d6 — exd6 is a capture
val board = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val history = GameHistory(halfMoveClock = 10)
processMove(board, history, Color.White, "e5d6") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: clock carries from previous history on non-pawn non-capture"):
val history = GameHistory(halfMoveClock = 5)
processMove(Board.initial, history, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 6
case other => fail(s"Expected Moved, got $other")
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent} import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers:
engine.board shouldBe boardAfterSecondMove engine.board shouldBe boardAfterSecondMove
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
// ──── 50-move rule ───────────────────────────────────────────────────
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[DrawClaimedEvent]
test("GameEngine: state resets to initial after draw claimed"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
engine.processUserInput("draw")
engine.board shouldBe Board.initial
engine.history shouldBe GameHistory.empty
engine.turn shouldBe Color.White
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3") // knight move on initial board
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
// Mock Observer for testing // Mock Observer for testing
private class MockObserver extends Observer: private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]() val events = mutable.ListBuffer[GameEvent]()
@@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
newHistory.moves should have length 1 newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (None) newHistory.moves.head.castleSide should be (None)
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
// ──── half-move clock ────────────────────────────────────────────────
test("halfMoveClock starts at 0"):
GameHistory.empty.halfMoveClock shouldBe 0
test("halfMoveClock increments on a non-pawn non-capture move"):
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
h.halfMoveClock shouldBe 1
test("halfMoveClock resets to 0 on a pawn move"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 on a capture"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock carries across multiple moves"):
val h = GameHistory.empty
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
h.halfMoveClock shouldBe 1
test("GameHistory can be initialised with a non-zero halfMoveClock"):
val h = GameHistory(halfMoveClock = 42)
h.halfMoveClock shouldBe 42
@@ -67,3 +67,22 @@ class FenExporterTest extends AnyFunSuite with Matchers:
) )
val fen = FenExporter.gameStateToFen(gameState) val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"):
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.notation.FenParser
val history = GameHistory(halfMoveClock = 42)
val gameState = GameState(
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = history.halfMoveClock,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
FenParser.parseFen(fen) match
case Some(gs) => gs.halfMoveClock shouldBe 42
case None => fail("FEN parsing failed")
@@ -100,3 +100,15 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include ("e2e4") pgn should include ("e2e4")
pgn should not include ("=") pgn should not include ("=")
} }
test("exportGame uses Result header as termination marker"):
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
pgn should endWith("1/2-1/2")
test("exportGame with no Result header still uses * as default"):
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn shouldBe "1. e2e4 *"
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=5 MINOR=6
PATCH=0 PATCH=0
+6
View File
@@ -0,0 +1,6 @@
## (2026-04-01)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
+3
View File
@@ -0,0 +1,3 @@
MAJOR=0
MINOR=1
PATCH=0