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 94c95ee..5325f60 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 @@ -1,6 +1,6 @@ package de.nowchess.chess.controller -import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.* @@ -76,3 +76,28 @@ object GameController: case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Drawn => MoveResult.Stalemate + + /** Apply a previously detected promotion move with the chosen piece. + * Called after processMove returned PromotionRequired. + */ + def completePromotion( + board: Board, + history: GameHistory, + from: Square, + to: Square, + piece: PromotionPiece, + turn: Color + ): MoveResult = + val (boardAfterMove, captured) = board.withMove(from, to) + val promotedPieceType = piece match + case PromotionPiece.Queen => PieceType.Queen + case PromotionPiece.Rook => PieceType.Rook + 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)) + GameRules.gameStatus(newBoard, newHistory, turn.opposite) match + case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) + case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) + case PositionStatus.Mated => MoveResult.Checkmate(turn) + case PositionStatus.Drawn => MoveResult.Stalemate 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 23b9217..0de5794 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 @@ -2,6 +2,7 @@ package de.nowchess.chess.controller import de.nowchess.api.board.* import de.nowchess.api.game.CastlingRights +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{CastleSide, GameHistory} import de.nowchess.chess.notation.FenParser import org.scalatest.funsuite.AnyFunSuite @@ -327,3 +328,112 @@ class GameControllerTest extends AnyFunSuite with Matchers: case MoveResult.PromotionRequired(_, _, _, _, captured, _) => captured should be (Some(Piece(Color.Black, PieceType.Queen))) case _ => fail("Expected PromotionRequired") + + // ──── completePromotion ────────────────────────────────────────────────── + + test("completePromotion applies move and places queen"): + // Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals) + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result should matchPattern { case _: MoveResult.Moved => } + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None) + newHistory.moves should have length 1 + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) + case _ => fail("Expected Moved") + + test("completePromotion with rook underpromotion"): + // Black king on h1: not attacked by rook on e8 (different file and rank) + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Rook, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook)) + case _ => fail("Expected Moved with Rook") + + test("completePromotion with bishop underpromotion"): + val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Bishop, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop))) + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop)) + case _ => fail("Expected Moved with Bishop") + + test("completePromotion with knight underpromotion"): + val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Knight, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight))) + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) + case _ => fail("Expected Moved with Knight") + + test("completePromotion captures opponent piece"): + // Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1) + val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.D, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result match + case MoveResult.Moved(newBoard, _, captured, _) => + newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + captured should be (Some(Piece(Color.Black, PieceType.Queen))) + case _ => fail("Expected Moved with captured piece") + + test("completePromotion for black pawn to R1"): + val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R2), sq(File.E, Rank.R1), + PromotionPiece.Knight, Color.Black + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight))) + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) + case _ => fail("Expected Moved") + + test("completePromotion evaluates check after promotion"): + val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result should matchPattern { case _: MoveResult.MovedInCheck => } + + test("completePromotion full round-trip via processMove then completePromotion"): + // Black king on h1: not attacked by queen on e8 + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match + case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) => + val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn) + result should matchPattern { case _: MoveResult.Moved => } + result match + case MoveResult.Moved(finalBoard, finalHistory, _, _) => + finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) + case _ => fail("Expected Moved") + case _ => fail("Expected PromotionRequired")