diff --git a/docs/superpowers/plans/2026-03-29-pawn-promotion.md b/docs/superpowers/plans/2026-03-29-pawn-promotion.md deleted file mode 100644 index b96f685..0000000 --- a/docs/superpowers/plans/2026-03-29-pawn-promotion.md +++ /dev/null @@ -1,1366 +0,0 @@ -# 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" -``` diff --git a/docs/superpowers/specs/2026-03-29-pawn-promotion-design.md b/docs/superpowers/specs/2026-03-29-pawn-promotion-design.md deleted file mode 100644 index 3fa4c53..0000000 --- a/docs/superpowers/specs/2026-03-29-pawn-promotion-design.md +++ /dev/null @@ -1,243 +0,0 @@ -# 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). diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala index cdff8e5..1a2b170 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala @@ -42,26 +42,30 @@ object PgnParser: if isMoveNumberOrResult(token) then state else parseAlgebraicMove(token, board, history, color) match - case None => state // unrecognised token — skip silently + case None => state // unrecognised token — skip silently case Some(move) => - 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 + val newBoard = applyMoveToBoard(board, move, color) val newHistory = history.addMove(move) (newBoard, newHistory, color.opposite, acc :+ move) moves + /** Apply a single HistoryMove to a Board, handling castling and promotion. */ + private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board = + 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 + /** True for move-number tokens ("1.", "12.") and PGN result tokens. */ private def isMoveNumberOrResult(token: String): Boolean = token.matches("""\d+\.""") ||