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

35 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, record the choice in game history.

Architecture: Move validation detects promotion moves early. The game loop pauses and prompts for a piece choice. Once chosen, completePromotion() applies the move and records it with the promotion piece. PGN export/import preserves promotion notation.

Tech Stack: Scala 3, Quarkus (core module is non-Quarkus), scoverage for coverage, FEN for test board setup.


Task 1: Extend GameHistory.Move to include promotionPiece

Files:

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

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

  • Step 1: Write failing test for promotionPiece field

Open modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala and add:

test("Move with promotion records the promotion piece") {
  val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
  move.promotionPiece should be (Some(PromotionPiece.Queen))
}

test("Normal move has no promotion piece") {
  val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)
  move.promotionPiece should be (None)
}

test("addMove with promotion stores promotionPiece") {
  val history = GameHistory.empty
  val newHistory = history.addMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
  newHistory.moves should have length 1
  newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v

Expected: FAIL with "not a member of Move" or similar (field doesn't exist yet).

  • Step 3: Extend Move case class with promotionPiece

In modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala, update the Move case class:

case class Move(
  from: Square,
  to: Square,
  castleSide: Option[CastleSide],
  promotionPiece: Option[PromotionPiece] = None
)

Also add the import at the top of the file:

import de.nowchess.api.move.PromotionPiece
  • Step 4: Update addMove() to accept promotionPiece parameter

In GameHistory case class, locate the addMove method and update it:

def addMove(
  from: Square,
  to: Square,
  castleSide: Option[CastleSide] = None,
  promotionPiece: Option[PromotionPiece] = None
): GameHistory =
  val newMove = Move(from, to, castleSide, promotionPiece)
  copy(moves = moves :+ newMove)
  • Step 5: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v

Expected: PASS.

  • Step 6: Commit
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala \
        modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala && \
git commit -m "feat: extend GameHistory.Move to track promotionPiece"

Task 2: Add isPromotionMove() detection 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 for isPromotionMove()

Open modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala and add:

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)
}

test("Pawn on R7 moving backward is not a promotion move") {
  val fen = "8/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.E, Rank.R5)) should be (false)
}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v

Expected: FAIL with "method isPromotionMove not found".

  • Step 3: Implement isPromotionMove()

Add to MoveValidator object in modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala:

def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
  board.pieceAt(from) match
    case Some(Piece(_, PieceType.Pawn)) =>
      val toRank = to.rank
      (from.rank == Rank.R7 && toRank == Rank.R8) ||  // White pawn to R8
      (from.rank == Rank.R2 && toRank == Rank.R1)     // Black pawn to R1
    case _ => false
  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v

Expected: PASS.

  • Step 5: Run full core test suite to ensure no regressions
./gradlew :modules:core:test -v

Expected: All tests pass.

  • Step 6: 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 ADT and detect in 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: Add PromotionRequired case to MoveResult

In modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala, add to the MoveResult sealed trait (after IllegalMove and before Moved):

case class PromotionRequired(
  from: Square,
  to: Square,
  newBoard: Board,
  newHistory: GameHistory,
  captured: Option[Piece],
  newTurn: Color
) extends MoveResult
  • Step 2: Write failing tests for promotion detection in processMove()

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

test("processMove detects 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 history = GameHistory.empty

  val result = GameController.processMove(board, history, Color.White, "e7e8")

  result should matchPattern { case _: MoveResult.PromotionRequired => }
  result match
    case MoveResult.PromotionRequired(from, to, _, _, _, newTurn) =>
      from should be (Square(File.E, Rank.R7))
      to should be (Square(File.E, Rank.R8))
      newTurn should be (Color.Black)
    case _ => fail("Expected PromotionRequired")
}

test("processMove detects pawn capturing to R8 and returns PromotionRequired") {
  val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val history = GameHistory.empty

  val result = GameController.processMove(board, history, Color.White, "e7d8")

  result should matchPattern { case _: MoveResult.PromotionRequired => }
  result match
    case MoveResult.PromotionRequired(from, to, _, _, captured, _) =>
      captured should be (Some(Piece(Color.Black, PieceType.Queen)))
    case _ => fail("Expected PromotionRequired with captured piece")
}

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 history = GameHistory.empty

  val result = GameController.processMove(board, history, Color.Black, "e2e1")

  result should matchPattern { case _: MoveResult.PromotionRequired => }
}
  • Step 3: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v

Expected: FAIL.

  • Step 4: Modify processMove() to detect promotion moves

