diff --git a/docs/superpowers/plans/2026-03-29-pawn-promotion.md b/docs/superpowers/plans/2026-03-29-pawn-promotion.md new file mode 100644 index 0000000..0c73f87 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-pawn-promotion.md @@ -0,0 +1,1030 @@ +# 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? \ No newline at end of file