Compare commits
8 Commits
ws-0.3.0
...
5e22924053
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e22924053 | |||
| 97c3ff5e67 | |||
| b8f5c8eb77 | |||
| 5a4fcb1b55 | |||
| 83c7d3a46b | |||
| 7855f0c136 | |||
| f61ffce22a | |||
| 381c3f06a1 |
@@ -0,0 +1,649 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# 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,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)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -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,
|
||||||
@@ -109,6 +122,8 @@ 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
|
||||||
@@ -117,6 +132,8 @@ 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,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
|
||||||
|
|||||||
@@ -464,3 +464,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 *"
|
||||||
|
|||||||
Reference in New Issue
Block a user