# 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" ```