Files
NowChessSystems/docs/superpowers/plans/2026-03-29-pawn-promotion.md
T

51 KiB

Pawn Promotion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement pawn promotion as a two-step interaction: detect pawn reaching back rank, prompt player to choose a promotion piece, apply the choice through GameEngine/TerminalUI, and record it in PGN export/import.

Architecture: Pure GameController.processMove() returns PromotionRequired when a pawn reaches the back rank. GameEngine stores pending promotion state and exposes completePromotion(piece). TerminalUI listens for PromotionRequiredEvent, prompts the user, then calls engine.completePromotion() on the next input. PGN export/import preserves =Q/=R/=B/=N notation.

Tech Stack: Scala 3, core module (no Quarkus), ui module, scoverage for coverage, FEN for test board setup.


Architecture Map

Layer File Change
Logic MoveValidator.scala Add isPromotionMove()
Controller GameController.scala Add PromotionRequired to MoveResult; update processMove(); add completePromotion()
Observer Observer.scala Add PromotionRequiredEvent
Engine GameEngine.scala Add optional init params, pending promotion state, isPendingPromotion, completePromotion(), handle PromotionRequired in processUserInput()
UI TerminalUI.scala (modules/ui) Handle PromotionRequiredEvent, route promotion input to engine.completePromotion()
PGN Export PgnExporter.scala Emit =Q/=R/=B/=N suffix in moveToAlgebraic()
PGN Import PgnParser.scala Preserve promotion piece in HistoryMove; apply promoted piece to board state

Task 1 is already complete: HistoryMove.promotionPiece: Option[PromotionPiece] was added in commit 0800c3a and tests already exist in GameHistoryTest.scala. Start from Task 2.


Task 2: Add isPromotionMove() to MoveValidator

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala

  • Modify: modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala

  • Step 1: Write failing tests

Open modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala and add at the end of the class:

test("White pawn reaching R8 is a promotion move") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  MoveValidator.isPromotionMove(board, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
}

