Compare commits

..

2 Commits

Author SHA1 Message Date
Janis e5e20c566e fix: update move validation to check for king safety (#13)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #13
2026-04-01 09:07:06 +02:00
Janis 13bfc16cfe feat: NCS-10 Implement Pawn Promotion (#12)
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>
2026-03-31 22:18:14 +02:00
24 changed files with 1085 additions and 220 deletions
+2 -1
View File
@@ -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 -2
View File
@@ -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.
@@ -19,5 +20,4 @@ When invoked BEFORE scala-implementer (no implementation exists yet):
When invoked AFTER scala-implementer (implementation exists): When invoked AFTER scala-implementer (implementation exists):
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
To regenerate the report run the tests first. To regenerate the report run the tests first.
+6
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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>
@@ -1,82 +0,0 @@
# Currying Public API — Design Spec
**Branch:** feat/NCS-11
**Date:** 2026-03-31
---
## Overview
Refactor the public methods of `MoveValidator` and `GameRules` to use multiple parameter groups (currying), separating `(board[, history])` as the context group from the computation parameters. This is a pure style refactoring — no behaviour changes, no new tests required.
---
## Motivation
Currying clarifies intent: `(board, history)` is "the world being operated on"; the remaining parameters are "what varies." It also enables partial application at call sites where board/history are fixed across a loop (e.g. `isInCheck`, `legalMoves`).
---
## Section 1: Methods Being Curried
### `MoveValidator` (public API only)
| Before | After |
|--------|-------|
| `legalTargets(board, from)` | `legalTargets(board)(from)` |
| `legalTargets(board, history, from)` | `legalTargets(board, history)(from)` |
| `isLegal(board, from, to)` | `isLegal(board)(from, to)` |
| `isLegal(board, history, from, to)` | `isLegal(board, history)(from, to)` |
| `isCastle(board, from, to)` | `isCastle(board)(from, to)` |
| `castlingTargets(board, history, color)` | `castlingTargets(board, history)(color)` |
| `castleSide(from, to)` | unchanged — no board parameter |
Private helpers (`isOwnPiece`, `isEnemyPiece`, `slide`, `pawnTargets`, `knightTargets`, `kingTargets`) are **not** changed.
### `GameRules` (all public methods)
| Before | After |
|--------|-------|
| `isInCheck(board, color)` | `isInCheck(board)(color)` |
| `legalMoves(board, history, color)` | `legalMoves(board, history)(color)` |
| `gameStatus(board, history, color)` | `gameStatus(board, history)(color)` |
---
## Section 2: Call Sites Updated
| File | Methods affected |
|------|-----------------|
| `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` | Internal calls to `legalTargets`, `castlingTargets`, `isCastle` |
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | `MoveValidator.legalTargets`, `MoveValidator.isCastle`, `isInCheck` |
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | `MoveValidator.isLegal`, `MoveValidator.isCastle`, `GameRules.gameStatus` |
| `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` | `legalTargets`, `isLegal`, `castlingTargets` |
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | `isInCheck`, `legalMoves`, `gameStatus` |
`EnPassantCalculator`, `CastlingRightsCalculator`, and their tests are **not** touched.
---
## Section 3: Concrete Style Gain
In `GameRules.isInCheck` and `legalMoves`, `board` is passed to `legalTargets` on every loop iteration today. After currying, it is factored out as a single partial application:
```scala
// Before
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
}
// After
val targets = MoveValidator.legalTargets(board)
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
targets(sq).contains(kingSq)
}
```
---
## Section 4: Testing
Pure refactoring — no new tests. All existing tests must pass after call sites are updated. The test suite is the regression guard.
+20
View File
@@ -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,25 +39,49 @@ 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)) =>
/** 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 board.pieceAt(from) match
case None => case None => MoveResult.NoPiece
MoveResult.NoPiece case Some(piece) if piece.color != turn => MoveResult.WrongColor
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) => case Some(_) =>
if !MoveValidator.isLegal(board, history)(from, to) then if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
MoveResult.IllegalMove else if MoveValidator.isPromotionMove(board, from, to) then
else MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
val castleOpt = if MoveValidator.isCastle(board)(from, to) else applyNormalMove(board, history, turn, from, to)
then Some(MoveValidator.castleSide(from, to))
else None 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 isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None) case Some(side) => (board.withCastle(turn, side), None)
@@ -59,7 +92,10 @@ object GameController:
(b.removed(capturedSq), board.pieceAt(capturedSq)) (b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap) else (b, cap)
val newHistory = history.addMove(from, to, castleOpt) val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory)(turn.opposite) match 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.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite) case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn) case PositionStatus.Mated => MoveResult.Checkmate(turn)
@@ -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()
@@ -9,40 +9,38 @@ enum PositionStatus:
object GameRules: object GameRules:
/** True if `color`'s king is under attack on this board. */ /** True if `color`'s king is under attack on this board. */
def isInCheck(board: Board)(color: Color): Boolean = def isInCheck(board: Board, color: Color): Boolean =
board.pieces board.pieces
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq } .collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
.exists { kingSq => .exists { kingSq =>
val targets = MoveValidator.legalTargets(board)
board.pieces.exists { case (sq, piece) => board.pieces.exists { case (sq, piece) =>
piece.color != color && piece.color != color &&
targets(sq).contains(kingSq) MoveValidator.legalTargets(board, sq).contains(kingSq)
} }
} }
/** All (from, to) moves for `color` that do not leave their own king in check. */ /** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, history: GameHistory)(color: Color): Set[(Square, Square)] = def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
val targets = MoveValidator.legalTargets(board, history)
board.pieces board.pieces
.collect { case (from, piece) if piece.color == color => from } .collect { case (from, piece) if piece.color == color => from }
.flatMap { from => .flatMap { from =>
targets(from) MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
.filter { to => .filter { to =>
val newBoard = val newBoard =
if MoveValidator.isCastle(board)(from, to) then if MoveValidator.isCastle(board, from, to) then
board.withCastle(color, MoveValidator.castleSide(from, to)) board.withCastle(color, MoveValidator.castleSide(from, to))
else else
board.withMove(from, to)._1 board.withMove(from, to)._1
!isInCheck(newBoard)(color) !isInCheck(newBoard, color)
} }
.map(to => from -> to) .map(to => from -> to)
} }
.toSet .toSet
/** Position status for the side whose turn it is (`color`). */ /** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, history: GameHistory)(color: Color): PositionStatus = def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
val moves = legalMoves(board, history)(color) val moves = legalMoves(board, history, color)
val inCheck = isInCheck(board)(color) val inCheck = isInCheck(board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck else if inCheck then PositionStatus.InCheck
@@ -11,11 +11,11 @@ object MoveValidator:
* - cannot capture own pieces * - cannot capture own pieces
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces * - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
*/ */
def isLegal(board: Board)(from: Square, to: Square): Boolean = def isLegal(board: Board, from: Square, to: Square): Boolean =
legalTargets(board)(from).contains(to) legalTargets(board, from).contains(to)
/** All squares a piece on `from` can legally move to (same rules as isLegal). */ /** All squares a piece on `from` can legally move to (same rules as isLegal). */
def legalTargets(board: Board)(from: Square): Set[Square] = def legalTargets(board: Board, from: Square): Set[Square] =
board.pieceAt(from) match board.pieceAt(from) match
case None => Set.empty case None => Set.empty
case Some(piece) => case Some(piece) =>
@@ -116,24 +116,24 @@ object MoveValidator:
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean = private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
board.pieces.exists { case (from, piece) => board.pieces.exists { case (from, piece) =>
piece.color == attackerColor && legalTargets(board)(from).contains(sq) piece.color == attackerColor && legalTargets(board, from).contains(sq)
} }
def isCastle(board: Board)(from: Square, to: Square): Boolean = def isCastle(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.King) && board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
math.abs(to.file.ordinal - from.file.ordinal) == 2 math.abs(to.file.ordinal - from.file.ordinal) == 2
def castleSide(from: Square, to: Square): CastleSide = def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
def castlingTargets(board: Board, history: GameHistory)(color: Color): Set[Square] = def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color) val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank) val kingSq = Square(File.E, rank)
val enemy = color.opposite val enemy = color.opposite
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) || if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(board)(color) then Set.empty GameRules.isInCheck(board, color) then Set.empty
else else
val kingsideSq = Option.when( val kingsideSq = Option.when(
rights.kingSide && rights.kingSide &&
@@ -151,14 +151,14 @@ object MoveValidator:
kingsideSq.toSet ++ queensideSq.toSet kingsideSq.toSet ++ queensideSq.toSet
def legalTargets(board: Board, history: GameHistory)(from: Square): Set[Square] = def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
board.pieceAt(from) match board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King => case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board)(from) ++ castlingTargets(board, history)(piece.color) legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case Some(piece) if piece.pieceType == PieceType.Pawn => case Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color) pawnTargets(board, history, from, piece.color)
case _ => case _ =>
legalTargets(board)(from) legalTargets(board, from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] = private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color) val existing = pawnTargets(board, from, color)
@@ -171,5 +171,13 @@ object MoveValidator:
.toSet .toSet
existing ++ epCapture existing ++ epCapture
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. */
@@ -43,14 +44,28 @@ object PgnParser:
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+\.""") ||
@@ -106,7 +121,7 @@ object PgnParser:
val reachable: Set[Square] = val reachable: Set[Square] =
board.pieces.collect { board.pieces.collect {
case (from, piece) if piece.color == color && case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board)(from).contains(toSquare) => from MoveValidator.legalTargets(board, from).contains(toSquare) => from
}.toSet }.toSet
val candidates: Set[Square] = val candidates: Set[Square] =
@@ -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 && (
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 if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true 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
@@ -41,6 +43,30 @@ class GameControllerTest extends AnyFunSuite with Matchers:
// White pawn at E2 cannot jump three squares to E5 // White pawn at E2 cannot jump three squares to E5
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
test("processMove: move that leaves own king in check returns IllegalMove"):
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
// geometrically legal but does not resolve the check — must be rejected.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.D, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
test("processMove: move that resolves check is allowed"):
// White King E1 is in check from Black Rook E8 along the E-file.
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a5e5") match
case _: MoveResult.Moved => succeed
case other => fail(s"Expected Moved, got $other")
test("processMove: legal pawn move returns Moved with updated board and flipped turn"): test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
@@ -293,3 +319,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))
@@ -13,34 +13,38 @@ class GameRulesTest extends AnyFunSuite with Matchers:
/** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */
private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] = private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] =
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty)(color) GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color)
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus = private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty)(color) GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
// ──── isInCheck ────────────────────────────────────────────────────── // ──── isInCheck ──────────────────────────────────────────────────────
test("isInCheck: king attacked by enemy rook on same rank"): test("isInCheck: king attacked by enemy rook on same rank"):
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.BlackRook sq(File.A, Rank.R1) -> Piece.BlackRook
) )
GameRules.isInCheck(b)(Color.White) shouldBe true GameRules.isInCheck(b, Color.White) shouldBe true
test("isInCheck: king not attacked"): test("isInCheck: king not attacked"):
// Black Rook A3 does not cover E1
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R3) -> Piece.BlackRook sq(File.A, Rank.R3) -> Piece.BlackRook
) )
GameRules.isInCheck(b)(Color.White) shouldBe false GameRules.isInCheck(b, Color.White) shouldBe false
test("isInCheck: no king on board returns false"): test("isInCheck: no king on board returns false"):
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook) val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
GameRules.isInCheck(b)(Color.White) shouldBe false GameRules.isInCheck(b, Color.White) shouldBe false
// ──── legalMoves ───────────────────────────────────────────────────── // ──── legalMoves ─────────────────────────────────────────────────────
test("legalMoves: move that exposes own king to rook is excluded"): test("legalMoves: move that exposes own king to rook is excluded"):
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8
// Moving the White Rook off the E-file would expose the king
val moves = testLegalMoves( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook, sq(File.E, Rank.R4) -> Piece.WhiteRook,
@@ -49,6 +53,7 @@ class GameRulesTest extends AnyFunSuite with Matchers:
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
test("legalMoves: move that blocks check is included"): test("legalMoves: move that blocks check is included"):
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
val moves = testLegalMoves( val moves = testLegalMoves(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook, sq(File.A, Rank.R5) -> Piece.WhiteRook,
@@ -59,6 +64,8 @@ class GameRulesTest extends AnyFunSuite with Matchers:
// ──── gameStatus ────────────────────────────────────────────────────── // ──── gameStatus ──────────────────────────────────────────────────────
test("gameStatus: checkmate returns Mated"): test("gameStatus: checkmate returns Mated"):
// White Qh8, Ka6; Black Ka8
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
testGameStatus( testGameStatus(
sq(File.H, Rank.R8) -> Piece.WhiteQueen, sq(File.H, Rank.R8) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing, sq(File.A, Rank.R6) -> Piece.WhiteKing,
@@ -66,6 +73,8 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Mated )(Color.Black) shouldBe PositionStatus.Mated
test("gameStatus: stalemate returns Drawn"): test("gameStatus: stalemate returns Drawn"):
// White Qb6, Kc6; Black Ka8
// Black king has no legal moves and is not in check (spec-verified position)
testGameStatus( testGameStatus(
sq(File.B, Rank.R6) -> Piece.WhiteQueen, sq(File.B, Rank.R6) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing, sq(File.C, Rank.R6) -> Piece.WhiteKing,
@@ -73,13 +82,14 @@ class GameRulesTest extends AnyFunSuite with Matchers:
)(Color.Black) shouldBe PositionStatus.Drawn )(Color.Black) shouldBe PositionStatus.Drawn
test("gameStatus: king in check with legal escape returns InCheck"): test("gameStatus: king in check with legal escape returns InCheck"):
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
testGameStatus( testGameStatus(
sq(File.A, Rank.R8) -> Piece.WhiteRook, sq(File.A, Rank.R8) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackKing sq(File.E, Rank.R8) -> Piece.BlackKing
)(Color.Black) shouldBe PositionStatus.InCheck )(Color.Black) shouldBe PositionStatus.InCheck
test("gameStatus: normal starting position returns Normal"): test("gameStatus: normal starting position returns Normal"):
GameRules.gameStatus(Board.initial, GameHistory.empty)(Color.White) shouldBe PositionStatus.Normal GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
test("legalMoves: includes castling destination when available"): test("legalMoves: includes castling destination when available"):
val b = board( val b = board(
@@ -87,7 +97,7 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing sq(File.H, Rank.R8) -> Piece.BlackKing
) )
GameRules.legalMoves(b, GameHistory.empty)(Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
test("legalMoves: excludes castling when king is in check"): test("legalMoves: excludes castling when king is in check"):
val b = board( val b = board(
@@ -96,9 +106,13 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R8) -> Piece.BlackRook, sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
) )
GameRules.legalMoves(b, GameHistory.empty)(Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"): test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"):
// White King e1, Rook h1 (kingside castling available).
// Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both,
// f1 attacked by f2. King cannot move to any adjacent square without entering
// an attacked square or an enemy piece. Only legal move: castle to g1.
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook, sq(File.H, Rank.R1) -> Piece.WhiteRook,
@@ -106,9 +120,11 @@ class GameRulesTest extends AnyFunSuite with Matchers:
sq(File.F, Rank.R2) -> Piece.BlackRook, sq(File.F, Rank.R2) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing sq(File.A, Rank.R8) -> Piece.BlackKing
) )
GameRules.gameStatus(b, GameHistory.empty)(Color.White) shouldBe PositionStatus.Normal // No history means castling rights are intact
GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
test("CastleSide.withCastle correctly positions pieces for Queenside castling"): test("CastleSide.withCastle correctly positions pieces for Queenside castling"):
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
val b = board( val b = board(
sq(File.E, Rank.R1) -> Piece.WhiteKing, sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
@@ -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
@@ -14,36 +15,36 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
// ──── Empty square ─────────────────────────────────────────────────── // ──── Empty square ───────────────────────────────────────────────────
test("legalTargets returns empty set when no piece at from square"): test("legalTargets returns empty set when no piece at from square"):
MoveValidator.legalTargets(Board.initial)(sq(File.E, Rank.R4)) shouldBe empty MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty
// ──── isLegal delegates to legalTargets ────────────────────────────── // ──── isLegal delegates to legalTargets ──────────────────────────────
test("isLegal returns true for a valid pawn move"): test("isLegal returns true for a valid pawn move"):
MoveValidator.isLegal(Board.initial)(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true
test("isLegal returns false for an invalid move"): test("isLegal returns false for an invalid move"):
MoveValidator.isLegal(Board.initial)(sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false
// ──── Pawn – White ─────────────────────────────────────────────────── // ──── Pawn – White ───────────────────────────────────────────────────
test("white pawn on starting rank can move forward one square"): test("white pawn on starting rank can move forward one square"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3)) MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3))
test("white pawn on starting rank can move forward two squares"): test("white pawn on starting rank can move forward two squares"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4)) MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4))
test("white pawn not on starting rank cannot move two squares"): test("white pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5) MoveValidator.legalTargets(b, sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5)
test("white pawn is blocked by piece directly in front, and cannot jump over it"): test("white pawn is blocked by piece directly in front, and cannot jump over it"):
val b = board( val b = board(
sq(File.E, Rank.R2) -> Piece.WhitePawn, sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R3) -> Piece.BlackPawn sq(File.E, Rank.R3) -> Piece.BlackPawn
) )
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
targets should not contain sq(File.E, Rank.R3) targets should not contain sq(File.E, Rank.R3)
targets should not contain sq(File.E, Rank.R4) targets should not contain sq(File.E, Rank.R4)
@@ -52,7 +53,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R2) -> Piece.WhitePawn, sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R4) -> Piece.BlackPawn sq(File.E, Rank.R4) -> Piece.BlackPawn
) )
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
targets should contain(sq(File.E, Rank.R3)) targets should contain(sq(File.E, Rank.R3))
targets should not contain sq(File.E, Rank.R4) targets should not contain sq(File.E, Rank.R4)
@@ -61,15 +62,15 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R2) -> Piece.WhitePawn, sq(File.E, Rank.R2) -> Piece.WhitePawn,
sq(File.D, Rank.R3) -> Piece.BlackPawn sq(File.D, Rank.R3) -> Piece.BlackPawn
) )
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3)) MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3))
test("white pawn cannot capture diagonally when no enemy piece is present"): test("white pawn cannot capture diagonally when no enemy piece is present"):
val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3) MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3)
test("white pawn at A-file does not generate diagonal to the left off the board"): test("white pawn at A-file does not generate diagonal to the left off the board"):
val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn) val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn)
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R2)) val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2))
targets should contain(sq(File.A, Rank.R3)) targets should contain(sq(File.A, Rank.R3))
targets should contain(sq(File.A, Rank.R4)) targets should contain(sq(File.A, Rank.R4))
targets.size shouldBe 2 targets.size shouldBe 2
@@ -78,50 +79,50 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("black pawn on starting rank can move forward one and two squares"): test("black pawn on starting rank can move forward one and two squares"):
val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn) val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn)
val targets = MoveValidator.legalTargets(b)(sq(File.E, Rank.R7)) val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R7))
targets should contain(sq(File.E, Rank.R6)) targets should contain(sq(File.E, Rank.R6))
targets should contain(sq(File.E, Rank.R5)) targets should contain(sq(File.E, Rank.R5))
test("black pawn not on starting rank cannot move two squares"): test("black pawn not on starting rank cannot move two squares"):
val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn) val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn)
MoveValidator.legalTargets(b)(sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4) MoveValidator.legalTargets(b, sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4)
test("black pawn can capture diagonally when enemy piece is present"): test("black pawn can capture diagonally when enemy piece is present"):
val b = board( val b = board(
sq(File.E, Rank.R7) -> Piece.BlackPawn, sq(File.E, Rank.R7) -> Piece.BlackPawn,
sq(File.F, Rank.R6) -> Piece.WhitePawn sq(File.F, Rank.R6) -> Piece.WhitePawn
) )
MoveValidator.legalTargets(b)(sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6)) MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6))
// ──── Knight ───────────────────────────────────────────────────────── // ──── Knight ─────────────────────────────────────────────────────────
test("knight in center has 8 possible moves"): test("knight in center has 8 possible moves"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight) val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)).size shouldBe 8 MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
test("knight in corner has only 2 possible moves"): test("knight in corner has only 2 possible moves"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight) val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight)
MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)).size shouldBe 2 MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2
test("knight cannot land on own piece"): test("knight cannot land on own piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight, sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.WhiteRook sq(File.F, Rank.R5) -> Piece.WhiteRook
) )
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5)
test("knight can capture enemy piece"): test("knight can capture enemy piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKnight, sq(File.D, Rank.R4) -> Piece.WhiteKnight,
sq(File.F, Rank.R5) -> Piece.BlackRook sq(File.F, Rank.R5) -> Piece.BlackRook
) )
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5)) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5))
// ──── Bishop ───────────────────────────────────────────────────────── // ──── Bishop ─────────────────────────────────────────────────────────
test("bishop slides diagonally across an empty board"): test("bishop slides diagonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop) val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop)
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5)) targets should contain(sq(File.E, Rank.R5))
targets should contain(sq(File.H, Rank.R8)) targets should contain(sq(File.H, Rank.R8))
targets should contain(sq(File.C, Rank.R3)) targets should contain(sq(File.C, Rank.R3))
@@ -132,7 +133,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.D, Rank.R4) -> Piece.WhiteBishop, sq(File.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.WhiteRook sq(File.F, Rank.R6) -> Piece.WhiteRook
) )
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5)) targets should contain(sq(File.E, Rank.R5))
targets should not contain sq(File.F, Rank.R6) targets should not contain sq(File.F, Rank.R6)
targets should not contain sq(File.G, Rank.R7) targets should not contain sq(File.G, Rank.R7)
@@ -142,7 +143,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.D, Rank.R4) -> Piece.WhiteBishop, sq(File.D, Rank.R4) -> Piece.WhiteBishop,
sq(File.F, Rank.R6) -> Piece.BlackRook sq(File.F, Rank.R6) -> Piece.BlackRook
) )
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
targets should contain(sq(File.E, Rank.R5)) targets should contain(sq(File.E, Rank.R5))
targets should contain(sq(File.F, Rank.R6)) targets should contain(sq(File.F, Rank.R6))
targets should not contain sq(File.G, Rank.R7) targets should not contain sq(File.G, Rank.R7)
@@ -151,7 +152,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("rook slides orthogonally across an empty board"): test("rook slides orthogonally across an empty board"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
targets should contain(sq(File.D, Rank.R8)) targets should contain(sq(File.D, Rank.R8))
targets should contain(sq(File.D, Rank.R1)) targets should contain(sq(File.D, Rank.R1))
targets should contain(sq(File.A, Rank.R4)) targets should contain(sq(File.A, Rank.R4))
@@ -162,7 +163,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.WhitePawn sq(File.C, Rank.R1) -> Piece.WhitePawn
) )
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)) val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
targets should contain(sq(File.B, Rank.R1)) targets should contain(sq(File.B, Rank.R1))
targets should not contain sq(File.C, Rank.R1) targets should not contain sq(File.C, Rank.R1)
targets should not contain sq(File.D, Rank.R1) targets should not contain sq(File.D, Rank.R1)
@@ -172,7 +173,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.A, Rank.R1) -> Piece.WhiteRook, sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R1) -> Piece.BlackPawn sq(File.C, Rank.R1) -> Piece.BlackPawn
) )
val targets = MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)) val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
targets should contain(sq(File.B, Rank.R1)) targets should contain(sq(File.B, Rank.R1))
targets should contain(sq(File.C, Rank.R1)) targets should contain(sq(File.C, Rank.R1))
targets should not contain sq(File.D, Rank.R1) targets should not contain sq(File.D, Rank.R1)
@@ -181,7 +182,7 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("queen combines rook and bishop movement for 27 squares from d4"): test("queen combines rook and bishop movement for 27 squares from d4"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen) val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
val targets = MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
targets should contain(sq(File.D, Rank.R8)) targets should contain(sq(File.D, Rank.R8))
targets should contain(sq(File.H, Rank.R4)) targets should contain(sq(File.H, Rank.R4))
targets should contain(sq(File.H, Rank.R8)) targets should contain(sq(File.H, Rank.R8))
@@ -192,63 +193,88 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
test("king moves one step in all 8 directions from center"): test("king moves one step in all 8 directions from center"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing) val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing)
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)).size shouldBe 8 MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
test("king at corner has only 3 reachable squares"): test("king at corner has only 3 reachable squares"):
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing) val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing)
MoveValidator.legalTargets(b)(sq(File.A, Rank.R1)).size shouldBe 3 MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3
test("king cannot capture own piece"): test("king cannot capture own piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing, sq(File.D, Rank.R4) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.WhiteRook sq(File.E, Rank.R4) -> Piece.WhiteRook
) )
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4)
test("king can capture enemy piece"): test("king can capture enemy piece"):
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.WhiteKing, sq(File.D, Rank.R4) -> Piece.WhiteKing,
sq(File.E, Rank.R4) -> Piece.BlackRook sq(File.E, Rank.R4) -> Piece.BlackRook
) )
MoveValidator.legalTargets(b)(sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
// ──── Pawn en passant targets ────────────────────────────────────── // ──── Pawn en passant targets ──────────────────────────────────────
test("white pawn includes ep target in legal moves after black double push"): test("white pawn includes ep target in legal moves after black double push"):
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
val b = board( val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h)(sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6)) MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
test("white pawn does not include ep target without a preceding double push"): test("white pawn does not include ep target without a preceding double push"):
val b = board( val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn, sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
MoveValidator.legalTargets(b, h)(sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6) MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
test("black pawn includes ep target in legal moves after white double push"): test("black pawn includes ep target in legal moves after white double push"):
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
val b = board( val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn, sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn sq(File.E, Rank.R4) -> Piece.WhitePawn
) )
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)) should contain(sq(File.E, Rank.R3)) MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
test("pawn on wrong file does not get ep target from adjacent double push"): test("pawn on wrong file does not get ep target from adjacent double push"):
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
val b = board( val b = board(
sq(File.A, Rank.R5) -> Piece.WhitePawn, sq(File.A, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn sq(File.D, Rank.R5) -> Piece.BlackPawn
) )
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h)(sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6) MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
// ──── History-aware legalTargets fallback for non-pawn non-king pieces ───── // ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"): test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"):
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,6 +63,17 @@ 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
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 input.toLowerCase match
case "quit" | "q" => case "quit" | "q" =>
running = false running = false
@@ -65,6 +82,7 @@ class TerminalUI(engine: GameEngine) extends Observer:
printPrompt(engine.turn) printPrompt(engine.turn)
case _ => case _ =>
engine.processUserInput(input) 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))
}
} }