docs: add 50-move rule implementation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-31 22:54:18 +02:00
committed by Janis
parent bc5fc83c2e
commit 67ed00657b
@@ -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"
```