Files
NowChessSystems/docs/superpowers/plans/2026-03-31-50-move-rule.md
T
LQ63 67ed00657b docs: add 50-move rule implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:20:44 +02:00

27 KiB

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):

  // ──── 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
./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:

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
./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
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):

  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
./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:

moveLines.mkString(" ") + " *"

Replace it with:

val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"

The full updated exportGame method:

  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
./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
./gradlew :modules:core:test 2>&1 | tail -10

Expected: BUILD SUCCESSFUL.

  • Step 6: Commit
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):

  // ──── 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
./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:

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
./gradlew :modules:core:test 2>&1 | tail -10

Expected: BUILD SUCCESSFUL.

  • Step 5: Commit
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:

  // ──── 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:

import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}

Add the following to FenExporterTest.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
./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:

/** 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" =>):

      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:

              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:

              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:

              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:

              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:

import de.nowchess.chess.observer.*

(Already a wildcard import — no change needed there.)

  • Step 5: Run tests
./gradlew :modules:core:test 2>&1 | tail -10

Expected: BUILD SUCCESSFUL, all tests pass.

  • Step 6: Commit
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"