diff --git a/docs/superpowers/plans/2026-03-29-pawn-promotion.md b/docs/superpowers/plans/2026-03-29-pawn-promotion.md index 0c73f87..b96f685 100644 --- a/docs/superpowers/plans/2026-03-29-pawn-promotion.md +++ b/docs/superpowers/plans/2026-03-29-pawn-promotion.md @@ -2,112 +2,39 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Implement pawn promotion as a two-step interaction: detect pawn reaching back rank, prompt player to choose a promotion piece, record the choice in game history. +**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:** Move validation detects promotion moves early. The game loop pauses and prompts for a piece choice. Once chosen, `completePromotion()` applies the move and records it with the promotion piece. PGN export/import preserves promotion notation. +**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, Quarkus (core module is non-Quarkus), scoverage for coverage, FEN for test board setup. +**Tech Stack:** Scala 3, core module (no Quarkus), ui module, scoverage for coverage, FEN for test board setup. --- -### Task 1: Extend GameHistory.Move to include promotionPiece +## Architecture Map -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` +| 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 | -- [ ] **Step 1: Write failing test for promotionPiece field** - -Open `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` and add: - -```scala -test("Move with promotion records the promotion piece") { - val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)) - move.promotionPiece should be (Some(PromotionPiece.Queen)) -} - -test("Normal move has no promotion piece") { - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None) - move.promotionPiece should be (None) -} - -test("addMove with promotion stores promotionPiece") { - val history = GameHistory.empty - val newHistory = history.addMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)) - newHistory.moves should have length 1 - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook)) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v -``` - -Expected: FAIL with "not a member of Move" or similar (field doesn't exist yet). - -- [ ] **Step 3: Extend Move case class with promotionPiece** - -In `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala`, update the `Move` case class: - -```scala -case class Move( - from: Square, - to: Square, - castleSide: Option[CastleSide], - promotionPiece: Option[PromotionPiece] = None -) -``` - -Also add the import at the top of the file: - -```scala -import de.nowchess.api.move.PromotionPiece -``` - -- [ ] **Step 4: Update addMove() to accept promotionPiece parameter** - -In `GameHistory` case class, locate the `addMove` method and update it: - -```scala -def addMove( - from: Square, - to: Square, - castleSide: Option[CastleSide] = None, - promotionPiece: Option[PromotionPiece] = None -): GameHistory = - val newMove = Move(from, to, castleSide, promotionPiece) - copy(moves = moves :+ newMove) -``` - -- [ ] **Step 5: Run test to verify it passes** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala \ - modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala && \ -git commit -m "feat: extend GameHistory.Move to track promotionPiece" -``` +> **Task 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() detection to MoveValidator +### 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 for isPromotionMove()** +- [ ] **Step 1: Write failing tests** -Open `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` and add: +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") { @@ -139,170 +66,165 @@ test("Non-pawn piece is never a promotion move") { val board = FenParser.parse(fen).board MoveValidator.isPromotionMove(board, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false) } - -test("Pawn on R7 moving backward is not a promotion move") { - val fen = "8/4P3/8/8/8/8/8/8 w - - 0 1" - val board = FenParser.parse(fen).board - MoveValidator.isPromotionMove(board, Square(File.E, Rank.R7), Square(File.E, Rank.R5)) should be (false) -} ``` - [ ] **Step 2: Run test to verify it fails** ```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20 ``` -Expected: FAIL with "method isPromotionMove not found". +Expected: FAIL — `isPromotionMove` not found. - [ ] **Step 3: Implement isPromotionMove()** -Add to `MoveValidator` object in `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`: +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)) => - val toRank = to.rank - (from.rank == Rank.R7 && toRank == Rank.R8) || // White pawn to R8 - (from.rank == Rank.R2 && toRank == Rank.R1) // Black pawn to R1 + (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" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -10 ``` -Expected: PASS. +Expected: All MoveValidatorTest tests pass. -- [ ] **Step 5: Run full core test suite to ensure no regressions** - -```bash -./gradlew :modules:core:test -v -``` - -Expected: All tests pass. - -- [ ] **Step 6: Commit** +- [ ] **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 && \ + modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala git commit -m "feat: add isPromotionMove detection to MoveValidator" ``` --- -### Task 3: Add PromotionRequired to MoveResult ADT and detect in processMove() +### 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: Add PromotionRequired case to MoveResult** - -In `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`, add to the `MoveResult` sealed trait (after `IllegalMove` and before `Moved`): - -```scala -case class PromotionRequired( - from: Square, - to: Square, - newBoard: Board, - newHistory: GameHistory, - captured: Option[Piece], - newTurn: Color -) extends MoveResult -``` - -- [ ] **Step 2: Write failing tests for promotion detection in processMove()** +- [ ] **Step 1: Write failing tests** Open `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` and add: ```scala -test("processMove detects pawn reaching R8 and returns PromotionRequired") { +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 history = GameHistory.empty - - val result = GameController.processMove(board, history, Color.White, "e7e8") - + val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") result should matchPattern { case _: MoveResult.PromotionRequired => } result match - case MoveResult.PromotionRequired(from, to, _, _, _, newTurn) => + case MoveResult.PromotionRequired(from, to, _, _, _, turn) => from should be (Square(File.E, Rank.R7)) - to should be (Square(File.E, Rank.R8)) - newTurn should be (Color.Black) + to should be (Square(File.E, Rank.R8)) + turn should be (Color.White) case _ => fail("Expected PromotionRequired") } -test("processMove detects pawn capturing to R8 and returns PromotionRequired") { - val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1" - val board = FenParser.parse(fen).board - val history = GameHistory.empty - - val result = GameController.processMove(board, history, Color.White, "e7d8") - - result should matchPattern { case _: MoveResult.PromotionRequired => } - result match - case MoveResult.PromotionRequired(from, to, _, _, captured, _) => - captured should be (Some(Piece(Color.Black, PieceType.Queen))) - case _ => fail("Expected PromotionRequired with captured piece") -} - test("processMove detects black pawn reaching R1 and returns PromotionRequired") { val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - - val result = GameController.processMove(board, history, Color.Black, "e2e1") - + 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") +} ``` -- [ ] **Step 3: Run test to verify it fails** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v -``` - -Expected: FAIL. - -- [ ] **Step 4: Modify processMove() to detect promotion moves** - -In `GameController.processMove()`, after checking `WrongColor` and before `IllegalMove` check, insert promotion detection. The full function should look like this (with promotion logic added in the inner match): +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 -def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = - raw.trim match - case "quit" | "q" => - MoveResult.Quit - case trimmed => - Parser.parseMove(trimmed) match - case None => - MoveResult.InvalidFormat(trimmed) - case Some((from, to)) => - board.pieceAt(from) match - case None => - MoveResult.NoPiece - case Some(piece) if piece.color != turn => - MoveResult.WrongColor - case Some(_) => if !MoveValidator.isLegal(board, history, from, to) then MoveResult.IllegalMove else if MoveValidator.isPromotionMove(board, from, to) then - // Pawn reaching back rank: return board state unchanged (pawn still on source) val captured = board.pieceAt(to) - MoveResult.PromotionRequired(from, to, board, history, captured, turn.opposite) + 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 => board.withMove(from, to) + 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) @@ -314,15 +236,15 @@ def processMove(board: Board, history: GameHistory, turn: Color, raw: String): M - [ ] **Step 5: Run test to verify it passes** ```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10 ``` -Expected: PASS. +Expected: All GameControllerTest tests pass. -- [ ] **Step 6: Run full core tests to ensure no regressions** +- [ ] **Step 6: Run full core tests to check no regressions** ```bash -./gradlew :modules:core:test -v +./gradlew :modules:core:test 2>&1 | tail -15 ``` Expected: All tests pass. @@ -331,35 +253,31 @@ Expected: All tests pass. ```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 && \ + modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala git commit -m "feat: add PromotionRequired to MoveResult and detect promotion in processMove" ``` --- -### Task 4: Implement completePromotion() function +### 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 for completePromotion()** +- [ ] **Step 1: Write failing tests** Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`: ```scala -test("completePromotion applies pawn move and places promoted queen") { +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 history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), - PromotionPiece.Queen, - Color.White + PromotionPiece.Queen, Color.White ) - result should matchPattern { case _: MoveResult.Moved => } result match case MoveResult.Moved(newBoard, newHistory, _, _) => @@ -373,15 +291,11 @@ test("completePromotion applies pawn move and places promoted queen") { test("completePromotion with rook underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), - PromotionPiece.Rook, - Color.White + 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))) @@ -392,15 +306,11 @@ test("completePromotion with rook underpromotion") { test("completePromotion with bishop underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), - PromotionPiece.Bishop, - Color.White + 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))) @@ -411,15 +321,11 @@ test("completePromotion with bishop underpromotion") { test("completePromotion with knight underpromotion") { val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), - PromotionPiece.Knight, - Color.White + 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))) @@ -427,20 +333,16 @@ test("completePromotion with knight underpromotion") { case _ => fail("Expected Moved with Knight") } -test("completePromotion with capture") { +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 history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.D, Rank.R8), - PromotionPiece.Queen, - Color.White + PromotionPiece.Queen, Color.White ) - result match - case MoveResult.Moved(newBoard, newHistory, captured, _) => + 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") @@ -449,15 +351,11 @@ test("completePromotion with capture") { test("completePromotion for black pawn to R1") { val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R2), Square(File.E, Rank.R1), - PromotionPiece.Knight, - Color.Black + 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))) @@ -468,123 +366,22 @@ test("completePromotion for black pawn to R1") { test("completePromotion evaluates check after promotion") { val fen = "3k4/4P3/8/8/8/8/8/8 w - - 0 1" val board = FenParser.parse(fen).board - val history = GameHistory.empty - - // Promote to Queen on e8 gives check to black king on d8 val result = GameController.completePromotion( - board, history, + board, GameHistory.empty, Square(File.E, Rank.R7), Square(File.E, Rank.R8), - PromotionPiece.Queen, - Color.White + PromotionPiece.Queen, Color.White ) - result should matchPattern { case _: MoveResult.MovedInCheck => } } -``` -- [ ] **Step 2: Run test to verify it fails** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v -``` - -Expected: FAIL with "method completePromotion not found". - -- [ ] **Step 3: Implement completePromotion()** - -Add to the `GameController` object in `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`: - -```scala - def completePromotion( - board: Board, - history: GameHistory, - from: Square, - to: Square, - piece: PromotionPiece, - turn: Color - ): MoveResult = - // Apply the pawn move - val (boardAfterMove, captured) = board.withMove(from, to) - - // Convert PromotionPiece to PieceType - val promotedPieceType = piece match - case PromotionPiece.Queen => PieceType.Queen - case PromotionPiece.Rook => PieceType.Rook - case PromotionPiece.Bishop => PieceType.Bishop - case PromotionPiece.Knight => PieceType.Knight - - // Replace the pawn with the promoted piece - val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType)) - - // Record the move with the promotion piece - val newHistory = history.addMove(from, to, None, Some(piece)) - - // Evaluate game status - GameRules.gameStatus(newBoard, newHistory, turn.opposite) match - case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) - case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) - case PositionStatus.Mated => MoveResult.Checkmate(turn) - case PositionStatus.Drawn => MoveResult.Stalemate -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v -``` - -Expected: PASS. - -- [ ] **Step 5: Run full core tests** - -```bash -./gradlew :modules:core:test -v -``` - -Expected: All tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \ - modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala && \ -git commit -m "feat: add completePromotion to finalize promotion moves" -``` - ---- - -### Task 5: Update gameLoop to handle PromotionRequired - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` (integration test) - -- [ ] **Step 1: Write integration test for gameLoop with promotion** - -Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`: - -```scala -test("gameLoop integration: e7e8 followed by q returns queen on e8") { - // Test that promotion interaction works end-to-end +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 - val history = GameHistory.empty - - // Step 1: Player enters e7e8 (pawn promotion) - val result1 = GameController.processMove(board, history, Color.White, "e7e8") - result1 should matchPattern { case _: MoveResult.PromotionRequired => } - - // Step 2: Extract from/to, then player enters q for queen - result1 match - case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, _, turn) => - val result2 = GameController.completePromotion( - boardBeforePromotion, histBeforePromotion, - from, to, - PromotionPiece.Queen, - Color.White - ) - result2 should matchPattern { case _: MoveResult.Moved => } - result2 match + 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)) @@ -593,210 +390,751 @@ test("gameLoop integration: e7e8 followed by q returns queen on e8") { } ``` -- [ ] **Step 2: Run test to verify it passes** - -```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v -``` - -Expected: PASS (completePromotion is already implemented). - -- [ ] **Step 3: Update gameLoop to handle PromotionRequired** - -In `GameController.gameLoop()`, add a new case to the match statement after `IllegalMove`: +You will need to add this import to `GameControllerTest.scala`: ```scala - case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, currentTurn) => - println("Promote to: (q/r/b/n)? ") - val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase - val piece: Option[PromotionPiece] = pieceInput match - case "q" => Some(PromotionPiece.Queen) - case "r" => Some(PromotionPiece.Rook) - case "b" => Some(PromotionPiece.Bishop) - case "n" => Some(PromotionPiece.Knight) - case _ => None - - piece match - case None => - println("Invalid choice. Enter q, r, b, or n.") - gameLoop(board, history, turn) // retry promotion choice - case Some(p) => - completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, Color.White) match - case MoveResult.Moved(newBoard, newHistory, _, newTurn) => - if captured.isDefined then - println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}") - else - println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) => - if captured.isDefined then - println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}") - else - println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}") - println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.Checkmate(winner) => - println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, GameHistory.empty, Color.White) - case MoveResult.Stalemate => - println("Stalemate! The game is a draw.") - gameLoop(Board.initial, GameHistory.empty, Color.White) - case _ => - // should not happen - println("Error completing promotion.") - gameLoop(board, history, turn) -``` - -Wait, there's an issue here. The `PromotionRequired` returns `Color.White` as `turn`, but inside the match `turn` variable refers to the loop's current turn. Let me fix: - -```scala - case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, _) => - println("Promote to: (q/r/b/n)? ") - val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase - val piece: Option[PromotionPiece] = pieceInput match - case "q" => Some(PromotionPiece.Queen) - case "r" => Some(PromotionPiece.Rook) - case "b" => Some(PromotionPiece.Bishop) - case "n" => Some(PromotionPiece.Knight) - case _ => None - - piece match - case None => - println("Invalid choice. Enter q, r, b, or n.") - gameLoop(board, history, turn) // retry promotion choice - case Some(p) => - completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, turn) match - case MoveResult.Moved(newBoard, newHistory, _, newTurn) => - if captured.isDefined then - println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}") - else - println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) => - if captured.isDefined then - println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}") - else - println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}") - println(s"${newTurn.label} is in check!") - gameLoop(newBoard, newHistory, newTurn) - case MoveResult.Checkmate(winner) => - println(s"Checkmate! ${winner.label} wins.") - gameLoop(Board.initial, GameHistory.empty, Color.White) - case MoveResult.Stalemate => - println("Stalemate! The game is a draw.") - gameLoop(Board.initial, GameHistory.empty, Color.White) - case _ => - println("Error completing promotion.") - gameLoop(board, history, turn) -``` - -- [ ] **Step 4: Run full core tests** - -```bash -./gradlew :modules:core:test -v -``` - -Expected: All tests pass. - -- [ ] **Step 5: Compile check** - -```bash -./gradlew :modules:core:build -v -``` - -Expected: Build succeeds. - -- [ ] **Step 6: Commit** - -```bash -git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala && \ -git commit -m "feat: update gameLoop to prompt for promotion piece and complete promotion" -``` - ---- - -### Task 6: Add PGN export support for promotions - -**Files:** -- Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` -- Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` - -- [ ] **Step 1: Write failing tests for promotion notation export** - -Open `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` and add: - -```scala -test("moveToSan exports promotion notation with Queen") { - val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)) - PgnExporter.moveToSan(move) should be ("e7e8=Q") -} - -test("moveToSan exports promotion notation with Rook") { - val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)) - PgnExporter.moveToSan(move) should be ("e7e8=R") -} - -test("moveToSan exports promotion notation with Bishop") { - val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)) - PgnExporter.moveToSan(move) should be ("e7e8=B") -} - -test("moveToSan exports promotion notation with Knight") { - val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)) - PgnExporter.moveToSan(move) should be ("e7e8=N") -} - -test("moveToSan exports normal move without promotion") { - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None) - PgnExporter.moveToSan(move) should be ("e2e4") -} - -test("moveToSan exports capture without promotion") { - val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), None, None) - PgnExporter.moveToSan(move) should be ("e5d6") -} +import de.nowchess.api.move.PromotionPiece ``` - [ ] **Step 2: Run test to verify it fails** ```bash -./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20 ``` -Expected: FAIL (moveToSan doesn't handle promotion yet). +Expected: FAIL — `completePromotion` not found. -- [ ] **Step 3: Implement promotion export in PgnExporter** +- [ ] **Step 3: Implement completePromotion()** -Open `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` and update the `moveToSan` function: +Add to `GameController` object in `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`: ```scala -def moveToSan(move: Move): String = - val baseMove = s"${move.from}${move.to}" - move.promotionPiece match - case Some(piece) => - val pieceLetter = piece match - case PromotionPiece.Queen => "Q" - case PromotionPiece.Rook => "R" - case PromotionPiece.Bishop => "B" - case PromotionPiece.Knight => "N" - s"$baseMove=$pieceLetter" - case None => baseMove + /** 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 ``` -(Assuming `moveToSan` currently exists; if not, create it as shown above.) +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.notation.PgnExporterTest" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10 ``` -Expected: PASS. +Expected: All GameControllerTest tests pass. - [ ] **Step 5: Run full core tests** ```bash -./gradlew :modules:core:test -v +./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. @@ -805,103 +1143,158 @@ Expected: All tests pass. ```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" + 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 7: Add PGN import support for promotions +### 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 for promotion notation parsing** +- [ ] **Step 1: Write failing tests** -Open `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala` and add: +Add to `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala`: ```scala -test("parseMove parses promotion to Queen") { - val result = PgnParser.parseMove("e7e8=Q") - result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen)))) +test("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("parseMove parses promotion to Rook") { - val result = PgnParser.parseMove("e7e8=R") - result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Rook)))) +test("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("parseMove parses promotion to Bishop") { - val result = PgnParser.parseMove("e7e8=B") - result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop)))) +test("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("parseMove parses promotion to Knight") { - val result = PgnParser.parseMove("e7e8=N") - result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight)))) +test("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("parseMove parses normal move without promotion") { - val result = PgnParser.parseMove("e2e4") - result should be (Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))) -} +test("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"] -test("parseMove rejects invalid promotion piece") { - val result = PgnParser.parseMove("e7e8=X") - result should be (None) +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)) } ``` -Note: Update the return type of `parseMove` to include `Option[PromotionPiece]`: +Add imports at the top of `PgnParserTest.scala`: ```scala -def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])] +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" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -20 ``` -Expected: FAIL. +Expected: FAIL — `promotionPiece` is always `None` since PgnParser strips `=[NBRQ]` but doesn't record it. -- [ ] **Step 3: Implement promotion parsing in PgnParser** +- [ ] **Step 3: Update PgnParser to preserve promotion piece** -Open `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala` and update (or create) the `parseMove` function: +In `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala`: + +1. Add imports at the top: ```scala -def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])] = - val pattern = """^([a-h])([1-8])([a-h])([1-8])(?:=([QRBN]))?$""".r - moveStr.trim match - case pattern(fromFile, fromRank, toFile, toRank, promotionPiece) => - val from = Square(File.fromChar(fromFile.head), Rank.fromOrdinal(fromRank.toInt - 1)) - val to = Square(File.fromChar(toFile.head), Rank.fromOrdinal(toRank.toInt - 1)) - val promotion = Option(promotionPiece).flatMap { p => - p match - case "Q" => Some(PromotionPiece.Queen) - case "R" => Some(PromotionPiece.Rook) - case "B" => Some(PromotionPiece.Bishop) - case "N" => Some(PromotionPiece.Knight) - case _ => None - } - Some((from, to, promotion)) - case _ => None +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" -v +./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -10 ``` -Expected: PASS. +Expected: All PgnParserTest tests pass. - [ ] **Step 5: Run full core tests** ```bash -./gradlew :modules:core:test -v +./gradlew :modules:core:test 2>&1 | tail -10 ``` Expected: All tests pass. @@ -910,121 +1303,64 @@ Expected: All tests pass. ```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 && \ + modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala git commit -m "feat: add PGN import support for pawn promotion notation" ``` --- -### Task 8: Final integration and coverage verification +### Task 10: Final integration and coverage verification -**Files:** -- All modified files from previous tasks -- Coverage report +**Files:** All modified files from previous tasks. -- [ ] **Step 1: Run full test suite** +- [ ] **Step 1: Full build** ```bash -./gradlew :modules:core:test -v +./gradlew build 2>&1 | tail -20 ``` -Expected: All tests pass, 100% line coverage for new code. +Expected: BUILD SUCCESSFUL across all modules. -- [ ] **Step 2: Check coverage gaps** +- [ ] **Step 2: Check coverage gaps for core module** ```bash -./gradlew :modules:core:test && \ +./gradlew :modules:core:scoverageTest && \ python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml ``` -Expected: No uncovered lines in new code. +Expected: No uncovered lines in newly added code. -- [ ] **Step 3: Build all modules** +- [ ] **Step 3: Check coverage gaps for ui module** ```bash -./gradlew build -v +./gradlew :modules:ui:scoverageTest && \ +python jacoco-reporter/scoverage_coverage_gaps.py modules/ui/build/reports/scoverageTest/scoverage.xml ``` -Expected: Full build passes. +Expected: No uncovered lines in `TerminalUI` promotion paths. -- [ ] **Step 4: Manual gameplay test (optional)** - -Run the application and test promotion manually: - -```bash -./gradlew :modules:core:run -``` - -At the prompt, set up a promotion scenario and verify: -- Move pawn to back rank (e.g. `e7e8`) -- System prompts `"Promote to: (q/r/b/n)?"` -- Enter choice (e.g. `q`) -- Pawn is replaced with chosen piece -- Game continues normally - -- [ ] **Step 5: Final build and test** - -```bash -./gradlew build -v -``` - -Expected: Full green build. - -- [ ] **Step 6: Verify requirements from NCS-10 DoD** +- [ ] **Step 4: Verify NCS-10 requirements** Check each requirement is satisfied: -- [x] Promotion is mandatory — move is not completed until piece is chosen - - `processMove` returns `PromotionRequired`, blocking move until `completePromotion` is called +- [ ] 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 -- [x] All four promotion targets are selectable - - Tests cover Queen, Rook, Bishop, Knight - - `gameLoop` accepts `q`, `r`, `b`, `n` - -- [x] Underpromotion works correctly - - Tests verify promotion to non-Queen pieces - -- [x] PGN notation records the promotion piece - - `moveToSan` exports `e8=Q` format - - `parseMove` parses `e8=Q` format - -- [x] Tests cover promotion to each piece, capture + promotion, underpromotion - - `MoveValidatorTest`: promotion detection - - `GameControllerTest`: complete promotion flow, each piece type, captures, underpromotion - - `PgnExporterTest`: notation export - - `PgnParserTest`: notation parsing - -- [ ] **Step 7: Commit final integration verification** +- [ ] **Step 5: Final commit** ```bash -git log --oneline -8 +git add . +git commit -m "chore: verify NCS-10 pawn promotion implementation complete" ``` - -Verify the 7 commits for promotion tasks are present. - ---- - -## Implementation Summary - -| Task | Component | Changes | -|------|-----------|---------| -| 1 | GameHistory | Extended `Move` with `promotionPiece: Option[PromotionPiece]` | -| 2 | MoveValidator | Added `isPromotionMove()` detection | -| 3 | GameController | Added `PromotionRequired` result, detection in `processMove()` | -| 4 | GameController | Added `completePromotion()` to apply move and record promotion | -| 5 | GameController | Updated `gameLoop()` to prompt and handle promotion | -| 6 | PgnExporter | Export promotion notation (e.g. `e8=Q`) | -| 7 | PgnParser | Parse promotion notation (e.g. `e8=Q`) | -| 8 | Integration | Full test suite passes, 100% coverage, requirements verified | - ---- - -Plan complete and saved to `docs/superpowers/plans/2026-03-29-pawn-promotion.md`. - -**Two execution options:** - -**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints - -Which approach? \ No newline at end of file diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 0de5794..8ff35cf 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -437,3 +437,30 @@ class GameControllerTest extends AnyFunSuite with Matchers: finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) case _ => fail("Expected Moved") case _ => fail("Expected PromotionRequired") + + test("completePromotion results in checkmate when promotion delivers checkmate"): + // Black king a8, white pawn h7, white king b6. + // After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check; + // a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape. + val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.H, Rank.R7), sq(File.H, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result should matchPattern { case MoveResult.Checkmate(_) => } + result match + case MoveResult.Checkmate(winner) => winner should be (Color.White) + case _ => fail("Expected Checkmate") + + test("completePromotion results in stalemate when promotion stalemates opponent"): + // Black king a8, white pawn b7, white bishop c7, white king b6. + // After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6; + // b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves. + val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.B, Rank.R7), sq(File.B, Rank.R8), + PromotionPiece.Knight, Color.White + ) + result should be (MoveResult.Stalemate) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 153918f..292e618 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -89,3 +89,62 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true) } + + test("completePromotion results in Moved when promotion doesn't give check") { + // White pawn on e7, black king on a2 (far away, not in check after promotion) + val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get + val engine = new GameEngine(initialBoard = board) + 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))) + events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) + } + + test("completePromotion results in Checkmate when promotion delivers checkmate") { + // Black king on a8, white king on b6, white pawn on h7 + // h7->h8=Q delivers checkmate + val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = board) + val events = captureEvents(engine) + + engine.processUserInput("h7h8") + engine.completePromotion(PromotionPiece.Queen) + + engine.isPendingPromotion should be (false) + events.exists(_.isInstanceOf[CheckmateEvent]) should be (true) + } + + test("completePromotion results in Stalemate when promotion creates stalemate") { + // Black king on a8, white pawn on b7, white bishop on c7, white king on b6 + // b7->b8=N: no check; Ka8 has no legal moves -> stalemate + val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = board) + val events = captureEvents(engine) + + engine.processUserInput("b7b8") + engine.completePromotion(PromotionPiece.Knight) + + engine.isPendingPromotion should be (false) + events.exists(_.isInstanceOf[StalemateEvent]) should be (true) + } + + test("completePromotion with black pawn promotion results in Moved") { + // Black pawn e2, white king h3 (not on rank 1 or file e), black king a8 + // e2->e1=Q: queen on e1 does not attack h3 -> normal Moved + val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get + val engine = new GameEngine(initialBoard = board) + val events = captureEvents(engine) + + engine.processUserInput("e2e1") + engine.completePromotion(PromotionPiece.Queen) + + engine.isPendingPromotion should be (false) + engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen))) + events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) + }