feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #12 Reviewed-by: Leon Hermann <lq@blackhole.local> Co-authored-by: Janis <janis-e@gmx.de> Co-committed-by: Janis <janis-e@gmx.de>
This commit was merged in pull request #12.
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
name: scala-implementer
|
name: scala-implementer
|
||||||
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
|
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
|
||||||
tools: Read, Write, Edit, Bash, Glob
|
tools: Read, Write, Edit, Bash, Glob
|
||||||
model: sonnet
|
model: inherit
|
||||||
color: pink
|
color: pink
|
||||||
---
|
---
|
||||||
|
|
||||||
You do not have permissions to write tests, just source code.
|
You do not have permissions to write tests, just source code.
|
||||||
You are a Scala 3 expert specialising in Quarkus microservices.
|
You are a Scala 3 expert specialising in Quarkus microservices.
|
||||||
Always read the relevant /docs/api/ file before implementing.
|
Always read the relevant /docs/api/ file before implementing.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
name: test-writer
|
name: test-writer
|
||||||
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
|
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
|
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
||||||
model: sonnet
|
model: haiku
|
||||||
color: purple
|
color: purple
|
||||||
---
|
---
|
||||||
|
|
||||||
You do not have permissions to modify the source code, just write tests.
|
You do not have permissions to modify the source code, just write tests.
|
||||||
You write tests for Scala 3 + Quarkus services.
|
You write tests for Scala 3 + Quarkus services.
|
||||||
|
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
-1
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="FrameworkDetectionExcludesConfiguration">
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
|
|||||||
Generated
+10
@@ -6,6 +6,16 @@
|
|||||||
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
</profile>
|
</profile>
|
||||||
</component>
|
</component>
|
||||||
|
<component name="IssueNavigationConfiguration">
|
||||||
|
<option name="links">
|
||||||
|
<list>
|
||||||
|
<IssueNavigationLink>
|
||||||
|
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
|
||||||
|
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
|
||||||
|
</IssueNavigationLink>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.controller
|
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.*
|
import de.nowchess.chess.logic.*
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -14,6 +15,14 @@ object MoveResult:
|
|||||||
case object NoPiece extends MoveResult
|
case object NoPiece extends MoveResult
|
||||||
case object WrongColor extends MoveResult
|
case object WrongColor extends MoveResult
|
||||||
case object IllegalMove 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 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 MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||||
case class Checkmate(winner: 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 =
|
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||||
raw.trim match
|
raw.trim match
|
||||||
case "quit" | "q" =>
|
case "quit" | "q" => MoveResult.Quit
|
||||||
MoveResult.Quit
|
|
||||||
case trimmed =>
|
case trimmed =>
|
||||||
Parser.parseMove(trimmed) match
|
Parser.parseMove(trimmed) match
|
||||||
case None =>
|
case None => MoveResult.InvalidFormat(trimmed)
|
||||||
MoveResult.InvalidFormat(trimmed)
|
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
|
||||||
case Some((from, to)) =>
|
|
||||||
board.pieceAt(from) match
|
/** Apply a previously detected promotion move with the chosen piece.
|
||||||
case None =>
|
* Called after processMove returned PromotionRequired.
|
||||||
MoveResult.NoPiece
|
*/
|
||||||
case Some(piece) if piece.color != turn =>
|
def completePromotion(
|
||||||
MoveResult.WrongColor
|
board: Board,
|
||||||
case Some(_) =>
|
history: GameHistory,
|
||||||
if !MoveValidator.isLegal(board, history, from, to) then
|
from: Square,
|
||||||
MoveResult.IllegalMove
|
to: Square,
|
||||||
else
|
piece: PromotionPiece,
|
||||||
val castleOpt = if MoveValidator.isCastle(board, from, to)
|
turn: Color
|
||||||
then Some(MoveValidator.castleSide(from, to))
|
): MoveResult =
|
||||||
else None
|
val (boardAfterMove, captured) = board.withMove(from, to)
|
||||||
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
val promotedPieceType = piece match
|
||||||
val (newBoard, captured) = castleOpt match
|
case PromotionPiece.Queen => PieceType.Queen
|
||||||
case Some(side) => (board.withCastle(turn, side), None)
|
case PromotionPiece.Rook => PieceType.Rook
|
||||||
case None =>
|
case PromotionPiece.Bishop => PieceType.Bishop
|
||||||
val (b, cap) = board.withMove(from, to)
|
case PromotionPiece.Knight => PieceType.Knight
|
||||||
if isEP then
|
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
val newHistory = history.addMove(from, to, None, Some(piece))
|
||||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
toMoveResult(newBoard, newHistory, captured, turn)
|
||||||
else (b, cap)
|
|
||||||
val newHistory = history.addMove(from, to, castleOpt)
|
// ---------------------------------------------------------------------------
|
||||||
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
// Private helpers
|
||||||
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)
|
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
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.logic.{GameHistory, GameRules, PositionStatus}
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||||
import de.nowchess.chess.observer.*
|
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
|
* All user interactions must go through this engine via Commands, and all state changes
|
||||||
* are communicated to observers via GameEvent notifications.
|
* are communicated to observers via GameEvent notifications.
|
||||||
*/
|
*/
|
||||||
class GameEngine extends Observable:
|
class GameEngine(
|
||||||
private var currentBoard: Board = Board.initial
|
initialBoard: Board = Board.initial,
|
||||||
private var currentHistory: GameHistory = GameHistory.empty
|
initialHistory: GameHistory = GameHistory.empty,
|
||||||
private var currentTurn: Color = Color.White
|
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()
|
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
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentBoard }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def history: GameHistory = synchronized { currentHistory }
|
||||||
@@ -115,6 +135,10 @@ class GameEngine extends Observable:
|
|||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
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. */
|
/** Undo the last move. */
|
||||||
@@ -127,6 +151,59 @@ class GameEngine extends Observable:
|
|||||||
performRedo()
|
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. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.nowchess.chess.logic
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.Square
|
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. */
|
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
||||||
case class HistoryMove(
|
case class HistoryMove(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
castleSide: Option[CastleSide]
|
castleSide: Option[CastleSide],
|
||||||
|
promotionPiece: Option[PromotionPiece] = None
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Complete game history: ordered list of moves. */
|
/** 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 =
|
def addMove(from: Square, to: Square): GameHistory =
|
||||||
addMove(HistoryMove(from, to, None))
|
addMove(HistoryMove(from, to, None))
|
||||||
|
|
||||||
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
|
def addMove(
|
||||||
addMove(HistoryMove(from, to, castleSide))
|
from: Square,
|
||||||
|
to: Square,
|
||||||
|
castleSide: Option[CastleSide] = None,
|
||||||
|
promotionPiece: Option[PromotionPiece] = None
|
||||||
|
): GameHistory =
|
||||||
|
addMove(HistoryMove(from, to, castleSide, promotionPiece))
|
||||||
|
|
||||||
object GameHistory:
|
object GameHistory:
|
||||||
val empty: GameHistory = GameHistory()
|
val empty: GameHistory = GameHistory()
|
||||||
|
|||||||
@@ -173,3 +173,11 @@ object MoveValidator:
|
|||||||
|
|
||||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||||
legalTargets(board, history, from).contains(to)
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
||||||
|
|
||||||
object PgnExporter:
|
object PgnExporter:
|
||||||
@@ -32,4 +33,11 @@ object PgnExporter:
|
|||||||
move.castleSide match
|
move.castleSide match
|
||||||
case Some(CastleSide.Kingside) => "O-O"
|
case Some(CastleSide.Kingside) => "O-O"
|
||||||
case Some(CastleSide.Queenside) => "O-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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
||||||
|
|
||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
/** A parsed PGN game containing headers and the resolved move list. */
|
||||||
@@ -41,16 +42,30 @@ object PgnParser:
|
|||||||
if isMoveNumberOrResult(token) then state
|
if isMoveNumberOrResult(token) then state
|
||||||
else
|
else
|
||||||
parseAlgebraicMove(token, board, history, color) match
|
parseAlgebraicMove(token, board, history, color) match
|
||||||
case None => state // unrecognised token — skip silently
|
case None => state // unrecognised token — skip silently
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val newBoard = move.castleSide match
|
val newBoard = applyMoveToBoard(board, move, color)
|
||||||
case Some(side) => board.withCastle(color, side)
|
|
||||||
case None => board.withMove(move.from, move.to)._1
|
|
||||||
val newHistory = history.addMove(move)
|
val newHistory = history.addMove(move)
|
||||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
(newBoard, newHistory, color.opposite, acc :+ move)
|
||||||
|
|
||||||
moves
|
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. */
|
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
private def isMoveNumberOrResult(token: String): Boolean =
|
||||||
token.matches("""\d+\.""") ||
|
token.matches("""\d+\.""") ||
|
||||||
@@ -128,16 +143,26 @@ object PgnParser:
|
|||||||
if hint.isEmpty then byPiece
|
if hint.isEmpty then byPiece
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
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). */
|
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||||
hint.foldLeft(true): (ok, c) =>
|
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||||
ok && (
|
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
else true)
|
||||||
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. */
|
/** Convert a piece-letter character to a PieceType. */
|
||||||
private def charToPieceType(c: Char): Option[PieceType] =
|
private def charToPieceType(c: Char): Option[PieceType] =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.observer
|
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
|
import de.nowchess.chess.logic.GameHistory
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events.
|
||||||
@@ -51,6 +51,15 @@ case class InvalidMoveEvent(
|
|||||||
reason: String
|
reason: String
|
||||||
) extends GameEvent
|
) 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. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
board: Board,
|
board: Board,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package de.nowchess.chess.controller
|
|||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.game.CastlingRights
|
import de.nowchess.api.game.CastlingRights
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||||
|
import de.nowchess.chess.notation.FenParser
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
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
|
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
||||||
captured shouldBe Some(Piece.WhitePawn)
|
captured shouldBe Some(Piece.WhitePawn)
|
||||||
case other => fail(s"Expected Moved but got $other")
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.logic
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
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))
|
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
history.moves should have length 1
|
history.moves should have length 1
|
||||||
history.moves.head.castleSide shouldBe None
|
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))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.nowchess.chess.logic
|
|||||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||||
import de.nowchess.api.game.CastlingRights
|
import de.nowchess.api.game.CastlingRights
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||||
|
import de.nowchess.chess.notation.FenParser
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
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 b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
|
||||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
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))
|
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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -63,3 +64,39 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
pgn.contains("O-O-O") shouldBe true
|
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 ("=")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.nowchess.chess.notation
|
package de.nowchess.chess.notation
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||||
|
import de.nowchess.chess.notation.FenParser
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -332,3 +334,118 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
result.isDefined shouldBe true
|
result.isDefined shouldBe true
|
||||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.ui.terminal
|
package de.nowchess.ui.terminal
|
||||||
|
|
||||||
import scala.io.StdIn
|
import scala.io.StdIn
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
||||||
import de.nowchess.chess.view.Renderer
|
import de.nowchess.chess.view.Renderer
|
||||||
@@ -11,6 +12,7 @@ import de.nowchess.chess.view.Renderer
|
|||||||
*/
|
*/
|
||||||
class TerminalUI(engine: GameEngine) extends Observer:
|
class TerminalUI(engine: GameEngine) extends Observer:
|
||||||
private var running = true
|
private var running = true
|
||||||
|
private var awaitingPromotion = false
|
||||||
|
|
||||||
/** Called by GameEngine whenever a game event occurs. */
|
/** Called by GameEngine whenever a game event occurs. */
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
@@ -44,6 +46,10 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
print(Renderer.render(e.board))
|
print(Renderer.render(e.board))
|
||||||
printPrompt(e.turn)
|
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. */
|
/** Start the terminal UI game loop. */
|
||||||
def start(): Unit =
|
def start(): Unit =
|
||||||
// Register as observer
|
// Register as observer
|
||||||
@@ -57,14 +63,26 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
// Game loop
|
// Game loop
|
||||||
while running do
|
while running do
|
||||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||||
input.toLowerCase match
|
synchronized {
|
||||||
case "quit" | "q" =>
|
if awaitingPromotion then
|
||||||
running = false
|
input.toLowerCase match
|
||||||
println("Game over. Goodbye!")
|
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
|
||||||
case "" =>
|
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
|
||||||
printPrompt(engine.turn)
|
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
|
||||||
case _ =>
|
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
|
||||||
engine.processUserInput(input)
|
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
|
// Unsubscribe when done
|
||||||
engine.unsubscribe(this)
|
engine.unsubscribe(this)
|
||||||
@@ -73,4 +91,3 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
val undoHint = if engine.canUndo then " [undo]" else ""
|
val undoHint = if engine.canUndo then " [undo]" else ""
|
||||||
val redoHint = if engine.canRedo then " [redo]" else ""
|
val redoHint = if engine.canRedo then " [redo]" else ""
|
||||||
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
import de.nowchess.chess.observer.*
|
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
|
import de.nowchess.chess.logic.GameHistory
|
||||||
|
|
||||||
class TerminalUITest extends AnyFunSuite with Matchers {
|
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
|
// The move should have been processed and the board displayed
|
||||||
engine.turn shouldBe Color.Black
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user