test("Black pawn reaching R1 is a promotion move") {
  val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
  val board = FenParser.parse(fen).board
  MoveValidator.isPromotionMove(board, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
}

test("Pawn capturing to back rank is a promotion move") {
  val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  MoveValidator.isPromotionMove(board, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
}

test("Pawn not reaching back rank is not a promotion move") {
  val fen = "8/8/8/4P3/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  MoveValidator.isPromotionMove(board, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
}

test("Non-pawn piece is never a promotion move") {
  val fen = "8/8/8/4Q3/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  MoveValidator.isPromotionMove(board, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20

Expected: FAIL — isPromotionMove not found.

  • Step 3: Implement isPromotionMove()

In modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala, add after the existing isLegal overload (around line 174):

/** 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
  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -10

Expected: All MoveValidatorTest tests pass.

  • Step 5: Commit
git add modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala \
        modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala
git commit -m "feat: add isPromotionMove detection to MoveValidator"

Task 3: Add PromotionRequired to MoveResult and update processMove()

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala

  • Modify: modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala

  • Step 1: Write failing tests

Open modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala and add:

test("processMove detects white pawn reaching R8 and returns PromotionRequired") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
  result should matchPattern { case _: MoveResult.PromotionRequired => }
  result match
    case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
      from should be (Square(File.E, Rank.R7))
      to   should be (Square(File.E, Rank.R8))
      turn should be (Color.White)
    case _ => fail("Expected PromotionRequired")
}

test("processMove detects black pawn reaching R1 and returns PromotionRequired") {
  val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
  result should matchPattern { case _: MoveResult.PromotionRequired => }
}

test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece") {
  val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
  result should matchPattern { case _: MoveResult.PromotionRequired => }
  result match
    case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
      captured should be (Some(Piece(Color.Black, PieceType.Queen)))
    case _ => fail("Expected PromotionRequired")
}

You will need to add these imports to GameControllerTest.scala if not already present:

import de.nowchess.chess.notation.FenParser
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20

Expected: FAIL — PromotionRequired not a member of MoveResult.

  • Step 3: Add PromotionRequired to MoveResult ADT

In modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala, add PromotionRequired to the MoveResult object after IllegalMove and before Moved. Also add the PromotionPiece import at the top of the file:

Add import after the existing imports:

import de.nowchess.api.move.PromotionPiece

Add to MoveResult object:

case class PromotionRequired(
  from: Square,
  to: Square,
  boardBefore: Board,
  historyBefore: GameHistory,
  captured: Option[Piece],
  turn: Color
) extends MoveResult

The full updated MoveResult object should be:

sealed trait MoveResult
object MoveResult:
  case object Quit                                                                                         extends MoveResult
  case class  InvalidFormat(raw: String)                                                                   extends 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
  case object Stalemate                                                                                    extends MoveResult
  • Step 4: Update processMove() to detect promotion before executing the move

In GameController.processMove(), add the promotion check after the isLegal check and before the castle/en-passant logic. Replace the else clause from if !MoveValidator.isLegal(...) with:

              if !MoveValidator.isLegal(board, history, from, to) then
                MoveResult.IllegalMove
              else if MoveValidator.isPromotionMove(board, from, to) then
                val captured = board.pieceAt(to)
                MoveResult.PromotionRequired(from, to, board, history, captured, turn)
              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
  • Step 5: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10

Expected: All GameControllerTest tests pass.

  • Step 6: Run full core tests to check no regressions
./gradlew :modules:core:test 2>&1 | tail -15

Expected: All tests pass.

  • Step 7: Commit
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \
        modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "feat: add PromotionRequired to MoveResult and detect promotion in processMove"

Task 4: Add completePromotion() to GameController

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala

  • Modify: modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala

  • Step 1: Write failing tests

Add to modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala:

test("completePromotion applies move and places queen") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Queen, Color.White
  )
  result should matchPattern { case _: MoveResult.Moved => }
  result match
    case MoveResult.Moved(newBoard, newHistory, _, _) =>
      newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
      newBoard.pieceAt(Square(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") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Rook, Color.White
  )
  result match
    case MoveResult.Moved(newBoard, newHistory, _, _) =>
      newBoard.pieceAt(Square(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 fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Bishop, Color.White
  )
  result match
    case MoveResult.Moved(newBoard, newHistory, _, _) =>
      newBoard.pieceAt(Square(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 fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Knight, Color.White
  )
  result match
    case MoveResult.Moved(newBoard, newHistory, _, _) =>
      newBoard.pieceAt(Square(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") {
  val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.D, Rank.R8),
    PromotionPiece.Queen, Color.White
  )
  result match
    case MoveResult.Moved(newBoard, _, captured, _) =>
      newBoard.pieceAt(Square(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 fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R2), Square(File.E, Rank.R1),
    PromotionPiece.Knight, Color.Black
  )
  result match
    case MoveResult.Moved(newBoard, newHistory, _, _) =>
      newBoard.pieceAt(Square(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 fen = "3k4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val result = GameController.completePromotion(
    board, GameHistory.empty,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Queen, Color.White
  )
  result should matchPattern { case _: MoveResult.MovedInCheck => }
}

test("completePromotion full round-trip via processMove then completePromotion") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  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(Square(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")
}

You will need to add this import to GameControllerTest.scala:

import de.nowchess.api.move.PromotionPiece
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20

Expected: FAIL — completePromotion not found.

  • Step 3: Implement completePromotion()

Add to GameController object in modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala:

  /** 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

Also add PieceType and Piece to the imports if not already there. The file already imports de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} so Piece is covered; add PieceType:

import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10

Expected: All GameControllerTest tests pass.

  • Step 5: Run full core tests
./gradlew :modules:core:test 2>&1 | tail -10

Expected: All tests pass.

  • Step 6: Commit
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \
        modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "feat: add completePromotion to GameController to finalize promotion moves"

Task 5: Add PromotionRequiredEvent to Observer

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala

  • Step 1: Add PromotionRequiredEvent case class

In modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala, add after InvalidMoveEvent and before BoardResetEvent. Also add the import for Square (it's already in de.nowchess.api.board.* but verify the import covers it):

/** 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

The existing import import de.nowchess.api.board.{Board, Color} must be expanded to include Square:

import de.nowchess.api.board.{Board, Color, Square}
  • Step 2: Run full core test suite to ensure nothing broke
./gradlew :modules:core:test 2>&1 | tail -10

Expected: All tests pass (adding a new case class with no logic can't break anything).

  • Step 3: Commit
git add modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
git commit -m "feat: add PromotionRequiredEvent to Observer for pawn promotion notification"

Task 6: Update GameEngine to handle promotion

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala

  • Create: modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala

  • Step 1: Write failing tests

Create modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala:

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.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
    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.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
    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.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
    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.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
    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.parse("3k4/4P3/8/8/8/8/8/8 w - - 0 1").board
    val engine = new GameEngine(initialBoard = promotionBoard)
    val events = captureEvents(engine)

    engine.processUserInput("e7e8")
    engine.completePromotion(PromotionPiece.Queen)

    events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
  }
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -20

Expected: FAIL — GameEngine has no initialBoard param, isPendingPromotion, or completePromotion.

  • Step 3: Update GameEngine class signature and add promotion state

In modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala:

  1. Change the class definition to accept optional initial state:
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()
  1. Add PromotionPiece import at the top (with existing imports):
import de.nowchess.api.move.PromotionPiece
  1. Add after the commandHistory def:
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
  1. Add private state and inner class before // Synchronized accessors:
private case class PendingPromotion(
  from: Square, to: Square,
  boardBefore: Board, historyBefore: GameHistory,
  turn: Color
)
private var pendingPromotion: Option[PendingPromotion] = None
  • Step 4: Handle PromotionRequired in processUserInput()

In processUserInput(), add a case for PromotionRequired in the GameController.processMove(...) match. After the MoveResult.Stalemate case and before the closing brace, add:

              case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) =>
                pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn))
                notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to))

Also update the case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => line to include MoveResult.PromotionRequired in the failure list is NOT needed — PromotionRequired is a success path. However the match may become non-exhaustive. Ensure the handleFailedMove branch still only covers the failure cases (it's @unchecked so it won't warn, but confirm the match above is exhaustive).

The updated match in processUserInput should be:

            GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
              case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
                handleFailedMove(moveInput)

              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(from.toString, 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(from.toString, 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 MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) =>
                pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn))
                notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to))
  • Step 5: Add completePromotion() method

Add after the redo() method in GameEngine.scala:

  /** 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."))
  }
  • Step 6: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -10

Expected: All GameEnginePromotionTest tests pass.

  • Step 7: Run full core tests
./gradlew :modules:core:test 2>&1 | tail -10

Expected: All tests pass.

  • Step 8: Commit
git add modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala \
        modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala
git commit -m "feat: add promotion handling to GameEngine with pending state and completePromotion()"

Task 7: Update TerminalUI to handle promotion I/O

Files:

  • Modify: modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala

  • Modify: modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala

  • Step 1: Write failing tests

Add to modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala:

test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
  val out = new ByteArrayOutputStream()
  val engine = new GameEngine()
  val ui = new TerminalUI(engine)

  Console.withOut(out) {
    ui.onGameEvent(PromotionRequiredEvent(
      Board(Map.empty), GameHistory(), Color.White,
      Square(File.E, Rank.R7), Square(File.E, Rank.R8)
    ))
  }

  out.toString should include("Promote to")
}

test("TerminalUI routes promotion choice to engine.completePromotion") {
  import de.nowchess.api.move.PromotionPiece
  import de.nowchess.api.board.{File, Rank, Square}

  var capturedPiece: Option[PromotionPiece] = None

  val engine = new GameEngine() {
    override def processUserInput(rawInput: String): Unit =
      if rawInput.trim == "e7e8" then
        notifyObservers(PromotionRequiredEvent(
          Board(Map.empty), GameHistory.empty, Color.White,
          Square(File.E, Rank.R7), Square(File.E, Rank.R8)
        ))
    override def completePromotion(piece: PromotionPiece): Unit =
      capturedPiece = Some(piece)
      notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
  }

  val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
  val out = new ByteArrayOutputStream()
  val ui = new TerminalUI(engine)

  Console.withIn(in) {
    Console.withOut(out) {
      ui.start()
    }
  }

  capturedPiece should be (Some(PromotionPiece.Queen))
  out.toString should include("Promote to")
}

test("TerminalUI re-prompts on invalid promotion choice") {
  import de.nowchess.api.move.PromotionPiece
  import de.nowchess.api.board.{File, Rank, Square}

  var capturedPiece: Option[PromotionPiece] = None

  val engine = new GameEngine() {
    override def processUserInput(rawInput: String): Unit =
      if rawInput.trim == "e7e8" then
        notifyObservers(PromotionRequiredEvent(
          Board(Map.empty), GameHistory.empty, Color.White,
          Square(File.E, Rank.R7), Square(File.E, Rank.R8)
        ))
    override def completePromotion(piece: PromotionPiece): Unit =
      capturedPiece = Some(piece)
      notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
  }

  // "x" is invalid, then "r" for rook
  val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
  val out = new ByteArrayOutputStream()
  val ui = new TerminalUI(engine)

  Console.withIn(in) {
    Console.withOut(out) {
      ui.start()
    }
  }

  capturedPiece should be (Some(PromotionPiece.Rook))
  out.toString should include("Invalid")
}

You will need to add to the imports at the top of TerminalUITest.scala:

import de.nowchess.chess.observer.PromotionRequiredEvent
import de.nowchess.api.board.{File, Rank, Square}
  • Step 2: Run test to verify it fails
./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -20

Expected: FAIL — PromotionRequiredEvent not handled, no promotion routing in game loop.

  • Step 3: Update TerminalUI

Replace the entire content of modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala with:

package de.nowchess.ui.terminal

import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.view.Renderer

/** Terminal UI that implements Observer pattern.
 *  Subscribes to GameEngine and receives state change events.
 *  Handles all I/O and user interaction in the terminal.
 */
class TerminalUI(engine: GameEngine) extends Observer:
  private var running = true
  private var awaitingPromotion = false

  /** Called by GameEngine whenever a game event occurs. */
  override def onGameEvent(event: GameEvent): Unit =
    event match
      case e: MoveExecutedEvent =>
        println()
        print(Renderer.render(e.board))
        e.capturedPiece.foreach: cap =>
          println(s"Captured: $cap on ${e.toSquare}")
        printPrompt(e.turn)

      case e: CheckDetectedEvent =>
        println(s"${e.turn.label} is in check!")

      case e: CheckmateEvent =>
        println(s"Checkmate! ${e.winner.label} wins.")
        println()
        print(Renderer.render(e.board))

      case e: StalemateEvent =>
        println("Stalemate! The game is a draw.")
        println()
        print(Renderer.render(e.board))

      case e: InvalidMoveEvent =>
        println(s"⚠️  ${e.reason}")

      case e: BoardResetEvent =>
        println("Board has been reset to initial position.")
        println()
        print(Renderer.render(e.board))
        printPrompt(e.turn)

      case _: PromotionRequiredEvent =>
        println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
        awaitingPromotion = true

  /** Start the terminal UI game loop. */
  def start(): Unit =
    // Register as observer
    engine.subscribe(this)

    // Show initial board
    println()
    print(Renderer.render(engine.board))
    printPrompt(engine.turn)

    // Game loop
    while running do
      val input = Option(StdIn.readLine()).getOrElse("quit").trim
      if awaitingPromotion then
        input.toLowerCase match
          case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
          case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
          case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
          case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
          case _ =>
            println("Invalid choice. Enter q, r, b, or n.")
            println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
      else
        input.toLowerCase match
          case "quit" | "q" =>
            running = false
            println("Game over. Goodbye!")
          case "" =>
            printPrompt(engine.turn)
          case _ =>
            engine.processUserInput(input)

    // Unsubscribe when done
    engine.unsubscribe(this)

  private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
    val undoHint = if engine.canUndo then " [undo]" else ""
    val redoHint = if engine.canRedo then " [redo]" else ""
    print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
  • Step 4: Run test to verify it passes
./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -10

Expected: All TerminalUITest tests pass.

  • Step 5: Run full build to check both modules
./gradlew :modules:core:test :modules:ui:test 2>&1 | tail -15

Expected: All tests pass.

  • Step 6: Commit
git add modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala \
        modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
git commit -m "feat: update TerminalUI to handle pawn promotion I/O via awaitingPromotion flag"

Task 8: PGN export support for promotions

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala

  • Modify: modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala

  • Step 1: Write failing tests

Add to modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala:

test("exportGame encodes promotion to Queen as =Q suffix") {
  val history = GameHistory()
    .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
  val pgn = PgnExporter.exportGame(Map.empty, history)
  pgn should include ("e7e8=Q")
}

test("exportGame encodes promotion to Rook as =R suffix") {
  val history = GameHistory()
    .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
  val pgn = PgnExporter.exportGame(Map.empty, history)
  pgn should include ("e7e8=R")
}

test("exportGame encodes promotion to Bishop as =B suffix") {
  val history = GameHistory()
    .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
  val pgn = PgnExporter.exportGame(Map.empty, history)
  pgn should include ("e7e8=B")
}

test("exportGame encodes promotion to Knight as =N suffix") {
  val history = GameHistory()
    .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
  val pgn = PgnExporter.exportGame(Map.empty, history)
  pgn should include ("e7e8=N")
}

test("exportGame does not add suffix for normal moves") {
  val history = GameHistory()
    .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
  val pgn = PgnExporter.exportGame(Map.empty, history)
  pgn should include ("e2e4")
  pgn should not include ("=")
}

Add the import at the top of PgnExporterTest.scala:

import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.board.{Rank}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -20

Expected: FAIL — normal moves export fine, but promotion moves don't emit =Q etc.

  • Step 3: Update moveToAlgebraic() in PgnExporter

In modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala:

  1. Add import after existing imports:
import de.nowchess.api.move.PromotionPiece
  1. Replace the existing moveToAlgebraic method:
  private def moveToAlgebraic(move: HistoryMove): String =
    move.castleSide match
      case Some(CastleSide.Kingside)  => "O-O"
      case Some(CastleSide.Queenside) => "O-O-O"
      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
  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -10

Expected: All PgnExporterTest tests pass.

  • Step 5: Run full core tests
./gradlew :modules:core:test 2>&1 | tail -10

Expected: All tests pass.

  • Step 6: Commit
git add modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala \
        modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala
git commit -m "feat: add PGN export support for pawn promotion notation (=Q/=R/=B/=N)"

Task 9: PGN import support for promotions

Files:

  • Modify: modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala

  • Modify: modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala

  • Step 1: Write failing tests

Add to modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala:

test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
  val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
  val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
  result.isDefined should be (true)
  result.get.promotionPiece should be (Some(PromotionPiece.Queen))
  result.get.to should be (Square(File.E, Rank.R8))
}

test("parseAlgebraicMove preserves promotion to Rook") {
  val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
  val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
  result.get.promotionPiece should be (Some(PromotionPiece.Rook))
}

test("parseAlgebraicMove preserves promotion to Bishop") {
  val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
  val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
  result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
}

test("parseAlgebraicMove preserves promotion to Knight") {
  val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
  val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
  result.get.promotionPiece should be (Some(PromotionPiece.Knight))
}

test("parsePgn applies promoted piece to board for subsequent moves") {
  // White promotes e7 to Queen, then Black King moves — should parse both
  val pgn = """[Event "Test"]
[White "A"]
[Black "B"]

1. e7e8=Q Ke7
"""
  val result = PgnParser.parsePgn(pgn)
  result.isDefined should be (true)
  result.get.moves.length should be (2)
  result.get.moves(0).promotionPiece should be (Some(PromotionPiece.Queen))
}

Add imports at the top of PgnParserTest.scala:

import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.notation.FenParser
import de.nowchess.api.board.{File, Rank}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -20

Expected: FAIL — promotionPiece is always None since PgnParser strips =[NBRQ] but doesn't record it.

  • Step 3: Update PgnParser to preserve promotion piece

In modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala:

  1. Add imports at the top:
import de.nowchess.api.move.PromotionPiece
  1. Add a private helper method to extract promotion piece from notation:
  private def extractPromotion(notation: String): Option[PromotionPiece] =
    val promotionPattern = """=([QRBN])""".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
    }
  1. In parseRegularMove, change the last line from:
        disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))

