diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 64e8d3a..4e3b47d 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -63,7 +63,8 @@ object GameController: case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Knight => PieceType.Knight val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType)) - val newHistory = history.addMove(from, to, None, Some(piece)) + // Promotion is always a pawn move → clock resets + val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true) toMoveResult(newBoard, newHistory, captured, turn) // --------------------------------------------------------------------------- @@ -91,7 +92,9 @@ object GameController: val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) (b.removed(capturedSq), board.pieceAt(capturedSq)) else (b, cap) - val newHistory = history.addMove(from, to, castleOpt) + 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 = 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..3d43fb8 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, @@ -77,70 +90,65 @@ class GameEngine( notifyObservers(event) case moveInput => - // Try to parse as a move Parser.parseMove(moveInput) match case None => - val event = InvalidMoveEvent( - currentBoard, - currentHistory, - currentTurn, + notifyObservers(InvalidMoveEvent( + currentBoard, currentHistory, currentTurn, s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." - ) - notifyObservers(event) - + )) case Some((from, to)) => - // Create a move command with current state snapshot - val cmd = MoveCommand( - from = from, - to = to, - previousBoard = Some(currentBoard), - previousHistory = Some(currentHistory), - previousTurn = Some(currentTurn) - ) - - // Execute the move through GameController - GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match - case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => - handleFailedMove(moveInput) - - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - // Move succeeded - store result and execute through invoker - 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) - - case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => - // Move succeeded with check - 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)) - - case MoveResult.Checkmate(winner) => - // Move resulted in checkmate - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) - - case MoveResult.Stalemate => - // Move resulted in stalemate - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) - - case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => - pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) - notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) + handleParsedMove(from, to, moveInput) } + private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit = + val cmd = MoveCommand( + from = from, + to = to, + previousBoard = Some(currentBoard), + previousHistory = Some(currentHistory), + previousTurn = Some(currentTurn) + ) + GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match + case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => + handleFailedMove(moveInput) + + 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 currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) + + 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 currentHistory.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.Checkmate(winner) => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) + invoker.execute(updatedCmd) + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) + + case MoveResult.Stalemate => + val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) + invoker.execute(updatedCmd) + currentBoard = Board.initial + currentHistory = GameHistory.empty + currentTurn = Color.White + notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => + pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) + notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) + /** Undo the last move. */ def undo(): Unit = synchronized { performUndo() diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala index 80011fe..fe52d55 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala @@ -11,21 +11,36 @@ case class HistoryMove( promotionPiece: Option[PromotionPiece] = None ) -/** Complete game history: ordered list of moves. */ -case class GameHistory(moves: List[HistoryMove] = List.empty): +/** 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) - - def addMove(from: Square, to: Square): GameHistory = - addMove(HistoryMove(from, to, None)) + 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 + promotionPiece: Option[PromotionPiece] = None, + wasPawnMove: Boolean = false, + wasCapture: Boolean = false ): GameHistory = - addMove(HistoryMove(from, to, castleSide, promotionPiece)) + val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1 + GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock) object GameHistory: val empty: GameHistory = GameHistory() diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala index a7f6449..38a3733 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala @@ -22,7 +22,8 @@ object PgnExporter: if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr" - moveLines.mkString(" ") + " *" + val termination = headers.getOrElse("Result", "*") + moveLines.mkString(" ") + s" $termination" if headerLines.isEmpty then moveText else if moveText.isEmpty then headerLines 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/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index c379d4a..3ec0330 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -488,3 +488,39 @@ class GameControllerTest extends AnyFunSuite with Matchers: PromotionPiece.Knight, Color.White ) result should be (MoveResult.Stalemate) + + // ──── 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") 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/logic/GameHistoryTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala index 96e9af4..8a6069f 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala @@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers: newHistory.moves should have length 1 newHistory.moves.head.castleSide should be (None) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) + + // ──── 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 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..6734b15 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,22 @@ 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) + FenParser.parseFen(fen) match + case Some(gs) => gs.halfMoveClock shouldBe 42 + case None => fail("FEN parsing failed") diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala index 6c39aa6..931ffc9 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -100,3 +100,15 @@ class PgnExporterTest extends AnyFunSuite with Matchers: pgn should include ("e2e4") pgn should not include ("=") } + + 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 *"