Compare commits

..

1 Commits

Author SHA1 Message Date
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
13 changed files with 37 additions and 1093 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,649 +0,0 @@
# 50-Move Rule Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the FIDE 50-move rule: track the half-move clock in `GameHistory`, let the player claim a draw by typing `draw` once the clock reaches 100, notify observers when the threshold is crossed, fix PGN to use the `Result` header as its termination marker, and verify FEN round-trips the clock correctly.
**Architecture:** `GameHistory` gains a `halfMoveClock: Int` field updated by `GameController`; `GameEngine` handles the `"draw"` text command and fires two new events (`FiftyMoveRuleAvailableEvent`, `DrawClaimedEvent`); `PgnExporter` derives its termination marker from the `Result` header instead of hardcoding `*`. No changes to `MoveValidator`, `GameRules`, `EnPassantCalculator`, or `CastlingRightsCalculator`.
**Tech Stack:** Scala 3.5.x, ScalaTest `AnyFunSuite with Matchers`, Gradle
---
## File Map
| File | Role |
|------|------|
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala` | Add `halfMoveClock` field; extend `addMove` with clock-reset flags |
| `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` | Use `Result` header as PGN termination marker |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Compute and pass `wasPawnMove`/`wasCapture` flags in `applyNormalMove` and `completePromotion` |
| `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` | Add `FiftyMoveRuleAvailableEvent` and `DrawClaimedEvent` |
| `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` | Handle `"draw"` input; fire `FiftyMoveRuleAvailableEvent` after eligible moves |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` | Clock update rules |
| `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` | `1/2-1/2` termination marker |
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Clock values from `applyNormalMove` / `completePromotion` |
| `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala` | `"draw"` command and `FiftyMoveRuleAvailableEvent` |
| `modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala` | FEN round-trip for non-zero `halfMoveClock` |
---
## Task 1: `GameHistory` — half-move clock field and `addMove` flags
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala`
---
- [ ] **Step 1: Write the failing tests**
Add to `GameHistoryTest.scala` (after the existing tests):
```scala
// ──── 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
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" 2>&1 | tail -20
```
Expected: compile error — `halfMoveClock` not a field of `GameHistory`, `wasPawnMove`/`wasCapture` not params of `addMove`.
- [ ] **Step 3: Implement the changes in `GameHistory.scala`**
Replace the entire file with:
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
import de.nowchess.api.move.PromotionPiece
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[CastleSide],
promotionPiece: Option[PromotionPiece] = None
)
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
*
* @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 =
GameHistory(moves :+ move, halfMoveClock + 1)
/** 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(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
object GameHistory:
val empty: GameHistory = GameHistory()
```
- [ ] **Step 4: Run tests**
```bash
./gradlew :modules:core:test 2>&1 | tail -10
```
Expected: BUILD SUCCESSFUL — all existing tests pass (the new `halfMoveClock` field defaults to 0 so no existing construction sites break), new clock tests pass.
- [ ] **Step 5: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala \
modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala
git commit -m "feat: NCS-11 add halfMoveClock to GameHistory with addMove reset flags"
```
---
## Task 2: `PgnExporter` — use `Result` header as termination marker
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala`
---
- [ ] **Step 1: Write failing tests**
Add to `PgnExporterTest.scala` (after the existing tests):
```scala
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 *"
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -20
```
Expected: FAIL — `exportGame` with `"Result" -> "1/2-1/2"` still produces `*`.
- [ ] **Step 3: Implement the fix in `PgnExporter.scala`**
Find the line that builds the move text — currently:
```scala
moveLines.mkString(" ") + " *"
```
Replace it with:
```scala
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
```
The full updated `exportGame` method:
```scala
def exportGame(headers: Map[String, String], history: GameHistory): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if history.moves.isEmpty then ""
else
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
```
- [ ] **Step 4: Run tests**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -10
```
Expected: BUILD SUCCESSFUL, all PGN exporter tests pass.
- [ ] **Step 5: Run full test suite**
```bash
./gradlew :modules:core:test 2>&1 | tail -10
```
Expected: BUILD SUCCESSFUL.
- [ ] **Step 6: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala \
modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala
git commit -m "feat: NCS-11 derive PGN termination marker from Result header"
```
---
## Task 3: `GameController` — pass clock flags in `applyNormalMove` and `completePromotion`
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
---
- [ ] **Step 1: Write failing tests**
Add to `GameControllerTest.scala` (after the existing tests):
```scala
// ──── 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")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20
```
Expected: FAIL — clock tests show 1 where 0 is expected (pawn/capture not resetting) and 0 where 1 is expected (knight not incrementing from initial empty history).
- [ ] **Step 3: Implement the fix in `GameController.scala`**
Update `applyNormalMove` and `completePromotion`. Replace the entire file with:
```scala
package de.nowchess.chess.controller
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.*
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
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 PromotionRequired(
from: Square,
to: Square,
boardBefore: Board,
historyBefore: GameHistory,
captured: Option[Piece],
turn: Color
) 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
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
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 =
raw.trim match
case "quit" | "q" => MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None => MoveResult.InvalidFormat(trimmed)
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
/** Apply a previously detected promotion move with the chosen piece.
* Called after processMove returned PromotionRequired.
*/
def completePromotion(
board: Board,
history: GameHistory,
from: Square,
to: Square,
piece: PromotionPiece,
turn: Color
): MoveResult =
val (boardAfterMove, captured) = board.withMove(from, to)
val promotedPieceType = piece match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
// Promotion is always a pawn move → clock resets
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
toMoveResult(newBoard, newHistory, captured, turn)
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
board.pieceAt(from) match
case None => MoveResult.NoPiece
case Some(piece) if piece.color != turn => MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove
else if MoveValidator.isPromotionMove(board, from, to) then
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
else applyNormalMove(board, history, turn, from, to)
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
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 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)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
```
- [ ] **Step 4: Run tests**
```bash
./gradlew :modules:core:test 2>&1 | tail -10
```
Expected: BUILD SUCCESSFUL.
- [ ] **Step 5: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "feat: NCS-11 propagate half-move clock flags through GameController"
```
---
## Task 4: `Observer` events + `GameEngine` draw command
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
- Modify: `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala`
---
- [ ] **Step 1: Write failing tests**
Add the following to `GameEngineTest.scala`. The mock observer class is already present at the bottom of that file — add these tests before it:
```scala
// ──── 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
```
Also add the import to `GameEngineTest.scala`'s import block:
```scala
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
```
Add the following to `FenExporterTest.scala`:
```scala
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)
val parsed = FenParser.parseFen(fen).get
parsed.halfMoveClock shouldBe 42
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEngineTest" 2>&1 | tail -20
```
Expected: compile error — `DrawClaimedEvent` and `FiftyMoveRuleAvailableEvent` not yet defined.
- [ ] **Step 3: Add new events to `Observer.scala`**
Add the two new event cases to `Observer.scala`, after `BoardResetEvent`:
```scala
/** 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
```
- [ ] **Step 4: Update `GameEngine.scala` — add `"draw"` case and `FiftyMoveRuleAvailableEvent` notification**
In `processUserInput`, add the `"draw"` case immediately before the `case moveInput =>` fallthrough (after `case "redo" =>`):
```scala
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."
))
```
In the same method, in both the `MoveResult.Moved` and `MoveResult.MovedInCheck` handling branches, add a `FiftyMoveRuleAvailableEvent` check **after** the existing `notifyObservers` call for that branch.
The `Moved` branch currently reads:
```scala
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)
```
Replace it with:
```scala
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 newHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
```
The `MovedInCheck` branch currently reads:
```scala
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))
```
Replace it with:
```scala
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 newHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
```
Also add `FiftyMoveRuleAvailableEvent` and `DrawClaimedEvent` to the import at the top of `GameEngine.scala`:
```scala
import de.nowchess.chess.observer.*
```
(Already a wildcard import — no change needed there.)
- [ ] **Step 5: Run tests**
```bash
./gradlew :modules:core:test 2>&1 | tail -10
```
Expected: BUILD SUCCESSFUL, all tests pass.
- [ ] **Step 6: Commit**
```bash
git add modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala \
modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala \
modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala \
modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala
git commit -m "feat: NCS-11 implement 50-move rule draw claim and observer events"
```
@@ -1,239 +0,0 @@
# 50-Move Rule — Design Spec
**Branch:** feat/NCS-11
**Date:** 2026-03-31
---
## Overview
Implement the FIDE 50-move rule: when 100 consecutive half-moves (plies) have been played without a pawn move or capture, the player whose turn it is may claim a draw by typing `draw`. The engine notifies observers when the threshold is reached so the UI can prompt the player.
---
## Motivation
The 50-move rule prevents games from continuing indefinitely in positions where neither side can force checkmate. Under FIDE rules it is a player-claimed draw, not automatic.
---
## Section 1: Data Model — `GameHistory`
`GameHistory` gains one new field:
```scala
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0)
```
The default value `0` means all existing construction sites compile unchanged.
### Clock update rule
The clock resets to 0 on any pawn move or capture; otherwise it increments by 1.
The main `addMove` overload gains two optional boolean flags:
```scala
def addMove(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
```
The base `addMove(HistoryMove)` overload is made **private**; all public call sites route through the flagged overload above.
The no-argument overload `addMove(from, to)` used in tests and en passant history recording defaults both flags to `false` (clock increments) and remains for backward compatibility.
---
## Section 2: Clock Update in `GameController`
### `applyNormalMove`
Two flags are derived from already-available data before calling `history.addMove`:
```scala
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val wasCapture = captured.isDefined // computed earlier in the same method
val newHistory = history.addMove(from, to, castleOpt,
wasPawnMove = wasPawnMove, wasCapture = wasCapture)
```
En passant moves are pawn captures, so both flags are `true` — the clock resets.
### `completePromotion`
Pawn promotion is always a pawn move, so `wasPawnMove = true`:
```scala
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
```
---
## Section 3: Claim Mechanism and New Events
### New events (`Observer.scala`)
```scala
/** Fired after any move where the 50-move rule threshold is reached (halfMoveClock >= 100). */
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
```
### Claim handling in `GameEngine.processUserInput`
A new `"draw"` case is added before the move-parsing fallthrough:
```scala
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."
))
```
The game state resets to initial (same pattern as `Checkmate` and `Stalemate`). The command invoker is cleared so undo/redo history does not survive the draw claim.
### Availability notification in `GameEngine`
After any move that results in `Moved` or `MovedInCheck`, the engine checks whether the threshold has been crossed:
```scala
if newHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
```
This fires immediately after the `MoveExecutedEvent` (or `CheckDetectedEvent`) for that move.
---
## Section 4: FEN Integration
`FenExporter.gameStateToFen` and `FenParser.parseFen` already handle `halfMoveClock` at the `GameState` level — no changes to those files are needed.
The bridge between `GameHistory.halfMoveClock` and `GameState.halfMoveClock` is a caller responsibility:
**FEN export (writing):** When constructing a `GameState` for FEN export, pass `halfMoveClock = history.halfMoveClock`. Since `GameEngine` already exposes `def history: GameHistory`, this works automatically once the field is populated:
```scala
GameState(
piecePlacement = FenExporter.boardToFen(engine.board),
activeColor = engine.turn,
...,
halfMoveClock = engine.history.halfMoveClock,
...
)
```
**FEN import (reading):** When loading from a parsed `GameState`, initialise the engine with a `GameHistory` carrying the parsed clock:
```scala
val gs = FenParser.parseFen(fenString).get
new GameEngine(
initialBoard = FenParser.parseBoard(gs.piecePlacement).get,
initialHistory = GameHistory(halfMoveClock = gs.halfMoveClock),
initialTurn = gs.activeColor
)
```
A round-trip test is added to `FenExporterTest` / `FenParserTest` verifying that a non-zero clock survives export → import.
---
## Section 5: PGN Integration
`PgnExporter.exportGame` currently hardcodes `" *"` as the game termination marker. PGN standard requires the marker to match the `Result` header (`1-0`, `0-1`, `1/2-1/2`, or `*`).
### Change to `PgnExporter`
Replace the hardcoded `" *"` with the value from the `Result` header:
```scala
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
```
### Draw claim result
When `DrawClaimedEvent` is handled by a caller that exports PGN, it should pass:
```scala
Map("Result" -> "1/2-1/2", ...)
```
The move text will then end with `1/2-1/2`, which is correct per PGN standard for a drawn game.
A test is added to `PgnExporterTest` verifying that `exportGame` with `"Result" -> "1/2-1/2"` produces a move text ending in `1/2-1/2`.
---
## Section 6: Files Changed
| File | Change |
|------|--------|
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala` | Add `halfMoveClock` field; extend `addMove` with `wasPawnMove`/`wasCapture` flags; make base overload private |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Compute and pass flags in `applyNormalMove` and `completePromotion` |
| `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` | Add `FiftyMoveRuleAvailableEvent` and `DrawClaimedEvent` |
| `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` | Handle `"draw"` input; fire `FiftyMoveRuleAvailableEvent` after eligible moves |
| `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` | Derive termination marker from `Result` header instead of hardcoding `*` |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` | New test suite for clock update rules |
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Tests for clock values in `applyNormalMove` and `completePromotion` |
| `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala` | Tests for `"draw"` command and `FiftyMoveRuleAvailableEvent` |
| `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` | Test for `1/2-1/2` termination marker |
| `modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala` | Round-trip test: non-zero `halfMoveClock` survives FEN export → import |
`EnPassantCalculator`, `CastlingRightsCalculator`, `MoveValidator`, `GameRules`, and their tests are **not** touched.
---
## Section 7: Testing
### `GameHistoryTest`
- Clock starts at 0
- Clock increments on a normal (non-pawn, non-capture) move
- Clock resets to 0 on a pawn move (`wasPawnMove = true`)
- Clock resets to 0 on a capture (`wasCapture = true`)
- Clock resets to 0 when both flags are true (en passant)
- Clock carries correctly across multiple sequential moves
### `GameControllerTest`
- `applyNormalMove` with a non-pawn, non-capture produces `history.halfMoveClock = 1`
- `applyNormalMove` with a pawn move produces `history.halfMoveClock = 0`
- `applyNormalMove` with a capture produces `history.halfMoveClock = 0`
- `completePromotion` always produces `history.halfMoveClock = 0`
### `GameEngineTest`
- `processUserInput("draw")` fires `DrawClaimedEvent` and resets state when `halfMoveClock >= 100`
- `processUserInput("draw")` fires `InvalidMoveEvent` when `halfMoveClock < 100`
- A successful non-pawn, non-capture move that brings the clock to exactly 100 fires `FiftyMoveRuleAvailableEvent`
- A successful move that does not reach 100 does not fire `FiftyMoveRuleAvailableEvent`
### `PgnExporterTest`
- `exportGame` with `"Result" -> "1/2-1/2"` produces move text ending in `1/2-1/2`
- `exportGame` with no `Result` header still produces `*` as before (backward-compatible)
### `FenExporterTest`
- Round-trip: a `GameHistory` with `halfMoveClock = 42` exported to FEN and re-parsed yields `halfMoveClock = 42`
@@ -63,8 +63,7 @@ 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))
// Promotion is always a pawn move → clock resets val newHistory = history.addMove(from, to, None, Some(piece))
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
toMoveResult(newBoard, newHistory, captured, turn) toMoveResult(newBoard, newHistory, captured, turn)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -76,7 +75,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)
@@ -92,9 +91,7 @@ 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 wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) val newHistory = history.addMove(from, to, castleOpt)
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,19 +67,6 @@ 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,
@@ -122,8 +109,6 @@ class GameEngine(
invoker.execute(updatedCmd) invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, 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) => case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check // Move succeeded with check
@@ -132,8 +117,6 @@ class GameEngine(
updateGameState(newBoard, newHistory, newTurn) updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) => case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate // Move resulted in checkmate
@@ -11,36 +11,21 @@ case class HistoryMove(
promotionPiece: Option[PromotionPiece] = None promotionPiece: Option[PromotionPiece] = None
) )
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule. /** Complete game history: ordered list of moves. */
* 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, halfMoveClock + 1) GameHistory(moves :+ move)
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 =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1 addMove(HistoryMove(from, to, castleSide, promotionPiece))
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
object GameHistory: object GameHistory:
val empty: GameHistory = GameHistory() val empty: GameHistory = GameHistory()
@@ -22,8 +22,7 @@ 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"
val termination = headers.getOrElse("Result", "*") moveLines.mkString(" ") + " *"
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,20 +67,6 @@ 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,39 +488,3 @@ 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, FiftyMoveRuleAvailableEvent, DrawClaimedEvent} import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -302,47 +302,6 @@ 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,36 +69,3 @@ 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,22 +67,3 @@ 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,15 +100,3 @@ 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 *"