to:

        val promotion = extractPromotion(notation)
        disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
  1. In parseMovesText, update the board-state update after a move to apply the promoted piece. Replace:
              val newBoard = move.castleSide match
                case Some(side) => board.withCastle(color, side)
                case None       => board.withMove(move.from, move.to)._1

with:

              val newBoard = 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

This requires Piece and PieceType in scope; they come from the existing import de.nowchess.api.board.*.

  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -10

Expected: All PgnParserTest tests pass.

  • Step 5: Run full core tests
./gradlew :modules:core:test 2>&1 | tail -10

Expected: All tests pass.

  • Step 6: Commit
git add modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala \
        modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala
git commit -m "feat: add PGN import support for pawn promotion notation"

Task 10: Final integration and coverage verification

Files: All modified files from previous tasks.

  • Step 1: Full build
./gradlew build 2>&1 | tail -20

Expected: BUILD SUCCESSFUL across all modules.

  • Step 2: Check coverage gaps for core module
./gradlew :modules:core:scoverageTest && \
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml

Expected: No uncovered lines in newly added code.

  • Step 3: Check coverage gaps for ui module
./gradlew :modules:ui:scoverageTest && \
python jacoco-reporter/scoverage_coverage_gaps.py modules/ui/build/reports/scoverageTest/scoverage.xml

Expected: No uncovered lines in TerminalUI promotion paths.

  • Step 4: Verify NCS-10 requirements

Check each requirement is satisfied:

  • Promotion is mandatory — pawn cannot complete move until piece is chosen

    • processMove returns PromotionRequired, move not recorded until completePromotion called
  • All four promotion targets are selectable (Queen, Rook, Bishop, Knight)

    • Tests: completePromotion with rook/bishop/knight underpromotion
    • UI: TerminalUI accepts q, r, b, n
  • Underpromotion works correctly

    • Tests cover non-Queen piece promotion in both GameController and GameEngine
  • PGN notation records the promotion piece

    • moveToAlgebraic exports e7e8=Q format
    • parseAlgebraicMove reads e7e8=Q and preserves it in HistoryMove.promotionPiece
    • Board state after parsed promotion uses promoted piece for subsequent move resolution
  • Promotion with capture works

    • completePromotion captures opponent piece test
    • processMove detects pawn capturing to back rank as PromotionRequired with captured piece test
  • Step 5: Final commit

git add .
git commit -m "chore: verify NCS-10 pawn promotion implementation complete"