# Pawn Promotion Design — NCS-10 **Date:** 2026-03-29 **Issue:** NCS-10 — Implement Pawn Promotion **Modules:** `modules/api` (domain types), `modules/core` (logic, game loop) ## Overview Pawn promotion is a **two-step interaction**: when a pawn reaches the opponent's back rank, the game pauses and prompts the player to choose a promotion piece (Queen, Rook, Bishop, or Knight). The move is not complete until a piece is selected. The choice is recorded in game history so promotions survive FEN/PGN serialization and round-trips. ## Requirements (from DoD) - [x] Promotion is mandatory — move is not completed until piece is chosen - [x] All four promotion targets are selectable (Q, R, B, N) - [x] Underpromotion (e.g. to knight) works correctly - [x] PGN notation records the promotion piece (e.g. e8=Q) - [x] Tests cover: promotion to each piece, promotion via capture, underpromotion ## Architecture ### 1. History Recording **File:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala` Extend the `Move` type to record promotion choices: ```scala case class Move( from: Square, to: Square, castleSide: Option[CastleSide], promotionPiece: Option[PromotionPiece] = None ) ``` - `promotionPiece = None` for non-promotion moves - `promotionPiece = Some(Queen|Rook|Bishop|Knight)` for promotion moves - `addMove()` overloaded to accept promotion piece: `addMove(from, to, castleSide?, promotionPiece?)` ### 2. Move Validation **File:** `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` Add promotion detection: ```scala def isPromotionMove(board: Board, from: Square, to: Square): Boolean = board.pieceAt(from) match case Some(Piece(_, PieceType.Pawn)) => val destRank = to.rank (from.rank == Rank.R7 && destRank == Rank.R8) || // White pawn to R8 (from.rank == Rank.R2 && destRank == Rank.R1) // Black pawn to R1 case _ => false ``` This identifies when a move is pawn reaching the back rank. The move is **legal** (passes `isLegal()`), but **incomplete** until a promotion piece is chosen. ### 3. Game Loop Flow **File:** `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` #### New MoveResult variant: ```scala case class PromotionRequired( from: Square, to: Square, newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color ) extends MoveResult ``` #### Flow in `processMove()`: 1. Validate move is legal (existing logic) 2. Detect castling or promotion: - If castling → apply transformation, return `Moved` / `MovedInCheck` - If promotion → return `PromotionRequired` (move board state pre-promotion, pawn still on source square) - Otherwise → apply move, return `Moved` / `MovedInCheck` #### New function: Complete promotion ```scala def completePromotion( board: Board, history: GameHistory, from: Square, to: Square, piece: PromotionPiece, turn: Color ): MoveResult ``` This applies the pawn move, places the promoted piece, and returns `Moved` or `MovedInCheck`. #### Loop integration: ```scala def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = // ... existing render + prompt processMove(board, history, turn, input) match case MoveResult.PromotionRequired(from, to, newBoard, newHistory, captured, newTurn) => println("Promote to: (q/r/b/n)? ") val pieceInput = StdIn.readLine().trim.toLowerCase val piece = 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 piece. Choose (q/r/b/n).") gameLoop(board, history, turn) // retry promotion choice case Some(p) => // completePromotion returns a MoveResult (Moved or MovedInCheck) // and is handled recursively through the same loop completePromotion(newBoard, newHistory, from, to, p, turn, newTurn) match case result: MoveResult.Moved => // handle as normal move gameLoop(result.newBoard, result.newHistory, result.newTurn) case result: MoveResult.MovedInCheck => // handle check state gameLoop(result.newBoard, result.newHistory, result.newTurn) case _ => // should not happen gameLoop(board, history, turn) case other => // existing cases (Quit, InvalidFormat, NoPiece, WrongColor, IllegalMove, Moved, MovedInCheck, Checkmate, Stalemate) ``` ### 4. PGN Support **File:** `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` When exporting a move that includes promotion: ```scala def moveToSan(move: HistoryMove): String = val base = s"${move.from}${move.to}" move.promotionPiece match case Some(piece) => s"$base=${piece.label.head.toUpperCase}" case None => base ``` Output: `e7e8=Q`, `e7e8=n` (underpromotion to knight), etc. **File:** `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala` Parse promotion notation during PGN import: ```scala def parsePromotion(move: String): Option[PromotionPiece] = // Extract '=Q' suffix and convert to PromotionPiece ``` ### 5. Test Coverage **Files:** - `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` - `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` Test scenarios (using FEN to set up board positions): 1. **Promotion detection:** - White pawn on e7 moving to e8 → `isPromotionMove` returns `true` - Black pawn on e2 moving to e1 → `isPromotionMove` returns `true` - Pawn moving but not to back rank → `isPromotionMove` returns `false` 2. **Each piece type:** - Promote to Queen: e7e8 + "q" → pawn becomes Queen - Promote to Rook: e7e8 + "r" → pawn becomes Rook - Promote to Bishop: e7e8 + "b" → pawn becomes Bishop - Promote to Knight: e7e8 + "n" → pawn becomes Knight 3. **Capture + promotion:** - Pawn captures enemy piece while promoting (e7d8 capturing bishop + promote to Queen) 4. **Underpromotion:** - Promote to Knight instead of Queen (strategic underpromotion) 5. **Both colors:** - White pawn (R7 → R8) - Black pawn (R2 → R1) 6. **Rejection cases:** - Pawn blocked on back rank (no move completes) - Illegal capture during promotion 7. **History recording:** - Move with promotion records `promotionPiece` field - Move without promotion has `promotionPiece = None` 8. **Game flow:** - `processMove()` returns `PromotionRequired` - `completePromotion()` advances game state correctly - Game status (check, mate, draw) evaluated after promotion completes ## Data Flow Diagram ``` User input: "e7e8" ↓ processMove() → parseMove() → (Square(E, R7), Square(E, R8)) ↓ Validate legality → MoveValidator.isLegal(board, history, from, to) ↓ Detect promotion? → MoveValidator.isPromotionMove(board, from, to) ↓ Yes → return PromotionRequired(from, to, board, history, ...) ↓ gameLoop handles result, prompts: "Promote to: (q/r/b/n)?" ↓ User input: "q" ↓ completePromotion(board, history, from, to, Queen, turn) ↓ Apply pawn move, place Queen, record in history with promotionPiece=Queen ↓ Evaluate game status, continue loop ``` ## Implementation Notes - **Promotion is not a choice in `processMove()`** — the function only detects and pauses. The loop handles the interaction. - **The board state in `PromotionRequired` is unchanged** — pawn still on source square until `completePromotion()` applies the move. - **Castling remains independent** — no interaction between promotion and castling logic. - **Coverage goals:** 100% line, branch, and method for all new code (per CLAUDE.md). - **Naming:** Rename `de.nowchess.chess.logic.Move` to `HistoryMove` to avoid collision with `de.nowchess.api.move.Move` (feedback from prior work). ## Scope - Core: move validation, history recording, game loop interaction - API: types already exist (`PromotionPiece`, `MoveType.Promotion`) - Notation: PGN export/import support (deferred if integration tests pass without it) - Rendering: no UI changes beyond console prompts ## Risks - **Off-by-one errors on rank detection:** White R7→R8, Black R2→R1. Tests must verify both. - **Game status evaluation:** Must evaluate check/mate/stalemate *after* promotion completes, not before. - **Backward compatibility:** Extending `GameHistory.Move` requires migration of existing saves (none yet; not a blocker).