From 7a06febde82695e30bb50ad739d59cf4b64df9f5 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 31 Mar 2026 14:06:14 +0200 Subject: [PATCH] feat: add promotion handling to GameEngine with pending state and completePromotion() Co-Authored-By: Claude Haiku 4.5 --- .../de/nowchess/chess/engine/GameEngine.scala | 83 ++++++++++++++++- .../engine/GameEnginePromotionTest.scala | 91 +++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala 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 59aafe4..029401d 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 @@ -1,6 +1,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, Piece, Square} +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus} import de.nowchess.chess.controller.{GameController, Parser, MoveResult} import de.nowchess.chess.observer.* @@ -11,12 +12,29 @@ import de.nowchess.chess.command.{CommandInvoker, MoveCommand} * All user interactions must go through this engine via Commands, and all state changes * are communicated to observers via GameEvent notifications. */ -class GameEngine extends Observable: - private var currentBoard: Board = Board.initial - private var currentHistory: GameHistory = GameHistory.empty - private var currentTurn: Color = Color.White +class GameEngine( + initialBoard: Board = Board.initial, + initialHistory: GameHistory = GameHistory.empty, + initialTurn: Color = Color.White +) extends Observable: + private var currentBoard: Board = initialBoard + private var currentHistory: GameHistory = initialHistory + private var currentTurn: Color = initialTurn private val invoker = new CommandInvoker() + /** Inner class for tracking pending promotion state */ + private case class PendingPromotion( + from: Square, to: Square, + boardBefore: Board, historyBefore: GameHistory, + turn: Color + ) + + /** Current pending promotion, if any */ + private var pendingPromotion: Option[PendingPromotion] = None + + /** True if a pawn promotion move is pending and needs a piece choice. */ + def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } + // Synchronized accessors for current state def board: Board = synchronized { currentBoard } def history: GameHistory = synchronized { currentHistory } @@ -115,6 +133,10 @@ class GameEngine extends Observable: 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. */ @@ -127,6 +149,59 @@ class GameEngine extends Observable: performRedo() } + /** Apply a player's promotion piece choice. + * Must only be called when isPendingPromotion is true. + */ + def completePromotion(piece: PromotionPiece): Unit = synchronized { + pendingPromotion match + case None => + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending.")) + case Some(pending) => + pendingPromotion = None + val cmd = MoveCommand( + from = pending.from, + to = pending.to, + previousBoard = Some(pending.boardBefore), + previousHistory = Some(pending.historyBefore), + previousTurn = Some(pending.turn) + ) + GameController.completePromotion( + pending.boardBefore, pending.historyBefore, + pending.from, pending.to, piece, pending.turn + ) match + 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(pending.from.toString, pending.to.toString, captured, newTurn) + + 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(pending.from.toString, pending.to.toString, captured, newTurn) + notifyObservers(CheckDetectedEvent(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 _ => + notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion.")) + } + /** Reset the board to initial position. */ def reset(): Unit = synchronized { currentBoard = Board.initial diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala new file mode 100644 index 0000000..153918f --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -0,0 +1,91 @@ +package de.nowchess.chess.engine + +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.move.PromotionPiece +import de.nowchess.chess.logic.GameHistory +import de.nowchess.chess.notation.FenParser +import de.nowchess.chess.observer.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameEnginePromotionTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + + private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = + val events = collection.mutable.ListBuffer[GameEvent]() + engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e }) + events + + test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + + events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true) + events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7)) + } + + test("isPendingPromotion is true after PromotionRequired input") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + captureEvents(engine) + + engine.processUserInput("e7e8") + + engine.isPendingPromotion should be (true) + } + + test("isPendingPromotion is false before any promotion input") { + val engine = new GameEngine() + engine.isPendingPromotion should be (false) + } + + test("completePromotion fires MoveExecutedEvent with promoted piece") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + engine.completePromotion(PromotionPiece.Queen) + + engine.isPendingPromotion should be (false) + engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None) + engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + } + + test("completePromotion with rook underpromotion") { + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + captureEvents(engine) + + engine.processUserInput("e7e8") + engine.completePromotion(PromotionPiece.Rook) + + engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) + } + + test("completePromotion with no pending promotion fires InvalidMoveEvent") { + val engine = new GameEngine() + val events = captureEvents(engine) + + engine.completePromotion(PromotionPiece.Queen) + + events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) + engine.isPendingPromotion should be (false) + } + + test("completePromotion fires CheckDetectedEvent when promotion gives check") { + val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + engine.completePromotion(PromotionPiece.Queen) + + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true) + }