From 13bfc16cfe25db78ec607db523ca6d993c13430c Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 31 Mar 2026 22:18:14 +0200 Subject: [PATCH] feat: NCS-10 Implement Pawn Promotion (#12) Reviewed-on: https://git.janis-eccarius.de/NowChess/NowChessSystems/pulls/12 Reviewed-by: Leon Hermann Co-authored-by: Janis Co-committed-by: Janis --- .claude/agents/scala-implementer.md | 3 +- .claude/agents/test-writer.md | 3 +- .idea/AndroidProjectSystem.xml | 6 + .idea/misc.xml | 1 - .idea/vcs.xml | 10 + docs/unresolved.md | 20 ++ .../chess/controller/GameController.scala | 102 +++++++---- .../de/nowchess/chess/engine/GameEngine.scala | 85 ++++++++- .../de/nowchess/chess/logic/GameHistory.scala | 13 +- .../nowchess/chess/logic/MoveValidator.scala | 8 + .../nowchess/chess/notation/PgnExporter.scala | 10 +- .../nowchess/chess/notation/PgnParser.scala | 47 +++-- .../de/nowchess/chess/observer/Observer.scala | 11 +- .../chess/controller/GameControllerTest.scala | 171 ++++++++++++++++++ .../engine/GameEnginePromotionTest.scala | 167 +++++++++++++++++ .../chess/logic/GameHistoryTest.scala | 30 +++ .../chess/logic/MoveValidatorTest.scala | 23 +++ .../chess/notation/PgnExporterTest.scala | 37 ++++ .../chess/notation/PgnParserTest.scala | 117 ++++++++++++ .../de/nowchess/ui/terminal/TerminalUI.scala | 35 +++- .../nowchess/ui/terminal/TerminalUITest.scala | 140 +++++++++++++- 21 files changed, 973 insertions(+), 66 deletions(-) create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala diff --git a/.claude/agents/scala-implementer.md b/.claude/agents/scala-implementer.md index 845c9e7..bfba4c0 100644 --- a/.claude/agents/scala-implementer.md +++ b/.claude/agents/scala-implementer.md @@ -2,9 +2,10 @@ name: scala-implementer description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence" tools: Read, Write, Edit, Bash, Glob -model: sonnet +model: inherit color: pink --- + You do not have permissions to write tests, just source code. You are a Scala 3 expert specialising in Quarkus microservices. Always read the relevant /docs/api/ file before implementing. diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 16be2bf..93668f2 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -2,9 +2,10 @@ name: test-writer description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished." tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit -model: sonnet +model: haiku color: purple --- + You do not have permissions to modify the source code, just write tests. You write tests for Scala 3 + Quarkus services. diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 32cf4db..d799c3d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 7ddfc9e..d72e5a2 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -6,6 +6,16 @@ + + + diff --git a/docs/unresolved.md b/docs/unresolved.md index e69de29..71ccda8 100644 --- a/docs/unresolved.md +++ b/docs/unresolved.md @@ -0,0 +1,20 @@ +## [2026-03-31] Unreachable code blocking 100% statement coverage + +**Requirement/Bug:** Reach 100% statement coverage in core module. + +**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code: +1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute +2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable +3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code + +**Attempted Fixes:** +1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4% +2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece) +3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓ +4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4 +5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults + +**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns: +- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex +- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature +- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 120b9e9..0542df6 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.controller -import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} +import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.* // --------------------------------------------------------------------------- @@ -14,6 +15,14 @@ object 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 @@ -30,37 +39,64 @@ object GameController: */ def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = raw.trim match - case "quit" | "q" => - MoveResult.Quit + 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 - 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 + case None => MoveResult.InvalidFormat(trimmed) + case Some((from, to)) => validateAndApply(board, history, turn, from, to) + + /** 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)) + toMoveResult(newBoard, newHistory, captured, turn) + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult = + 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 + MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn) + else applyNormalMove(board, history, turn, from, to) + + private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult = + val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to)) + 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) + toMoveResult(newBoard, newHistory, captured, turn) + + private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult = + 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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 59aafe4..8b6508f 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, Piece, Square} +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus} import de.nowchess.chess.controller.{GameController, Parser, MoveResult} import de.nowchess.chess.observer.* @@ -11,12 +12,31 @@ import de.nowchess.chess.command.{CommandInvoker, MoveCommand} * All user interactions must go through this engine via Commands, and all state changes * are communicated to observers via GameEvent notifications. */ -class GameEngine extends Observable: - private var currentBoard: Board = Board.initial - private var currentHistory: GameHistory = GameHistory.empty - private var currentTurn: Color = Color.White +class GameEngine( + initialBoard: Board = Board.initial, + initialHistory: GameHistory = GameHistory.empty, + initialTurn: Color = Color.White, + completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult = + GameController.completePromotion +) extends Observable: + private var currentBoard: Board = initialBoard + private var currentHistory: GameHistory = initialHistory + private var currentTurn: Color = initialTurn private val invoker = new CommandInvoker() + /** Inner class for tracking pending promotion state */ + private case class PendingPromotion( + from: Square, to: Square, + boardBefore: Board, historyBefore: GameHistory, + turn: Color + ) + + /** Current pending promotion, if any */ + private var pendingPromotion: Option[PendingPromotion] = None + + /** True if a pawn promotion move is pending and needs a piece choice. */ + def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } + // Synchronized accessors for current state def board: Board = synchronized { currentBoard } def history: GameHistory = synchronized { currentHistory } @@ -115,6 +135,10 @@ class GameEngine extends Observable: currentHistory = GameHistory.empty currentTurn = Color.White notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) + + case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => + pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) + notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo)) } /** Undo the last move. */ @@ -127,6 +151,59 @@ class GameEngine extends Observable: performRedo() } + /** 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) + ) + completePromotionFn( + 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.")) + } + /** Reset the board to initial position. */ def reset(): Unit = synchronized { currentBoard = Board.initial diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala index 1cea5cd..80011fe 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala @@ -1,12 +1,14 @@ package de.nowchess.chess.logic import de.nowchess.api.board.Square +import de.nowchess.api.move.PromotionPiece /** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */ case class HistoryMove( from: Square, to: Square, - castleSide: Option[CastleSide] + castleSide: Option[CastleSide], + promotionPiece: Option[PromotionPiece] = None ) /** Complete game history: ordered list of moves. */ @@ -17,8 +19,13 @@ case class GameHistory(moves: List[HistoryMove] = List.empty): def addMove(from: Square, to: Square): GameHistory = addMove(HistoryMove(from, to, None)) - def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory = - addMove(HistoryMove(from, to, castleSide)) + def addMove( + from: Square, + to: Square, + castleSide: Option[CastleSide] = None, + promotionPiece: Option[PromotionPiece] = None + ): GameHistory = + addMove(HistoryMove(from, to, castleSide, promotionPiece)) object GameHistory: val empty: GameHistory = GameHistory() diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala index 22a8eee..f33d470 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala @@ -173,3 +173,11 @@ object MoveValidator: def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = legalTargets(board, history, from).contains(to) + + /** 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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala index 8eb4d1c..a7f6449 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove} object PgnExporter: @@ -32,4 +33,11 @@ object PgnExporter: move.castleSide match case Some(CastleSide.Kingside) => "O-O" case Some(CastleSide.Queenside) => "O-O-O" - case None => s"${move.from}${move.to}" + 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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala index a362daf..1a2b170 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle} /** A parsed PGN game containing headers and the resolved move list. */ @@ -41,16 +42,30 @@ object PgnParser: if isMoveNumberOrResult(token) then state else parseAlgebraicMove(token, board, history, color) match - case None => state // unrecognised token — skip silently + case None => state // unrecognised token — skip silently case Some(move) => - val newBoard = move.castleSide match - case Some(side) => board.withCastle(color, side) - case None => board.withMove(move.from, move.to)._1 + val newBoard = applyMoveToBoard(board, move, color) val newHistory = history.addMove(move) (newBoard, newHistory, color.opposite, acc :+ move) moves + /** Apply a single HistoryMove to a Board, handling castling and promotion. */ + private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board = + move.castleSide match + case Some(side) => board.withCastle(color, side) + case None => + val (boardAfterMove, _) = board.withMove(move.from, move.to) + move.promotionPiece match + case Some(pp) => + val pieceType = pp match + case PromotionPiece.Queen => PieceType.Queen + case PromotionPiece.Rook => PieceType.Rook + case PromotionPiece.Bishop => PieceType.Bishop + case PromotionPiece.Knight => PieceType.Knight + boardAfterMove.updated(move.to, Piece(color, pieceType)) + case None => boardAfterMove + /** True for move-number tokens ("1.", "12.") and PGN result tokens. */ private def isMoveNumberOrResult(token: String): Boolean = token.matches("""\d+\.""") || @@ -128,16 +143,26 @@ object PgnParser: if hint.isEmpty then byPiece else byPiece.filter(from => matchesHint(from, hint)) - disambiguated.headOption.map(from => HistoryMove(from, toSquare, None)) + val promotion = extractPromotion(notation) + disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion)) /** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */ private def matchesHint(sq: Square, hint: String): Boolean = - hint.foldLeft(true): (ok, c) => - ok && ( - if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) - else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') - else true - ) + hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) + else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') + else true) + + /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ + private[notation] def extractPromotion(notation: String): Option[PromotionPiece] = + val promotionPattern = """=([A-Z])""".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 + } /** Convert a piece-letter character to a PieceType. */ private def charToPieceType(c: Char): Option[PieceType] = diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 3ed526b..7d465c5 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -1,6 +1,6 @@ package de.nowchess.chess.observer -import de.nowchess.api.board.{Board, Color} +import de.nowchess.api.board.{Board, Color, Square} import de.nowchess.chess.logic.GameHistory /** Base trait for all game state events. @@ -51,6 +51,15 @@ case class InvalidMoveEvent( reason: String ) extends GameEvent +/** 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 + /** Fired when the board is reset. */ case class BoardResetEvent( board: Board, 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 f5493b0..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 @@ -2,7 +2,9 @@ package de.nowchess.chess.controller import de.nowchess.api.board.* import de.nowchess.api.game.CastlingRights +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{CastleSide, GameHistory} +import de.nowchess.chess.notation.FenParser import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -293,3 +295,172 @@ class GameControllerTest extends AnyFunSuite with Matchers: newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed captured shouldBe Some(Piece.WhitePawn) case other => fail(s"Expected Moved but got $other") + + // ──── pawn promotion detection ─────────────────────────────────────────── + + test("processMove detects white pawn reaching R8 and returns PromotionRequired"): + val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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 (sq(File.E, Rank.R7)) + to should be (sq(File.E, Rank.R8)) + turn should be (Color.White) + case _ => fail("Expected PromotionRequired") + + test("processMove detects black pawn reaching R1 and returns PromotionRequired"): + val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get + val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1") + result should matchPattern { case _: MoveResult.PromotionRequired => } + result match + case MoveResult.PromotionRequired(from, to, _, _, _, turn) => + from should be (sq(File.E, Rank.R2)) + to should be (sq(File.E, Rank.R1)) + turn should be (Color.Black) + case _ => fail("Expected PromotionRequired") + + test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"): + val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get + 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") + + // ──── completePromotion ────────────────────────────────────────────────── + + test("completePromotion applies move and places queen"): + // Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals) + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result should matchPattern { case _: MoveResult.Moved => } + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + newBoard.pieceAt(sq(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"): + // Black king on h1: not attacked by rook on e8 (different file and rank) + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Rook, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(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 board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Bishop, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(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 board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Knight, Color.White + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(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"): + // Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1) + val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.D, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result match + case MoveResult.Moved(newBoard, _, captured, _) => + newBoard.pieceAt(sq(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 board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R2), sq(File.E, Rank.R1), + PromotionPiece.Knight, Color.Black + ) + result match + case MoveResult.Moved(newBoard, newHistory, _, _) => + newBoard.pieceAt(sq(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 board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get + val result = GameController.completePromotion( + board, GameHistory.empty, + sq(File.E, Rank.R7), sq(File.E, Rank.R8), + PromotionPiece.Queen, Color.White + ) + result should matchPattern { case _: MoveResult.MovedInCheck => } + + test("completePromotion full round-trip via processMove then completePromotion"): + // Black king on h1: not attacked by queen on e8 + val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get + 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(sq(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") + + 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 new file mode 100644 index 0000000..c40c392 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -0,0 +1,167 @@ +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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("3k4/4P3/8/8/8/8/8/8").get + val engine = new GameEngine(initialBoard = promotionBoard) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + engine.completePromotion(PromotionPiece.Queen) + + 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, initialTurn = Color.Black) + 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) + } + + test("completePromotion catch-all fires InvalidMoveEvent for unexpected MoveResult") { + // Inject a function that returns an unexpected MoveResult to hit the catch-all case + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val stubFn: (de.nowchess.api.board.Board, de.nowchess.chess.logic.GameHistory, Square, Square, PromotionPiece, Color) => de.nowchess.chess.controller.MoveResult = + (_, _, _, _, _, _) => de.nowchess.chess.controller.MoveResult.NoPiece + val engine = new GameEngine(initialBoard = promotionBoard, completePromotionFn = stubFn) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + engine.isPendingPromotion should be (true) + + engine.completePromotion(PromotionPiece.Queen) + + engine.isPendingPromotion should be (false) + events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) + } diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala index 7b9a878..96e9af4 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.logic import de.nowchess.api.board.* +import de.nowchess.api.move.PromotionPiece import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -39,3 +40,32 @@ class GameHistoryTest extends AnyFunSuite with Matchers: val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) history.moves should have length 1 history.moves.head.castleSide shouldBe None + + test("Move with promotion records the promotion piece"): + val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen)) + move.promotionPiece should be (Some(PromotionPiece.Queen)) + + test("Normal move has no promotion piece"): + val move = HistoryMove(sq(File.E, Rank.R2), sq(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(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Rook)) + newHistory.moves should have length 1 + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook)) + + test("addMove with castleSide only uses promotionPiece default (None)"): + val history = GameHistory.empty + // With overload 3 removed, this uses the 4-param version and triggers addMove$default$4 + val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside)) + newHistory.moves should have length 1 + newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside)) + newHistory.moves.head.promotionPiece should be (None) + + test("addMove using named parameters with only promotion, using castleSide default"): + val history = GameHistory.empty + val newHistory = history.addMove(from = sq(File.E, Rank.R7), to = sq(File.E, Rank.R8), promotionPiece = Some(PromotionPiece.Queen)) + newHistory.moves should have length 1 + newHistory.moves.head.castleSide should be (None) + newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index 6c819dd..b5dce75 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -3,6 +3,7 @@ package de.nowchess.chess.logic import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.api.game.CastlingRights import de.nowchess.chess.logic.{CastleSide, GameHistory} +import de.nowchess.chess.notation.FenParser import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -255,3 +256,25 @@ class MoveValidatorTest extends AnyFunSuite with Matchers: val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + + // ──── isPromotionMove ──────────────────────────────────────────────── + + test("White pawn reaching R8 is a promotion move"): + val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true) + + test("Black pawn reaching R1 is a promotion move"): + val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get + MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true) + + test("Pawn capturing to back rank is a promotion move"): + val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get + MoveValidator.isPromotionMove(b, 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 b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get + MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false) + + test("Non-pawn piece is never a promotion move"): + val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get + MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false) diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala index 133252b..6c39aa6 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -63,3 +64,39 @@ class PgnExporterTest extends AnyFunSuite with Matchers: pgn.contains("O-O-O") shouldBe true } + + 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 ("=") + } diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala index 687d1b1..520f842 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala @@ -1,7 +1,9 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* +import de.nowchess.api.move.PromotionPiece import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} +import de.nowchess.chess.notation.FenParser import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -332,3 +334,118 @@ class PgnParserTest extends AnyFunSuite with Matchers: result.isDefined shouldBe true result.get.to shouldBe Square(File.D, Rank.R1) } + + test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") { + val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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.parseBoard("8/4P3/4k3/8/8/8/8/8").get + 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") { + // Build a board with a white pawn on e7 plus the two kings + import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType} + val pieces: Map[Square, Piece] = Map( + Square(File.E, Rank.R7) -> Piece(Color.White, PieceType.Pawn), + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King) + ) + val board = Board(pieces) + val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White) + move.isDefined should be (true) + move.get.promotionPiece should be (Some(PromotionPiece.Queen)) + // After applying the promotion the square e8 should hold a White Queen + val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to) + val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen)) + promotedBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + } + + test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") { + // This test exercises lines 53-58 in PgnParser.parseMovesText which contain + // the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight + val pgn = """[Event "Promotion Test"] +[White "A"] +[Black "B"] + +1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0 +""" + val game = PgnParser.parsePgn(pgn) + + game.isDefined shouldBe true + // Move 10 is h2h1=Q (black pawn promotes to queen) + val blackPromotionToQ = game.get.moves(9) // 0-indexed + blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen) + + // Move 11 is a7a8=R (white pawn promotes to rook) + val whitePromotionToR = game.get.moves(10) + whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook) + } + + test("parseAlgebraicMove promotion with Rook through full PGN parse") { + val pgn = """[Event "Test"] +[White "A"] +[Black "B"] + +1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R +""" + val game = PgnParser.parsePgn(pgn) + game.isDefined shouldBe true + val lastMove = game.get.moves.last + lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook) + } + + test("parseAlgebraicMove promotion with Bishop through full PGN parse") { + val pgn = """[Event "Test"] +[White "A"] +[Black "B"] + +1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B +""" + val game = PgnParser.parsePgn(pgn) + game.isDefined shouldBe true + val lastMove = game.get.moves.last + lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop) + } + + test("parseAlgebraicMove promotion with Knight through full PGN parse") { + val pgn = """[Event "Test"] +[White "A"] +[Black "B"] + +1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N +""" + val game = PgnParser.parsePgn(pgn) + game.isDefined shouldBe true + val lastMove = game.get.moves.last + lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight) + } + + test("extractPromotion returns None for invalid promotion letter") { + // Regex =([A-Z]) now captures any uppercase letter, so =X is matched but case _ => None fires + val result = PgnParser.extractPromotion("e7e8=X") + result shouldBe None + } + + test("extractPromotion returns None when no promotion in notation") { + val result = PgnParser.extractPromotion("e7e8") + result shouldBe None + } diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 5fc32af..90bb91d 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -1,6 +1,7 @@ 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 @@ -11,6 +12,7 @@ import de.nowchess.chess.view.Renderer */ 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 = @@ -44,6 +46,10 @@ class TerminalUI(engine: GameEngine) extends Observer: print(Renderer.render(e.board)) printPrompt(e.turn) + case _: PromotionRequiredEvent => + println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") + synchronized { awaitingPromotion = true } + /** Start the terminal UI game loop. */ def start(): Unit = // Register as observer @@ -57,14 +63,26 @@ class TerminalUI(engine: GameEngine) extends Observer: // Game loop while running do val input = Option(StdIn.readLine()).getOrElse("quit").trim - input.toLowerCase match - case "quit" | "q" => - running = false - println("Game over. Goodbye!") - case "" => - printPrompt(engine.turn) - case _ => - engine.processUserInput(input) + synchronized { + 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) @@ -73,4 +91,3 @@ class TerminalUI(engine: GameEngine) extends Observer: 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: ") - diff --git a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala index 16ccba4..514ad0d 100644 --- a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala +++ b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala @@ -5,7 +5,7 @@ import org.scalatest.matchers.should.Matchers import java.io.{ByteArrayInputStream, ByteArrayOutputStream} import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.observer.* -import de.nowchess.api.board.{Board, Color} +import de.nowchess.api.board.{Board, Color, File, Rank, Square} import de.nowchess.chess.logic.GameHistory class TerminalUITest extends AnyFunSuite with Matchers { @@ -186,4 +186,142 @@ class TerminalUITest extends AnyFunSuite with Matchers { // The move should have been processed and the board displayed engine.turn shouldBe Color.Black } + + 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 + + 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 + + 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") + } + + test("TerminalUI routes Bishop promotion choice to engine.completePromotion") { + import de.nowchess.api.move.PromotionPiece + + 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\nb\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.Bishop)) + } + + test("TerminalUI routes Knight promotion choice to engine.completePromotion") { + import de.nowchess.api.move.PromotionPiece + + 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\nn\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.Knight)) + } }