# 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: ```scala 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** ```bash ./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: ```scala case class Move( from: Square, to: Square, castleSide: Option[CastleSide], promotionPiece: Option[PromotionPiece] = None ) ``` Also add the import at the top of the file: ```scala 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: ```scala 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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash 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: ```scala 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** ```bash ./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`: ```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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v ``` Expected: PASS. - [ ] **Step 5: Run full core test suite to ensure no regressions** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash 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`): ```scala 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: ```scala 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** ```bash ./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): ```scala 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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v ``` Expected: PASS. - [ ] **Step 6: Run full core tests to ensure no regressions** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 7: Commit** ```bash 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`: ```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** ```bash ./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`: ```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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v ``` Expected: PASS. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash 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`: ```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** ```bash ./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`: ```scala 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: ```scala 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** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 5: Compile check** ```bash ./gradlew :modules:core:build -v ``` Expected: Build succeeds. - [ ] **Step 6: Commit** ```bash 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: ```scala 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** ```bash ./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: ```scala 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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v ``` Expected: PASS. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash 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: ```scala 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]`: ```scala def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])] ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./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: ```scala 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** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" -v ``` Expected: PASS. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash 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** ```bash ./gradlew :modules:core:test -v ``` Expected: All tests pass, 100% line coverage for new code. - [ ] **Step 2: Check coverage gaps** ```bash ./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** ```bash ./gradlew build -v ``` Expected: Full build passes. - [ ] **Step 4: Manual gameplay test (optional)** Run the application and test promotion manually: ```bash ./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** ```bash ./gradlew build -v ``` Expected: Full green build. - [ ] **Step 6: Verify requirements from NCS-10 DoD** Check each requirement is satisfied: - [x] Promotion is mandatory — move is not completed until piece is chosen - `processMove` returns `PromotionRequired`, blocking move until `completePromotion` is called - [x] All four promotion targets are selectable - Tests cover Queen, Rook, Bishop, Knight - `gameLoop` accepts `q`, `r`, `b`, `n` - [x] Underpromotion works correctly - Tests verify promotion to non-Queen pieces - [x] PGN notation records the promotion piece - `moveToSan` exports `e8=Q` format - `parseMove` parses `e8=Q` format - [x] 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** ```bash 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?