feat: NCS-11 implement 50-move rule draw claim and observer events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-31 23:24:58 +02:00
committed by Janis
parent b2bfcf3953
commit 9ceb7cf746
4 changed files with 91 additions and 1 deletions
@@ -67,6 +67,19 @@ class GameEngine(
case "redo" =>
performRedo()
case "draw" =>
if currentHistory.halfMoveClock >= 100 then
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear()
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
else
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" =>
val event = InvalidMoveEvent(
currentBoard,
@@ -109,6 +122,8 @@ class GameEngine(
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check
@@ -117,6 +132,8 @@ class GameEngine(
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate
@@ -67,6 +67,20 @@ case class BoardResetEvent(
turn: Color
) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers:
engine.board shouldBe boardAfterSecondMove
engine.turn shouldBe Color.White
// ──── 50-move rule ───────────────────────────────────────────────────
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[DrawClaimedEvent]
test("GameEngine: state resets to initial after draw claimed"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
engine.processUserInput("draw")
engine.board shouldBe Board.initial
engine.history shouldBe GameHistory.empty
engine.turn shouldBe Color.White
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3") // knight move on initial board
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
// Mock Observer for testing
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -67,3 +67,21 @@ class FenExporterTest extends AnyFunSuite with Matchers:
)
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"):
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.notation.FenParser
val history = GameHistory(halfMoveClock = 42)
val gameState = GameState(
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = history.halfMoveClock,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
val parsed = FenParser.parseFen(fen).get
parsed.halfMoveClock shouldBe 42