Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 andFiftyMoveRuleAvailableEventnotification
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"