feat: add completePromotion to GameController to finalize promotion moves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 13:40:46 +02:00
parent 7661aa08f4
commit 2a5755a905
2 changed files with 136 additions and 1 deletions
@@ -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
@@ -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")