feat: add promotion handling to GameEngine with pending state and completePromotion()
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
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.logic.{GameHistory, GameRules, PositionStatus}
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||||
import de.nowchess.chess.observer.*
|
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
|
* All user interactions must go through this engine via Commands, and all state changes
|
||||||
* are communicated to observers via GameEvent notifications.
|
* are communicated to observers via GameEvent notifications.
|
||||||
*/
|
*/
|
||||||
class GameEngine extends Observable:
|
class GameEngine(
|
||||||
private var currentBoard: Board = Board.initial
|
initialBoard: Board = Board.initial,
|
||||||
private var currentHistory: GameHistory = GameHistory.empty
|
initialHistory: GameHistory = GameHistory.empty,
|
||||||
private var currentTurn: Color = Color.White
|
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()
|
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
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentBoard }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def history: GameHistory = synchronized { currentHistory }
|
||||||
@@ -115,6 +133,10 @@ class GameEngine extends Observable:
|
|||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
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. */
|
/** Undo the last move. */
|
||||||
@@ -127,6 +149,59 @@ class GameEngine extends Observable:
|
|||||||
performRedo()
|
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. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user