From ec1adef682f6f676e836ae9ff67861200c0305e4 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 22:42:20 +0200 Subject: [PATCH 01/12] docs: add 50-move rule design spec for NCS-11 --- .../specs/2026-03-31-50-move-rule-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-31-50-move-rule-design.md diff --git a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md new file mode 100644 index 0000000..5d972a2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md @@ -0,0 +1,169 @@ +# 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: 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/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` | + +`EnPassantCalculator`, `CastlingRightsCalculator`, `MoveValidator`, `GameRules`, and their tests are **not** touched. + +--- + +## Section 5: 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` -- 2.52.0 From bc5fc83c2e056193c4f2c39234021b86f3577042 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 22:46:29 +0200 Subject: [PATCH 02/12] docs: extend 50-move rule spec with FEN and PGN integration --- .../specs/2026-03-31-50-move-rule-design.md | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md index 5d972a2..86789fd 100644 --- a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md +++ b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md @@ -130,7 +130,67 @@ This fires immediately after the `MoveExecutedEvent` (or `CheckDetectedEvent`) f --- -## Section 4: Files Changed +## 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 | |------|--------| @@ -138,15 +198,18 @@ This fires immediately after the `MoveExecutedEvent` (or `CheckDetectedEvent`) f | `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 5: Testing +## Section 7: Testing ### `GameHistoryTest` - Clock starts at 0 @@ -167,3 +230,10 @@ This fires immediately after the `MoveExecutedEvent` (or `CheckDetectedEvent`) f - `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` -- 2.52.0 From 67ed00657bc83942a79f1af243f368509235c48d Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 22:54:18 +0200 Subject: [PATCH 03/12] docs: add 50-move rule implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-31-50-move-rule.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-50-move-rule.md diff --git a/docs/superpowers/plans/2026-03-31-50-move-rule.md b/docs/superpowers/plans/2026-03-31-50-move-rule.md new file mode 100644 index 0000000..b1e121f --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-50-move-rule.md @@ -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" +``` -- 2.52.0 From 1953b69173964b74c36fce9301a3195fc72dd2d2 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 23:02:14 +0200 Subject: [PATCH 04/12] feat: NCS-11 add halfMoveClock to GameHistory with addMove reset flags Co-Authored-By: Claude Sonnet 4.6 --- .../de/nowchess/chess/logic/GameHistory.scala | 31 ++++++++++++----- .../chess/logic/GameHistoryTest.scala | 33 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala index 80011fe..fe52d55 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala @@ -11,21 +11,36 @@ case class HistoryMove( promotionPiece: Option[PromotionPiece] = None ) -/** Complete game history: ordered list of moves. */ -case class GameHistory(moves: List[HistoryMove] = List.empty): +/** 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) - - def addMove(from: Square, to: Square): GameHistory = - addMove(HistoryMove(from, to, None)) + 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 + promotionPiece: Option[PromotionPiece] = None, + wasPawnMove: Boolean = false, + wasCapture: Boolean = false ): 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: val empty: GameHistory = GameHistory() diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala index 96e9af4..8a6069f 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala @@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers: newHistory.moves should have length 1 newHistory.moves.head.castleSide should be (None) 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 -- 2.52.0 From 396940da439a20e49b8cd3d6da8c6f1e7241a594 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 23:13:10 +0200 Subject: [PATCH 05/12] feat: NCS-11 derive PGN termination marker from Result header Co-Authored-By: Claude Sonnet 4.6 --- .../de/nowchess/chess/notation/PgnExporter.scala | 3 ++- .../de/nowchess/chess/notation/PgnExporterTest.scala | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala index a7f6449..38a3733 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala @@ -22,7 +22,8 @@ object PgnExporter: if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr" - moveLines.mkString(" ") + " *" + val termination = headers.getOrElse("Result", "*") + moveLines.mkString(" ") + s" $termination" if headerLines.isEmpty then moveText else if moveText.isEmpty then headerLines diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala index 6c39aa6..931ffc9 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -100,3 +100,15 @@ class PgnExporterTest extends AnyFunSuite with Matchers: pgn should include ("e2e4") 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 *" -- 2.52.0 From b2bfcf3953d1ac0fcf99e17c70b9d4f216ea0069 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 23:18:36 +0200 Subject: [PATCH 06/12] feat: NCS-11 propagate half-move clock flags through GameController Co-Authored-By: Claude Sonnet 4.6 --- .../chess/controller/GameController.scala | 7 ++-- .../chess/controller/GameControllerTest.scala | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 64e8d3a..4e3b47d 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -63,7 +63,8 @@ object GameController: case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Knight => PieceType.Knight 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) // --------------------------------------------------------------------------- @@ -91,7 +92,9 @@ object GameController: val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) (b.removed(capturedSq), board.pieceAt(capturedSq)) 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) private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult = diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index c379d4a..3ec0330 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -488,3 +488,39 @@ class GameControllerTest extends AnyFunSuite with Matchers: PromotionPiece.Knight, Color.White ) 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") -- 2.52.0 From 9ceb7cf746699c2a666c06df1b815aa298471e56 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 23:24:58 +0200 Subject: [PATCH 07/12] feat: NCS-11 implement 50-move rule draw claim and observer events Co-Authored-By: Claude Sonnet 4.6 --- .../de/nowchess/chess/engine/GameEngine.scala | 17 ++++++++ .../de/nowchess/chess/observer/Observer.scala | 14 ++++++ .../chess/engine/GameEngineTest.scala | 43 ++++++++++++++++++- .../chess/notation/FenExporterTest.scala | 18 ++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 8b6508f..38852c3 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -67,6 +67,19 @@ class GameEngine( case "redo" => 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 "" => val event = InvalidMoveEvent( currentBoard, @@ -109,6 +122,8 @@ class GameEngine( invoker.execute(updatedCmd) updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn) + if currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => // Move succeeded with check @@ -117,6 +132,8 @@ class GameEngine( updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn) notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) + if currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) case MoveResult.Checkmate(winner) => // Move resulted in checkmate diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 7d465c5..1dc2496 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -67,6 +67,20 @@ case class BoardResetEvent( turn: Color ) 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. */ trait Observer: def onGameEvent(event: GameEvent): Unit diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala index 755ddb8..073505d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala @@ -3,7 +3,7 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} 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.matchers.should.Matchers @@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.board shouldBe boardAfterSecondMove 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 private class MockObserver extends Observer: val events = mutable.ListBuffer[GameEvent]() diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala index b14ff69..8a25d2c 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala @@ -67,3 +67,21 @@ class FenExporterTest extends AnyFunSuite with Matchers: ) val fen = FenExporter.gameStateToFen(gameState) 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) + val parsed = FenParser.parseFen(fen).get + parsed.halfMoveClock shouldBe 42 -- 2.52.0 From a5d7743c775189a20da7cb9a828a5e24bbb8c770 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 31 Mar 2026 23:28:22 +0200 Subject: [PATCH 08/12] fix: replace .get with pattern match in FenExporterTest halfMoveClock round-trip --- .../scala/de/nowchess/chess/notation/FenExporterTest.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala index 8a25d2c..6734b15 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala @@ -83,5 +83,6 @@ class FenExporterTest extends AnyFunSuite with Matchers: status = GameStatus.InProgress ) val fen = FenExporter.gameStateToFen(gameState) - val parsed = FenParser.parseFen(fen).get - parsed.halfMoveClock shouldBe 42 + FenParser.parseFen(fen) match + case Some(gs) => gs.halfMoveClock shouldBe 42 + case None => fail("FEN parsing failed") -- 2.52.0 From e1bc4d1d2518644f54f271624b4c76459cc3f7b0 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 1 Apr 2026 10:00:26 +0200 Subject: [PATCH 09/12] docs(50-move-rule): 50 move docs Removed 50 move docs from this repo --- .../plans/2026-03-31-50-move-rule.md | 649 ------------------ .../specs/2026-03-31-50-move-rule-design.md | 239 ------- 2 files changed, 888 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-31-50-move-rule.md delete mode 100644 docs/superpowers/specs/2026-03-31-50-move-rule-design.md diff --git a/docs/superpowers/plans/2026-03-31-50-move-rule.md b/docs/superpowers/plans/2026-03-31-50-move-rule.md deleted file mode 100644 index b1e121f..0000000 --- a/docs/superpowers/plans/2026-03-31-50-move-rule.md +++ /dev/null @@ -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" -``` diff --git a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md b/docs/superpowers/specs/2026-03-31-50-move-rule-design.md deleted file mode 100644 index 86789fd..0000000 --- a/docs/superpowers/specs/2026-03-31-50-move-rule-design.md +++ /dev/null @@ -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` -- 2.52.0 From 466b05d3bedd3600f2fbdf6e37ff4c73985942f4 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 1 Apr 2026 10:02:50 +0200 Subject: [PATCH 10/12] docs(50-move-rule): 50 move docs Removed 50 move docs from this repo --- .claude/memory/MEMORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 81be3d8..e70ff87 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -1,7 +1,7 @@ # Memory Index ## Feedback -- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md) — Update structure memory files whenever source files are added, removed, or changed +- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md). — Update structure memory files whenever source files are added, removed, or changed ## Project Structure - [project_structure_root.md](project_structure_root.md) — Top-level layout, modules list, VERSIONS map, navigation rules (skip `build/`, `.gradle/`, `.idea/`) -- 2.52.0 From c8ea043cccf4931b49e7652e282e07d967596473 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 1 Apr 2026 10:03:05 +0200 Subject: [PATCH 11/12] docs(50-move-rule): 50 move docs Removed 50 move docs from this repo --- .claude/memory/MEMORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index e70ff87..81be3d8 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -1,7 +1,7 @@ # Memory Index ## Feedback -- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md). — Update structure memory files whenever source files are added, removed, or changed +- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md) — Update structure memory files whenever source files are added, removed, or changed ## Project Structure - [project_structure_root.md](project_structure_root.md) — Top-level layout, modules list, VERSIONS map, navigation rules (skip `build/`, `.gradle/`, `.idea/`) -- 2.52.0 From 0e622e4f54d351c00c4e62bfa43733fc2c9d8e09 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 1 Apr 2026 10:29:11 +0200 Subject: [PATCH 12/12] refactor(50-move-rule): extract handleParsedMove to reduce cognitive complexity in processUserInput Co-Authored-By: Claude Sonnet 4.6 --- .../de/nowchess/chess/engine/GameEngine.scala | 115 ++++++++---------- 1 file changed, 53 insertions(+), 62 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 38852c3..3d43fb8 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -90,74 +90,65 @@ class GameEngine( notifyObservers(event) case moveInput => - // Try to parse as a move Parser.parseMove(moveInput) match case None => - val event = InvalidMoveEvent( - currentBoard, - currentHistory, - currentTurn, + notifyObservers(InvalidMoveEvent( + currentBoard, currentHistory, currentTurn, s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." - ) - notifyObservers(event) - + )) case Some((from, to)) => - // Create a move command with current state snapshot - val cmd = MoveCommand( - from = from, - to = to, - previousBoard = Some(currentBoard), - previousHistory = Some(currentHistory), - previousTurn = Some(currentTurn) - ) - - // Execute the move through GameController - GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match - case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => - handleFailedMove(moveInput) - - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - // Move succeeded - store result and execute through invoker - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(from.toString, to.toString, captured, newTurn) - if currentHistory.halfMoveClock >= 100 then - notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) - - case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => - // Move succeeded with check - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(from.toString, to.toString, captured, newTurn) - notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) - if currentHistory.halfMoveClock >= 100 then - notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) - - case MoveResult.Checkmate(winner) => - // Move resulted in checkmate - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) - - case MoveResult.Stalemate => - // Move resulted in stalemate - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) - - case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => - pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) - notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) + handleParsedMove(from, to, moveInput) } + private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit = + val cmd = MoveCommand( + from = from, + to = to, + previousBoard = Some(currentBoard), + previousHistory = Some(currentHistory), + previousTurn = Some(currentTurn) + ) + GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match + case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => + handleFailedMove(moveInput) + + case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) + invoker.execute(updatedCmd) + updateGameState(newBoard, newHistory, newTurn) + emitMoveEvent(from.toString, to.toString, captured, newTurn) + if currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) + invoker.execute(updatedCmd) + updateGameState(newBoard, newHistory, newTurn) + emitMoveEvent(from.toString, to.toString, captured, newTurn) + notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) + if currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.Checkmate(winner) => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) + invoker.execute(updatedCmd) + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) + + case MoveResult.Stalemate => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) + invoker.execute(updatedCmd) + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => + pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) + notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) + /** Undo the last move. */ def undo(): Unit = synchronized { performUndo() -- 2.52.0