feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #12
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
This commit was merged in pull request #12.
This commit is contained in:
2026-03-31 22:18:14 +02:00
committed by Leon Hermann
parent 85cbf95c18
commit 13bfc16cfe
21 changed files with 973 additions and 66 deletions
@@ -1,6 +1,7 @@
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.*
// ---------------------------------------------------------------------------
@@ -14,6 +15,14 @@ object MoveResult:
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class PromotionRequired(
from: Square,
to: Square,
boardBefore: Board,
historyBefore: GameHistory,
captured: Option[Piece],
turn: Color
) extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
@@ -30,37 +39,64 @@ object GameController:
*/
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case "quit" | "q" => MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val newHistory = history.addMove(from, to, castleOpt)
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
case None => MoveResult.InvalidFormat(trimmed)
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
/** 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))
toMoveResult(newBoard, newHistory, captured, turn)
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
board.pieceAt(from) match
case None => MoveResult.NoPiece
case Some(piece) if piece.color != turn => MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove
else if MoveValidator.isPromotionMove(board, from, to) then
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
else applyNormalMove(board, history, turn, from, to)
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val newHistory = history.addMove(from, to, castleOpt)
toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
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
@@ -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,31 @@ 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,
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
GameController.completePromotion
) 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 +135,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 +151,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)
)
completePromotionFn(
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
@@ -1,12 +1,14 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
import de.nowchess.api.move.PromotionPiece
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[CastleSide]
castleSide: Option[CastleSide],
promotionPiece: Option[PromotionPiece] = None
)
/** Complete game history: ordered list of moves. */
@@ -17,8 +19,13 @@ case class GameHistory(moves: List[HistoryMove] = List.empty):
def addMove(from: Square, to: Square): GameHistory =
addMove(HistoryMove(from, to, None))
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
addMove(HistoryMove(from, to, castleSide))
def addMove(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None
): GameHistory =
addMove(HistoryMove(from, to, castleSide, promotionPiece))
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -173,3 +173,11 @@ object MoveValidator:
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from) match
case Some(Piece(_, PieceType.Pawn)) =>
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
(from.rank == Rank.R2 && to.rank == Rank.R1)
case _ => false
@@ -1,6 +1,7 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
object PgnExporter:
@@ -32,4 +33,11 @@ object PgnExporter:
move.castleSide match
case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O"
case None => s"${move.from}${move.to}"
case None =>
val base = s"${move.from}${move.to}"
move.promotionPiece match
case Some(PromotionPiece.Queen) => s"$base=Q"
case Some(PromotionPiece.Rook) => s"$base=R"
case Some(PromotionPiece.Bishop) => s"$base=B"
case Some(PromotionPiece.Knight) => s"$base=N"
case None => base
@@ -1,6 +1,7 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */
@@ -41,16 +42,30 @@ object PgnParser:
if isMoveNumberOrResult(token) then state
else
parseAlgebraicMove(token, board, history, color) match
case None => state // unrecognised token — skip silently
case None => state // unrecognised token — skip silently
case Some(move) =>
val newBoard = move.castleSide match
case Some(side) => board.withCastle(color, side)
case None => board.withMove(move.from, move.to)._1
val newBoard = applyMoveToBoard(board, move, color)
val newHistory = history.addMove(move)
(newBoard, newHistory, color.opposite, acc :+ move)
moves
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
move.castleSide match
case Some(side) => board.withCastle(color, side)
case None =>
val (boardAfterMove, _) = board.withMove(move.from, move.to)
move.promotionPiece match
case Some(pp) =>
val pieceType = pp match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
boardAfterMove.updated(move.to, Piece(color, pieceType))
case None => boardAfterMove
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") ||
@@ -128,16 +143,26 @@ object PgnParser:
if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint))
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))
val promotion = extractPromotion(notation)
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
hint.foldLeft(true): (ok, c) =>
ok && (
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true
)
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true)
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
val promotionPattern = """=([A-Z])""".r
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
m.group(1) match
case "Q" => Some(PromotionPiece.Queen)
case "R" => Some(PromotionPiece.Rook)
case "B" => Some(PromotionPiece.Bishop)
case "N" => Some(PromotionPiece.Knight)
case _ => None
}
/** Convert a piece-letter character to a PieceType. */
private def charToPieceType(c: Char): Option[PieceType] =
@@ -1,6 +1,6 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.board.{Board, Color, Square}
import de.nowchess.chess.logic.GameHistory
/** Base trait for all game state events.
@@ -51,6 +51,15 @@ case class InvalidMoveEvent(
reason: String
) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent(
board: Board,
history: GameHistory,
turn: Color,
from: Square,
to: Square
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
board: Board,