# Pawn Promotion Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement pawn promotion as a two-step interaction: detect pawn reaching back rank, prompt player to choose a promotion piece, apply the choice through GameEngine/TerminalUI, and record it in PGN export/import. **Architecture:** Pure `GameController.processMove()` returns `PromotionRequired` when a pawn reaches the back rank. `GameEngine` stores pending promotion state and exposes `completePromotion(piece)`. `TerminalUI` listens for `PromotionRequiredEvent`, prompts the user, then calls `engine.completePromotion()` on the next input. PGN export/import preserves `=Q/=R/=B/=N` notation. **Tech Stack:** Scala 3, core module (no Quarkus), ui module, scoverage for coverage, FEN for test board setup. --- ## Architecture Map | Layer | File | Change | |-------|------|--------| | Logic | `MoveValidator.scala` | Add `isPromotionMove()` | | Controller | `GameController.scala` | Add `PromotionRequired` to `MoveResult`; update `processMove()`; add `completePromotion()` | | Observer | `Observer.scala` | Add `PromotionRequiredEvent` | | Engine | `GameEngine.scala` | Add optional init params, pending promotion state, `isPendingPromotion`, `completePromotion()`, handle `PromotionRequired` in `processUserInput()` | | UI | `TerminalUI.scala` (modules/ui) | Handle `PromotionRequiredEvent`, route promotion input to `engine.completePromotion()` | | PGN Export | `PgnExporter.scala` | Emit `=Q/=R/=B/=N` suffix in `moveToAlgebraic()` | | PGN Import | `PgnParser.scala` | Preserve promotion piece in `HistoryMove`; apply promoted piece to board state | > **Task 1 is already complete:** `HistoryMove.promotionPiece: Option[PromotionPiece]` was added in commit `0800c3a` and tests already exist in `GameHistoryTest.scala`. Start from Task 2. --- ### Task 2: Add isPromotionMove() to MoveValidator **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` - Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` - [ ] **Step 1: Write failing tests** Open `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` and add at the end of the class: ```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) } ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 ``` Expected: FAIL — `isPromotionMove` not found. - [ ] **Step 3: Implement isPromotionMove()** In `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`, add after the existing `isLegal` overload (around line 174): ```scala /** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */ def isPromotionMove(board: Board, from: Square, to: Square): Boolean = board.pieceAt(from) match case Some(Piece(_, PieceType.Pawn)) => (from.rank == Rank.R7 && to.rank == Rank.R8) || (from.rank == Rank.R2 && to.rank == Rank.R1) case _ => false ``` - [ ] **Step 4: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -10 ``` Expected: All MoveValidatorTest tests pass. - [ ] **Step 5: 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 and update processMove() **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` - Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - [ ] **Step 1: Write failing tests** Open `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` and add: ```scala test("processMove detects white pawn reaching R8 and returns PromotionRequired") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") result should matchPattern { case _: MoveResult.PromotionRequired => } result match case MoveResult.PromotionRequired(from, to, _, _, _, turn) => from should be (Square(File.E, Rank.R7)) to should be (Square(File.E, Rank.R8)) turn should be (Color.White) case _ => fail("Expected PromotionRequired") } test("processMove detects black pawn reaching R1 and returns PromotionRequired") { val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1" val board = FenParser.parse(fen).board val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1") result should matchPattern { case _: MoveResult.PromotionRequired => } } test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece") { val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8") result should matchPattern { case _: MoveResult.PromotionRequired => } result match case MoveResult.PromotionRequired(_, _, _, _, captured, _) => captured should be (Some(Piece(Color.Black, PieceType.Queen))) case _ => fail("Expected PromotionRequired") } ``` You will need to add these imports to `GameControllerTest.scala` if not already present: ```scala import de.nowchess.chess.notation.FenParser ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 ``` Expected: FAIL — `PromotionRequired` not a member of `MoveResult`. - [ ] **Step 3: Add PromotionRequired to MoveResult ADT** In `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`, add `PromotionRequired` to the `MoveResult` object **after** `IllegalMove` and **before** `Moved`. Also add the `PromotionPiece` import at the top of the file: Add import after the existing imports: ```scala import de.nowchess.api.move.PromotionPiece ``` Add to `MoveResult` object: ```scala case class PromotionRequired( from: Square, to: Square, boardBefore: Board, historyBefore: GameHistory, captured: Option[Piece], turn: Color ) extends MoveResult ``` The full updated `MoveResult` object should be: ```scala sealed trait MoveResult object MoveResult: case object Quit extends MoveResult case class InvalidFormat(raw: String) extends MoveResult case object NoPiece extends MoveResult case object WrongColor extends MoveResult case object IllegalMove extends MoveResult case class PromotionRequired(from: Square, to: Square, boardBefore: Board, historyBefore: GameHistory, captured: Option[Piece], turn: Color) extends MoveResult case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class Checkmate(winner: Color) extends MoveResult case object Stalemate extends MoveResult ``` - [ ] **Step 4: Update processMove() to detect promotion before executing the move** In `GameController.processMove()`, add the promotion check after the `isLegal` check and before the castle/en-passant logic. Replace the `else` clause from `if !MoveValidator.isLegal(...)` with: ```scala if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove else if MoveValidator.isPromotionMove(board, from, to) then val captured = board.pieceAt(to) MoveResult.PromotionRequired(from, to, board, history, captured, turn) else val castleOpt = if MoveValidator.isCastle(board, from, to) then Some(MoveValidator.castleSide(from, to)) else None val isEP = EnPassantCalculator.isEnPassant(board, history, from, to) val (newBoard, captured) = castleOpt match case Some(side) => (board.withCastle(turn, side), None) case None => val (b, cap) = board.withMove(from, to) if isEP then val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) (b.removed(capturedSq), board.pieceAt(capturedSq)) else (b, cap) val newHistory = history.addMove(from, to, castleOpt) GameRules.gameStatus(newBoard, newHistory, turn.opposite) match case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Drawn => MoveResult.Stalemate ``` - [ ] **Step 5: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10 ``` Expected: All GameControllerTest tests pass. - [ ] **Step 6: Run full core tests to check no regressions** ```bash ./gradlew :modules:core:test 2>&1 | tail -15 ``` 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: Add completePromotion() to GameController **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` - Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` - [ ] **Step 1: Write failing tests** Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`: ```scala test("completePromotion applies move and places queen") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), PromotionPiece.Queen, Color.White ) result should matchPattern { case _: MoveResult.Moved => } result match case MoveResult.Moved(newBoard, newHistory, _, _) => newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) newBoard.pieceAt(Square(File.E, Rank.R7)) should be (None) newHistory.moves should have length 1 newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) case _ => fail("Expected Moved") } test("completePromotion with rook underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), PromotionPiece.Rook, Color.White ) result match case MoveResult.Moved(newBoard, newHistory, _, _) => newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook)) case _ => fail("Expected Moved with Rook") } test("completePromotion with bishop underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), PromotionPiece.Bishop, Color.White ) result match case MoveResult.Moved(newBoard, newHistory, _, _) => newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop))) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop)) case _ => fail("Expected Moved with Bishop") } test("completePromotion with knight underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), PromotionPiece.Knight, Color.White ) result match case MoveResult.Moved(newBoard, newHistory, _, _) => newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight))) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) case _ => fail("Expected Moved with Knight") } test("completePromotion captures opponent piece") { val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.D, Rank.R8), PromotionPiece.Queen, Color.White ) result match case MoveResult.Moved(newBoard, _, captured, _) => newBoard.pieceAt(Square(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) captured should be (Some(Piece(Color.Black, PieceType.Queen))) case _ => fail("Expected Moved with captured piece") } test("completePromotion for black pawn to R1") { val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R2), Square(File.E, Rank.R1), PromotionPiece.Knight, Color.Black ) result match case MoveResult.Moved(newBoard, newHistory, _, _) => newBoard.pieceAt(Square(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight))) newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) case _ => fail("Expected Moved") } test("completePromotion evaluates check after promotion") { val fen = "3k4/4P3/8/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board val result = GameController.completePromotion( board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), PromotionPiece.Queen, Color.White ) result should matchPattern { case _: MoveResult.MovedInCheck => } } test("completePromotion full round-trip via processMove then completePromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) => val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn) result should matchPattern { case _: MoveResult.Moved => } result match case MoveResult.Moved(finalBoard, finalHistory, _, _) => finalBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) case _ => fail("Expected Moved") case _ => fail("Expected PromotionRequired") } ``` You will need to add this import to `GameControllerTest.scala`: ```scala import de.nowchess.api.move.PromotionPiece ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 ``` Expected: FAIL — `completePromotion` not found. - [ ] **Step 3: Implement completePromotion()** Add to `GameController` object in `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`: ```scala /** Apply a previously detected promotion move with the chosen piece. * Called after processMove returned PromotionRequired. */ def completePromotion( board: Board, history: GameHistory, from: Square, to: Square, piece: PromotionPiece, turn: Color ): MoveResult = val (boardAfterMove, captured) = board.withMove(from, to) val promotedPieceType = piece match case PromotionPiece.Queen => PieceType.Queen case PromotionPiece.Rook => PieceType.Rook case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Knight => PieceType.Knight val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType)) val newHistory = history.addMove(from, to, None, Some(piece)) GameRules.gameStatus(newBoard, newHistory, turn.opposite) match case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Drawn => MoveResult.Stalemate ``` Also add `PieceType` and `Piece` to the imports if not already there. The file already imports `de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}` so `Piece` is covered; add `PieceType`: ```scala import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} ``` - [ ] **Step 4: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10 ``` Expected: All GameControllerTest tests pass. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test 2>&1 | tail -10 ``` 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 GameController to finalize promotion moves" ``` --- ### Task 5: Add PromotionRequiredEvent to Observer **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` - [ ] **Step 1: Add PromotionRequiredEvent case class** In `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, add after `InvalidMoveEvent` and before `BoardResetEvent`. Also add the import for `Square` (it's already in `de.nowchess.api.board.*` but verify the import covers it): ```scala /** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ case class PromotionRequiredEvent( board: Board, history: GameHistory, turn: Color, from: Square, to: Square ) extends GameEvent ``` The existing import `import de.nowchess.api.board.{Board, Color}` must be expanded to include `Square`: ```scala import de.nowchess.api.board.{Board, Color, Square} ``` - [ ] **Step 2: Run full core test suite to ensure nothing broke** ```bash ./gradlew :modules:core:test 2>&1 | tail -10 ``` Expected: All tests pass (adding a new case class with no logic can't break anything). - [ ] **Step 3: Commit** ```bash git add modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala git commit -m "feat: add PromotionRequiredEvent to Observer for pawn promotion notification" ``` --- ### Task 6: Update GameEngine to handle promotion **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala` - Create: `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala` - [ ] **Step 1: Write failing tests** Create `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`: ```scala package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.notation.FenParser import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers class GameEnginePromotionTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = val events = collection.mutable.ListBuffer[GameEvent]() engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e }) events test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val engine = new GameEngine(initialBoard = promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true) events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7)) } test("isPendingPromotion is true after PromotionRequired input") { val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val engine = new GameEngine(initialBoard = promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") engine.isPendingPromotion should be (true) } test("isPendingPromotion is false before any promotion input") { val engine = new GameEngine() engine.isPendingPromotion should be (false) } test("completePromotion fires MoveExecutedEvent with promoted piece") { val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val engine = new GameEngine(initialBoard = promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) engine.isPendingPromotion should be (false) engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None) engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) } test("completePromotion with rook underpromotion") { val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val engine = new GameEngine(initialBoard = promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Rook) engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) } test("completePromotion with no pending promotion fires InvalidMoveEvent") { val engine = new GameEngine() val events = captureEvents(engine) engine.completePromotion(PromotionPiece.Queen) events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) engine.isPendingPromotion should be (false) } test("completePromotion fires CheckDetectedEvent when promotion gives check") { val promotionBoard = FenParser.parse("3k4/4P3/8/8/8/8/8/8 w - - 0 1").board val engine = new GameEngine(initialBoard = promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true) } ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -20 ``` Expected: FAIL — `GameEngine` has no `initialBoard` param, `isPendingPromotion`, or `completePromotion`. - [ ] **Step 3: Update GameEngine class signature and add promotion state** In `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`: 1. Change the class definition to accept optional initial state: ```scala class GameEngine( initialBoard: Board = Board.initial, initialHistory: GameHistory = GameHistory.empty, initialTurn: Color = Color.White ) extends Observable: private var currentBoard: Board = initialBoard private var currentHistory: GameHistory = initialHistory private var currentTurn: Color = initialTurn private val invoker = new CommandInvoker() ``` 2. Add `PromotionPiece` import at the top (with existing imports): ```scala import de.nowchess.api.move.PromotionPiece ``` 3. Add after the `commandHistory` def: ```scala /** True if a pawn promotion move is pending and needs a piece choice. */ def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } ``` 4. Add private state and inner class before `// Synchronized accessors`: ```scala private case class PendingPromotion( from: Square, to: Square, boardBefore: Board, historyBefore: GameHistory, turn: Color ) private var pendingPromotion: Option[PendingPromotion] = None ``` - [ ] **Step 4: Handle PromotionRequired in processUserInput()** In `processUserInput()`, add a case for `PromotionRequired` in the `GameController.processMove(...)` match. After the `MoveResult.Stalemate` case and before the closing brace, add: ```scala case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) => pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn)) notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to)) ``` Also update the `case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>` line to include `MoveResult.PromotionRequired` in the failure list is NOT needed — `PromotionRequired` is a success path. However the match may become non-exhaustive. Ensure the `handleFailedMove` branch still only covers the failure cases (it's `@unchecked` so it won't warn, but confirm the match above is exhaustive). The updated match in `processUserInput` should be: ```scala GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => handleFailedMove(moveInput) case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) invoker.execute(updatedCmd) updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn) case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) invoker.execute(updatedCmd) updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(from.toString, to.toString, captured, newTurn) notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) case MoveResult.Checkmate(winner) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) invoker.execute(updatedCmd) currentBoard = Board.initial currentHistory = GameHistory.empty currentTurn = Color.White notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) case MoveResult.Stalemate => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) invoker.execute(updatedCmd) currentBoard = Board.initial currentHistory = GameHistory.empty currentTurn = Color.White notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) => pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn)) notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to)) ``` - [ ] **Step 5: Add completePromotion() method** Add after the `redo()` method in `GameEngine.scala`: ```scala /** Apply a player's promotion piece choice. * Must only be called when isPendingPromotion is true. */ def completePromotion(piece: PromotionPiece): Unit = synchronized { pendingPromotion match case None => notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending.")) case Some(pending) => pendingPromotion = None val cmd = MoveCommand( from = pending.from, to = pending.to, previousBoard = Some(pending.boardBefore), previousHistory = Some(pending.historyBefore), previousTurn = Some(pending.turn) ) GameController.completePromotion( pending.boardBefore, pending.historyBefore, pending.from, pending.to, piece, pending.turn ) match case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) invoker.execute(updatedCmd) updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn) case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) invoker.execute(updatedCmd) updateGameState(newBoard, newHistory, newTurn) emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn) notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn)) case MoveResult.Checkmate(winner) => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) invoker.execute(updatedCmd) currentBoard = Board.initial currentHistory = GameHistory.empty currentTurn = Color.White notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner)) case MoveResult.Stalemate => val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) invoker.execute(updatedCmd) currentBoard = Board.initial currentHistory = GameHistory.empty currentTurn = Color.White notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) case _ => notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion.")) } ``` - [ ] **Step 6: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -10 ``` Expected: All GameEnginePromotionTest tests pass. - [ ] **Step 7: Run full core tests** ```bash ./gradlew :modules:core:test 2>&1 | tail -10 ``` Expected: All tests pass. - [ ] **Step 8: Commit** ```bash git add modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala \ modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala git commit -m "feat: add promotion handling to GameEngine with pending state and completePromotion()" ``` --- ### Task 7: Update TerminalUI to handle promotion I/O **Files:** - Modify: `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala` - Modify: `modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala` - [ ] **Step 1: Write failing tests** Add to `modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala`: ```scala test("TerminalUI shows promotion prompt on PromotionRequiredEvent") { val out = new ByteArrayOutputStream() val engine = new GameEngine() val ui = new TerminalUI(engine) Console.withOut(out) { ui.onGameEvent(PromotionRequiredEvent( Board(Map.empty), GameHistory(), Color.White, Square(File.E, Rank.R7), Square(File.E, Rank.R8) )) } out.toString should include("Promote to") } test("TerminalUI routes promotion choice to engine.completePromotion") { import de.nowchess.api.move.PromotionPiece import de.nowchess.api.board.{File, Rank, Square} var capturedPiece: Option[PromotionPiece] = None val engine = new GameEngine() { override def processUserInput(rawInput: String): Unit = if rawInput.trim == "e7e8" then notifyObservers(PromotionRequiredEvent( Board(Map.empty), GameHistory.empty, Color.White, Square(File.E, Rank.R7), Square(File.E, Rank.R8) )) override def completePromotion(piece: PromotionPiece): Unit = capturedPiece = Some(piece) notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) } val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes) val out = new ByteArrayOutputStream() val ui = new TerminalUI(engine) Console.withIn(in) { Console.withOut(out) { ui.start() } } capturedPiece should be (Some(PromotionPiece.Queen)) out.toString should include("Promote to") } test("TerminalUI re-prompts on invalid promotion choice") { import de.nowchess.api.move.PromotionPiece import de.nowchess.api.board.{File, Rank, Square} var capturedPiece: Option[PromotionPiece] = None val engine = new GameEngine() { override def processUserInput(rawInput: String): Unit = if rawInput.trim == "e7e8" then notifyObservers(PromotionRequiredEvent( Board(Map.empty), GameHistory.empty, Color.White, Square(File.E, Rank.R7), Square(File.E, Rank.R8) )) override def completePromotion(piece: PromotionPiece): Unit = capturedPiece = Some(piece) notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) } // "x" is invalid, then "r" for rook val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes) val out = new ByteArrayOutputStream() val ui = new TerminalUI(engine) Console.withIn(in) { Console.withOut(out) { ui.start() } } capturedPiece should be (Some(PromotionPiece.Rook)) out.toString should include("Invalid") } ``` You will need to add to the imports at the top of `TerminalUITest.scala`: ```scala import de.nowchess.chess.observer.PromotionRequiredEvent import de.nowchess.api.board.{File, Rank, Square} ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -20 ``` Expected: FAIL — `PromotionRequiredEvent` not handled, no promotion routing in game loop. - [ ] **Step 3: Update TerminalUI** Replace the entire content of `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala` with: ```scala package de.nowchess.ui.terminal import scala.io.StdIn import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.observer.{Observer, GameEvent, *} import de.nowchess.chess.view.Renderer /** Terminal UI that implements Observer pattern. * Subscribes to GameEngine and receives state change events. * Handles all I/O and user interaction in the terminal. */ class TerminalUI(engine: GameEngine) extends Observer: private var running = true private var awaitingPromotion = false /** Called by GameEngine whenever a game event occurs. */ override def onGameEvent(event: GameEvent): Unit = event match case e: MoveExecutedEvent => println() print(Renderer.render(e.board)) e.capturedPiece.foreach: cap => println(s"Captured: $cap on ${e.toSquare}") printPrompt(e.turn) case e: CheckDetectedEvent => println(s"${e.turn.label} is in check!") case e: CheckmateEvent => println(s"Checkmate! ${e.winner.label} wins.") println() print(Renderer.render(e.board)) case e: StalemateEvent => println("Stalemate! The game is a draw.") println() print(Renderer.render(e.board)) case e: InvalidMoveEvent => println(s"⚠️ ${e.reason}") case e: BoardResetEvent => println("Board has been reset to initial position.") println() print(Renderer.render(e.board)) printPrompt(e.turn) case _: PromotionRequiredEvent => println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") awaitingPromotion = true /** Start the terminal UI game loop. */ def start(): Unit = // Register as observer engine.subscribe(this) // Show initial board println() print(Renderer.render(engine.board)) printPrompt(engine.turn) // Game loop while running do val input = Option(StdIn.readLine()).getOrElse("quit").trim if awaitingPromotion then input.toLowerCase match case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen) case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook) case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop) case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight) case _ => println("Invalid choice. Enter q, r, b, or n.") println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") else input.toLowerCase match case "quit" | "q" => running = false println("Game over. Goodbye!") case "" => printPrompt(engine.turn) case _ => engine.processUserInput(input) // Unsubscribe when done engine.unsubscribe(this) private def printPrompt(turn: de.nowchess.api.board.Color): Unit = val undoHint = if engine.canUndo then " [undo]" else "" val redoHint = if engine.canRedo then " [redo]" else "" print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ") ``` - [ ] **Step 4: Run test to verify it passes** ```bash ./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -10 ``` Expected: All TerminalUITest tests pass. - [ ] **Step 5: Run full build to check both modules** ```bash ./gradlew :modules:core:test :modules:ui:test 2>&1 | tail -15 ``` Expected: All tests pass. - [ ] **Step 6: Commit** ```bash git add modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala \ modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala git commit -m "feat: update TerminalUI to handle pawn promotion I/O via awaitingPromotion flag" ``` --- ### Task 8: PGN export support for promotions **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` - Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` - [ ] **Step 1: Write failing tests** Add to `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala`: ```scala test("exportGame encodes promotion to Queen as =Q suffix") { val history = GameHistory() .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))) val pgn = PgnExporter.exportGame(Map.empty, history) pgn should include ("e7e8=Q") } test("exportGame encodes promotion to Rook as =R suffix") { val history = GameHistory() .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))) val pgn = PgnExporter.exportGame(Map.empty, history) pgn should include ("e7e8=R") } test("exportGame encodes promotion to Bishop as =B suffix") { val history = GameHistory() .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop))) val pgn = PgnExporter.exportGame(Map.empty, history) pgn should include ("e7e8=B") } test("exportGame encodes promotion to Knight as =N suffix") { val history = GameHistory() .addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight))) val pgn = PgnExporter.exportGame(Map.empty, history) pgn should include ("e7e8=N") } test("exportGame does not add suffix for normal moves") { val history = GameHistory() .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)) val pgn = PgnExporter.exportGame(Map.empty, history) pgn should include ("e2e4") pgn should not include ("=") } ``` Add the import at the top of `PgnExporterTest.scala`: ```scala import de.nowchess.api.move.PromotionPiece import de.nowchess.api.board.{Rank} ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -20 ``` Expected: FAIL — normal moves export fine, but promotion moves don't emit `=Q` etc. - [ ] **Step 3: Update moveToAlgebraic() in PgnExporter** In `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala`: 1. Add import after existing imports: ```scala import de.nowchess.api.move.PromotionPiece ``` 2. Replace the existing `moveToAlgebraic` method: ```scala private def moveToAlgebraic(move: HistoryMove): String = move.castleSide match case Some(CastleSide.Kingside) => "O-O" case Some(CastleSide.Queenside) => "O-O-O" case None => val base = s"${move.from}${move.to}" move.promotionPiece match case Some(PromotionPiece.Queen) => s"$base=Q" case Some(PromotionPiece.Rook) => s"$base=R" case Some(PromotionPiece.Bishop) => s"$base=B" case Some(PromotionPiece.Knight) => s"$base=N" case None => base ``` - [ ] **Step 4: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -10 ``` Expected: All PgnExporterTest tests pass. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test 2>&1 | tail -10 ``` 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 (=Q/=R/=B/=N)" ``` --- ### Task 9: PGN import support for promotions **Files:** - Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala` - Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala` - [ ] **Step 1: Write failing tests** Add to `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala`: ```scala test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") { val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White) result.isDefined should be (true) result.get.promotionPiece should be (Some(PromotionPiece.Queen)) result.get.to should be (Square(File.E, Rank.R8)) } test("parseAlgebraicMove preserves promotion to Rook") { val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White) result.get.promotionPiece should be (Some(PromotionPiece.Rook)) } test("parseAlgebraicMove preserves promotion to Bishop") { val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White) result.get.promotionPiece should be (Some(PromotionPiece.Bishop)) } test("parseAlgebraicMove preserves promotion to Knight") { val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White) result.get.promotionPiece should be (Some(PromotionPiece.Knight)) } test("parsePgn applies promoted piece to board for subsequent moves") { // White promotes e7 to Queen, then Black King moves — should parse both val pgn = """[Event "Test"] [White "A"] [Black "B"] 1. e7e8=Q Ke7 """ val result = PgnParser.parsePgn(pgn) result.isDefined should be (true) result.get.moves.length should be (2) result.get.moves(0).promotionPiece should be (Some(PromotionPiece.Queen)) } ``` Add imports at the top of `PgnParserTest.scala`: ```scala import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.notation.FenParser import de.nowchess.api.board.{File, Rank} ``` - [ ] **Step 2: Run test to verify it fails** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -20 ``` Expected: FAIL — `promotionPiece` is always `None` since PgnParser strips `=[NBRQ]` but doesn't record it. - [ ] **Step 3: Update PgnParser to preserve promotion piece** In `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala`: 1. Add imports at the top: ```scala import de.nowchess.api.move.PromotionPiece ``` 2. Add a private helper method to extract promotion piece from notation: ```scala private def extractPromotion(notation: String): Option[PromotionPiece] = val promotionPattern = """=([QRBN])""".r promotionPattern.findFirstMatchIn(notation).flatMap { m => m.group(1) match case "Q" => Some(PromotionPiece.Queen) case "R" => Some(PromotionPiece.Rook) case "B" => Some(PromotionPiece.Bishop) case "N" => Some(PromotionPiece.Knight) case _ => None } ``` 3. In `parseRegularMove`, change the last line from: ```scala disambiguated.headOption.map(from => HistoryMove(from, toSquare, None)) ``` to: ```scala val promotion = extractPromotion(notation) disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion)) ``` 4. In `parseMovesText`, update the board-state update after a move to apply the promoted piece. Replace: ```scala val newBoard = move.castleSide match case Some(side) => board.withCastle(color, side) case None => board.withMove(move.from, move.to)._1 ``` with: ```scala val newBoard = move.castleSide match case Some(side) => board.withCastle(color, side) case None => val (boardAfterMove, _) = board.withMove(move.from, move.to) move.promotionPiece match case Some(pp) => val pieceType = pp match case PromotionPiece.Queen => PieceType.Queen case PromotionPiece.Rook => PieceType.Rook case PromotionPiece.Bishop => PieceType.Bishop case PromotionPiece.Knight => PieceType.Knight boardAfterMove.updated(move.to, Piece(color, pieceType)) case None => boardAfterMove ``` This requires `Piece` and `PieceType` in scope; they come from the existing `import de.nowchess.api.board.*`. - [ ] **Step 4: Run test to verify it passes** ```bash ./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -10 ``` Expected: All PgnParserTest tests pass. - [ ] **Step 5: Run full core tests** ```bash ./gradlew :modules:core:test 2>&1 | tail -10 ``` 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 10: Final integration and coverage verification **Files:** All modified files from previous tasks. - [ ] **Step 1: Full build** ```bash ./gradlew build 2>&1 | tail -20 ``` Expected: BUILD SUCCESSFUL across all modules. - [ ] **Step 2: Check coverage gaps for core module** ```bash ./gradlew :modules:core:scoverageTest && \ python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml ``` Expected: No uncovered lines in newly added code. - [ ] **Step 3: Check coverage gaps for ui module** ```bash ./gradlew :modules:ui:scoverageTest && \ python jacoco-reporter/scoverage_coverage_gaps.py modules/ui/build/reports/scoverageTest/scoverage.xml ``` Expected: No uncovered lines in `TerminalUI` promotion paths. - [ ] **Step 4: Verify NCS-10 requirements** Check each requirement is satisfied: - [ ] Promotion is mandatory — pawn cannot complete move until piece is chosen - `processMove` returns `PromotionRequired`, move not recorded until `completePromotion` called - [ ] All four promotion targets are selectable (Queen, Rook, Bishop, Knight) - Tests: `completePromotion with rook/bishop/knight underpromotion` - UI: TerminalUI accepts `q`, `r`, `b`, `n` - [ ] Underpromotion works correctly - Tests cover non-Queen piece promotion in both GameController and GameEngine - [ ] PGN notation records the promotion piece - `moveToAlgebraic` exports `e7e8=Q` format - `parseAlgebraicMove` reads `e7e8=Q` and preserves it in `HistoryMove.promotionPiece` - Board state after parsed promotion uses promoted piece for subsequent move resolution - [ ] Promotion with capture works - `completePromotion captures opponent piece` test - `processMove detects pawn capturing to back rank as PromotionRequired with captured piece` test - [ ] **Step 5: Final commit** ```bash git add . git commit -m "chore: verify NCS-10 pawn promotion implementation complete" ```