In GameController.processMove(), after checking WrongColor and before IllegalMove check, insert promotion detection. The full function should look like this (with promotion logic added in the inner match):

def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
  raw.trim match
    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 if MoveValidator.isPromotionMove(board, from, to) then
                // Pawn reaching back rank: return board state unchanged (pawn still on source)
                val captured = board.pieceAt(to)
                MoveResult.PromotionRequired(from, to, board, history, captured, turn.opposite)
              else
                val castleOpt = if MoveValidator.isCastle(board, from, to)
                                then Some(MoveValidator.castleSide(from, to))
                                else None
                val (newBoard, captured) = castleOpt match
                  case Some(side) => (board.withCastle(turn, side), None)
                  case None       => board.withMove(from, to)
                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" -v

Expected: PASS.

  • Step 6: Run full core tests to ensure no regressions
./gradlew :modules:core:test -v

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: Implement completePromotion() function

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 for completePromotion()

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

test("completePromotion applies pawn move and places promoted queen") {
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    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 history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    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 history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    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 history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    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 with capture") {
  val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    Square(File.E, Rank.R7), Square(File.D, Rank.R8),
    PromotionPiece.Queen,
    Color.White
  )

  result match
    case MoveResult.Moved(newBoard, newHistory, 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 history = GameHistory.empty

  val result = GameController.completePromotion(
    board, history,
    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 history = GameHistory.empty

  // Promote to Queen on e8 gives check to black king on d8
  val result = GameController.completePromotion(
    board, history,
    Square(File.E, Rank.R7), Square(File.E, Rank.R8),
    PromotionPiece.Queen,
    Color.White
  )

  result should matchPattern { case _: MoveResult.MovedInCheck => }
}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v

Expected: FAIL with "method completePromotion not found".

  • Step 3: Implement completePromotion()

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

  def completePromotion(
    board: Board,
    history: GameHistory,
    from: Square,
    to: Square,
    piece: PromotionPiece,
    turn: Color
  ): MoveResult =
    // Apply the pawn move
    val (boardAfterMove, captured) = board.withMove(from, to)

    // Convert PromotionPiece to PieceType
    val promotedPieceType = piece match
      case PromotionPiece.Queen  => PieceType.Queen
      case PromotionPiece.Rook   => PieceType.Rook
      case PromotionPiece.Bishop => PieceType.Bishop
      case PromotionPiece.Knight => PieceType.Knight

    // Replace the pawn with the promoted piece
    val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))

    // Record the move with the promotion piece
    val newHistory = history.addMove(from, to, None, Some(piece))

    // Evaluate game status
    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 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v

Expected: PASS.

  • Step 5: Run full core tests
./gradlew :modules:core:test -v

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 finalize promotion moves"

Task 5: Update gameLoop to handle PromotionRequired

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 (integration test)

  • Step 1: Write integration test for gameLoop with promotion

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

test("gameLoop integration: e7e8 followed by q returns queen on e8") {
  // Test that promotion interaction works end-to-end
  val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
  val board = FenParser.parse(fen).board
  val history = GameHistory.empty

  // Step 1: Player enters e7e8 (pawn promotion)
  val result1 = GameController.processMove(board, history, Color.White, "e7e8")
  result1 should matchPattern { case _: MoveResult.PromotionRequired => }

  // Step 2: Extract from/to, then player enters q for queen
  result1 match
    case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, _, turn) =>
      val result2 = GameController.completePromotion(
        boardBeforePromotion, histBeforePromotion,
        from, to,
        PromotionPiece.Queen,
        Color.White
      )
      result2 should matchPattern { case _: MoveResult.Moved => }
      result2 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")
}
  • Step 2: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v

Expected: PASS (completePromotion is already implemented).

  • Step 3: Update gameLoop to handle PromotionRequired

