diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 8b6508f..38852c3 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 7d465c5..1dc2496 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -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 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala index 755ddb8..073505d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala @@ -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]() diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala index b14ff69..8a25d2c 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala @@ -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