51 KiB
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 commit0800c3aand tests already exist inGameHistoryTest.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:
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
./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):
/** 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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -10
Expected: All MoveValidatorTest tests pass.
- Step 5: Commit
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:
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:
import de.nowchess.chess.notation.FenParser
- Step 2: Run test to verify it fails
./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:
import de.nowchess.api.move.PromotionPiece
Add to MoveResult object:
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:
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:
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
./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
./gradlew :modules:core:test 2>&1 | tail -15
Expected: All tests pass.
- Step 7: Commit
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:
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:
import de.nowchess.api.move.PromotionPiece
- Step 2: Run test to verify it fails
./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:
/** 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:
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
- Step 4: Run test to verify it passes
./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
./gradlew :modules:core:test 2>&1 | tail -10
Expected: All tests pass.
- Step 6: Commit
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):
/** 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:
import de.nowchess.api.board.{Board, Color, Square}
- Step 2: Run full core test suite to ensure nothing broke
./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
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:
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
./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:
- Change the class definition to accept optional initial state:
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()
- Add
PromotionPieceimport at the top (with existing imports):
import de.nowchess.api.move.PromotionPiece
- Add after the
commandHistorydef:
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
- Add private state and inner class before
// Synchronized accessors:
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:
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:
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:
/** 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
./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
./gradlew :modules:core:test 2>&1 | tail -10
Expected: All tests pass.
- Step 8: Commit
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:
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:
import de.nowchess.chess.observer.PromotionRequiredEvent
import de.nowchess.api.board.{File, Rank, Square}
- Step 2: Run test to verify it fails
./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:
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
./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
./gradlew :modules:core:test :modules:ui:test 2>&1 | tail -15
Expected: All tests pass.
- Step 6: Commit
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:
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:
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.board.{Rank}
- Step 2: Run test to verify it fails
./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:
- Add import after existing imports:
import de.nowchess.api.move.PromotionPiece
- Replace the existing
moveToAlgebraicmethod:
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
./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
./gradlew :modules:core:test 2>&1 | tail -10
Expected: All tests pass.
- Step 6: Commit
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:
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:
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
./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:
- Add imports at the top:
import de.nowchess.api.move.PromotionPiece
- Add a private helper method to extract promotion piece from notation:
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
}
- In
parseRegularMove, change the last line from:
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))
to:
val promotion = extractPromotion(notation)
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
- In
parseMovesText, update the board-state update after a move to apply the promoted piece. Replace:
val newBoard = move.castleSide match
case Some(side) => board.withCastle(color, side)
case None => board.withMove(move.from, move.to)._1
with:
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
./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
./gradlew :modules:core:test 2>&1 | tail -10
Expected: All tests pass.
- Step 6: Commit
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
./gradlew build 2>&1 | tail -20
Expected: BUILD SUCCESSFUL across all modules.
- Step 2: Check coverage gaps for core module
./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
./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
processMovereturnsPromotionRequired, move not recorded untilcompletePromotioncalled
-
All four promotion targets are selectable (Queen, Rook, Bishop, Knight)
- Tests:
completePromotion with rook/bishop/knight underpromotion - UI: TerminalUI accepts
q,r,b,n
- Tests:
-
Underpromotion works correctly
- Tests cover non-Queen piece promotion in both GameController and GameEngine
-
PGN notation records the promotion piece
moveToAlgebraicexportse7e8=QformatparseAlgebraicMovereadse7e8=Qand preserves it inHistoryMove.promotionPiece- Board state after parsed promotion uses promoted piece for subsequent move resolution
-
Promotion with capture works
completePromotion captures opponent piecetestprocessMove detects pawn capturing to back rank as PromotionRequired with captured piecetest
-
Step 5: Final commit
git add .
git commit -m "chore: verify NCS-10 pawn promotion implementation complete"