In GameController.gameLoop(), add a new case to the match statement after IllegalMove:

      case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, currentTurn) =>
        println("Promote to: (q/r/b/n)? ")
        val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase
        val piece: Option[PromotionPiece] = pieceInput match
          case "q" => Some(PromotionPiece.Queen)
          case "r" => Some(PromotionPiece.Rook)
          case "b" => Some(PromotionPiece.Bishop)
          case "n" => Some(PromotionPiece.Knight)
          case _   => None

        piece match
          case None =>
            println("Invalid choice. Enter q, r, b, or n.")
            gameLoop(board, history, turn)  // retry promotion choice
          case Some(p) =>
            completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, Color.White) match
              case MoveResult.Moved(newBoard, newHistory, _, newTurn) =>
                if captured.isDefined then
                  println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
                else
                  println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
                gameLoop(newBoard, newHistory, newTurn)
              case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) =>
                if captured.isDefined then
                  println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
                else
                  println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
                println(s"${newTurn.label} is in check!")
                gameLoop(newBoard, newHistory, newTurn)
              case MoveResult.Checkmate(winner) =>
                println(s"Checkmate! ${winner.label} wins.")
                gameLoop(Board.initial, GameHistory.empty, Color.White)
              case MoveResult.Stalemate =>
                println("Stalemate! The game is a draw.")
                gameLoop(Board.initial, GameHistory.empty, Color.White)
              case _ =>
                // should not happen
                println("Error completing promotion.")
                gameLoop(board, history, turn)

Wait, there's an issue here. The PromotionRequired returns Color.White as turn, but inside the match turn variable refers to the loop's current turn. Let me fix:

      case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, _) =>
        println("Promote to: (q/r/b/n)? ")
        val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase
        val piece: Option[PromotionPiece] = pieceInput match
          case "q" => Some(PromotionPiece.Queen)
          case "r" => Some(PromotionPiece.Rook)
          case "b" => Some(PromotionPiece.Bishop)
          case "n" => Some(PromotionPiece.Knight)
          case _   => None

        piece match
          case None =>
            println("Invalid choice. Enter q, r, b, or n.")
            gameLoop(board, history, turn)  // retry promotion choice
          case Some(p) =>
            completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, turn) match
              case MoveResult.Moved(newBoard, newHistory, _, newTurn) =>
                if captured.isDefined then
                  println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
                else
                  println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
                gameLoop(newBoard, newHistory, newTurn)
              case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) =>
                if captured.isDefined then
                  println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
                else
                  println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
                println(s"${newTurn.label} is in check!")
                gameLoop(newBoard, newHistory, newTurn)
              case MoveResult.Checkmate(winner) =>
                println(s"Checkmate! ${winner.label} wins.")
                gameLoop(Board.initial, GameHistory.empty, Color.White)
              case MoveResult.Stalemate =>
                println("Stalemate! The game is a draw.")
                gameLoop(Board.initial, GameHistory.empty, Color.White)
              case _ =>
                println("Error completing promotion.")
                gameLoop(board, history, turn)
  • Step 4: Run full core tests
./gradlew :modules:core:test -v

Expected: All tests pass.

  • Step 5: Compile check
./gradlew :modules:core:build -v

Expected: Build succeeds.

  • Step 6: Commit
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala && \
git commit -m "feat: update gameLoop to prompt for promotion piece and complete promotion"

Task 6: Add 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 for promotion notation export

Open modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala and add:

test("moveToSan exports promotion notation with Queen") {
  val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
  PgnExporter.moveToSan(move) should be ("e7e8=Q")
}

test("moveToSan exports promotion notation with Rook") {
  val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
  PgnExporter.moveToSan(move) should be ("e7e8=R")
}

test("moveToSan exports promotion notation with Bishop") {
  val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop))
  PgnExporter.moveToSan(move) should be ("e7e8=B")
}

test("moveToSan exports promotion notation with Knight") {
  val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight))
  PgnExporter.moveToSan(move) should be ("e7e8=N")
}

test("moveToSan exports normal move without promotion") {
  val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)
  PgnExporter.moveToSan(move) should be ("e2e4")
}

test("moveToSan exports capture without promotion") {
  val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), None, None)
  PgnExporter.moveToSan(move) should be ("e5d6")
}
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v

Expected: FAIL (moveToSan doesn't handle promotion yet).

  • Step 3: Implement promotion export in PgnExporter

Open modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala and update the moveToSan function:

def moveToSan(move: Move): String =
  val baseMove = s"${move.from}${move.to}"
  move.promotionPiece match
    case Some(piece) =>
      val pieceLetter = piece match
        case PromotionPiece.Queen  => "Q"
        case PromotionPiece.Rook   => "R"
        case PromotionPiece.Bishop => "B"
        case PromotionPiece.Knight => "N"
      s"$baseMove=$pieceLetter"
    case None => baseMove

(Assuming moveToSan currently exists; if not, create it as shown above.)

  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v

Expected: PASS.

  • Step 5: Run full core tests
./gradlew :modules:core:test -v

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"

Task 7: Add 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 for promotion notation parsing

Open modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala and add:

test("parseMove parses promotion to Queen") {
  val result = PgnParser.parseMove("e7e8=Q")
  result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen))))
}

test("parseMove parses promotion to Rook") {
  val result = PgnParser.parseMove("e7e8=R")
  result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Rook))))
}

test("parseMove parses promotion to Bishop") {
  val result = PgnParser.parseMove("e7e8=B")
  result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop))))
}

test("parseMove parses promotion to Knight") {
  val result = PgnParser.parseMove("e7e8=N")
  result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight))))
}

test("parseMove parses normal move without promotion") {
  val result = PgnParser.parseMove("e2e4")
  result should be (Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)))
}

test("parseMove rejects invalid promotion piece") {
  val result = PgnParser.parseMove("e7e8=X")
  result should be (None)
}

Note: Update the return type of parseMove to include Option[PromotionPiece]:

def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])]
  • Step 2: Run test to verify it fails
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" -v

Expected: FAIL.

  • Step 3: Implement promotion parsing in PgnParser

Open modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala and update (or create) the parseMove function:

def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])] =
  val pattern = """^([a-h])([1-8])([a-h])([1-8])(?:=([QRBN]))?$""".r
  moveStr.trim match
    case pattern(fromFile, fromRank, toFile, toRank, promotionPiece) =>
      val from = Square(File.fromChar(fromFile.head), Rank.fromOrdinal(fromRank.toInt - 1))
      val to = Square(File.fromChar(toFile.head), Rank.fromOrdinal(toRank.toInt - 1))
      val promotion = Option(promotionPiece).flatMap { p =>
        p match
          case "Q" => Some(PromotionPiece.Queen)
          case "R" => Some(PromotionPiece.Rook)
          case "B" => Some(PromotionPiece.Bishop)
          case "N" => Some(PromotionPiece.Knight)
          case _   => None
      }
      Some((from, to, promotion))
    case _ => None
  • Step 4: Run test to verify it passes
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" -v

Expected: PASS.

  • Step 5: Run full core tests
./gradlew :modules:core:test -v

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 8: Final integration and coverage verification

Files:

  • All modified files from previous tasks

  • Coverage report

  • Step 1: Run full test suite

./gradlew :modules:core:test -v

Expected: All tests pass, 100% line coverage for new code.

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

Expected: No uncovered lines in new code.

  • Step 3: Build all modules
./gradlew build -v

Expected: Full build passes.

  • Step 4: Manual gameplay test (optional)

Run the application and test promotion manually:

./gradlew :modules:core:run

At the prompt, set up a promotion scenario and verify:

  • Move pawn to back rank (e.g. e7e8)

  • System prompts "Promote to: (q/r/b/n)?"

  • Enter choice (e.g. q)

  • Pawn is replaced with chosen piece

  • Game continues normally

  • Step 5: Final build and test

./gradlew build -v

Expected: Full green build.

  • Step 6: Verify requirements from NCS-10 DoD

Check each requirement is satisfied:

  • Promotion is mandatory — move is not completed until piece is chosen

    • processMove returns PromotionRequired, blocking move until completePromotion is called
  • All four promotion targets are selectable

    • Tests cover Queen, Rook, Bishop, Knight
    • gameLoop accepts q, r, b, n
  • Underpromotion works correctly

    • Tests verify promotion to non-Queen pieces
  • PGN notation records the promotion piece

    • moveToSan exports e8=Q format
    • parseMove parses e8=Q format
  • Tests cover promotion to each piece, capture + promotion, underpromotion

    • MoveValidatorTest: promotion detection
    • GameControllerTest: complete promotion flow, each piece type, captures, underpromotion
    • PgnExporterTest: notation export
    • PgnParserTest: notation parsing
  • Step 7: Commit final integration verification

git log --oneline -8

Verify the 7 commits for promotion tasks are present.


Implementation Summary

Task Component Changes
1 GameHistory Extended Move with promotionPiece: Option[PromotionPiece]
2 MoveValidator Added isPromotionMove() detection
3 GameController Added PromotionRequired result, detection in processMove()
4 GameController Added completePromotion() to apply move and record promotion
5 GameController Updated gameLoop() to prompt and handle promotion
6 PgnExporter Export promotion notation (e.g. e8=Q)
7 PgnParser Parse promotion notation (e.g. e8=Q)
8 Integration Full test suite passes, 100% coverage, requirements verified

Plan complete and saved to docs/superpowers/plans/2026-03-29-pawn-promotion.md.

Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?