Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e20c566e | |||
| 13bfc16cfe |
@@ -2,9 +2,10 @@
|
|||||||
name: scala-implementer
|
name: scala-implementer
|
||||||
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
|
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
|
||||||
tools: Read, Write, Edit, Bash, Glob
|
tools: Read, Write, Edit, Bash, Glob
|
||||||
model: sonnet
|
model: inherit
|
||||||
color: pink
|
color: pink
|
||||||
---
|
---
|
||||||
|
|
||||||
You do not have permissions to write tests, just source code.
|
You do not have permissions to write tests, just source code.
|
||||||
You are a Scala 3 expert specialising in Quarkus microservices.
|
You are a Scala 3 expert specialising in Quarkus microservices.
|
||||||
Always read the relevant /docs/api/ file before implementing.
|
Always read the relevant /docs/api/ file before implementing.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
name: test-writer
|
name: test-writer
|
||||||
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
|
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
|
||||||
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
||||||
model: sonnet
|
model: haiku
|
||||||
color: purple
|
color: purple
|
||||||
---
|
---
|
||||||
|
|
||||||
You do not have permissions to modify the source code, just write tests.
|
You do not have permissions to modify the source code, just write tests.
|
||||||
You write tests for Scala 3 + Quarkus services.
|
You write tests for Scala 3 + Quarkus services.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
-1
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="FrameworkDetectionExcludesConfiguration">
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
|
|||||||
Generated
+10
@@ -6,6 +6,16 @@
|
|||||||
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
</profile>
|
</profile>
|
||||||
</component>
|
</component>
|
||||||
|
<component name="IssueNavigationConfiguration">
|
||||||
|
<option name="links">
|
||||||
|
<list>
|
||||||
|
<IssueNavigationLink>
|
||||||
|
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
|
||||||
|
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
|
||||||
|
</IssueNavigationLink>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## [2026-03-31] Unreachable code blocking 100% statement coverage
|
||||||
|
|
||||||
|
**Requirement/Bug:** Reach 100% statement coverage in core module.
|
||||||
|
|
||||||
|
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
|
||||||
|
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
|
||||||
|
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
|
||||||
|
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
|
||||||
|
|
||||||
|
**Attempted Fixes:**
|
||||||
|
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
|
||||||
|
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
|
||||||
|
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
|
||||||
|
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
|
||||||
|
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
|
||||||
|
|
||||||
|
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
|
||||||
|
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
|
||||||
|
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
|
||||||
|
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.controller
|
package de.nowchess.chess.controller
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.*
|
import de.nowchess.chess.logic.*
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -14,6 +15,14 @@ object MoveResult:
|
|||||||
case object NoPiece extends MoveResult
|
case object NoPiece extends MoveResult
|
||||||
case object WrongColor extends MoveResult
|
case object WrongColor extends MoveResult
|
||||||
case object IllegalMove extends MoveResult
|
case object IllegalMove extends MoveResult
|
||||||
|
case class PromotionRequired(
|
||||||
|
from: Square,
|
||||||
|
to: Square,
|
||||||
|
boardBefore: Board,
|
||||||
|
historyBefore: GameHistory,
|
||||||
|
captured: Option[Piece],
|
||||||
|
turn: Color
|
||||||
|
) extends MoveResult
|
||||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||||
case class Checkmate(winner: Color) extends MoveResult
|
case class Checkmate(winner: Color) extends MoveResult
|
||||||
@@ -30,37 +39,64 @@ object GameController:
|
|||||||
*/
|
*/
|
||||||
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||||
raw.trim match
|
raw.trim match
|
||||||
case "quit" | "q" =>
|
case "quit" | "q" => MoveResult.Quit
|
||||||
MoveResult.Quit
|
|
||||||
case trimmed =>
|
case trimmed =>
|
||||||
Parser.parseMove(trimmed) match
|
Parser.parseMove(trimmed) match
|
||||||
case None =>
|
case None => MoveResult.InvalidFormat(trimmed)
|
||||||
MoveResult.InvalidFormat(trimmed)
|
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
|
||||||
case Some((from, to)) =>
|
|
||||||
board.pieceAt(from) match
|
/** Apply a previously detected promotion move with the chosen piece.
|
||||||
case None =>
|
* Called after processMove returned PromotionRequired.
|
||||||
MoveResult.NoPiece
|
*/
|
||||||
case Some(piece) if piece.color != turn =>
|
def completePromotion(
|
||||||
MoveResult.WrongColor
|
board: Board,
|
||||||
case Some(_) =>
|
history: GameHistory,
|
||||||
if !MoveValidator.isLegal(board, history)(from, to) then
|
from: Square,
|
||||||
MoveResult.IllegalMove
|
to: Square,
|
||||||
else
|
piece: PromotionPiece,
|
||||||
val castleOpt = if MoveValidator.isCastle(board)(from, to)
|
turn: Color
|
||||||
then Some(MoveValidator.castleSide(from, to))
|
): MoveResult =
|
||||||
else None
|
val (boardAfterMove, captured) = board.withMove(from, to)
|
||||||
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
val promotedPieceType = piece match
|
||||||
val (newBoard, captured) = castleOpt match
|
case PromotionPiece.Queen => PieceType.Queen
|
||||||
case Some(side) => (board.withCastle(turn, side), None)
|
case PromotionPiece.Rook => PieceType.Rook
|
||||||
case None =>
|
case PromotionPiece.Bishop => PieceType.Bishop
|
||||||
val (b, cap) = board.withMove(from, to)
|
case PromotionPiece.Knight => PieceType.Knight
|
||||||
if isEP then
|
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
val newHistory = history.addMove(from, to, None, Some(piece))
|
||||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
toMoveResult(newBoard, newHistory, captured, turn)
|
||||||
else (b, cap)
|
|
||||||
val newHistory = history.addMove(from, to, castleOpt)
|
// ---------------------------------------------------------------------------
|
||||||
GameRules.gameStatus(newBoard, newHistory)(turn.opposite) match
|
// Private helpers
|
||||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
// ---------------------------------------------------------------------------
|
||||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
|
||||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
board.pieceAt(from) match
|
||||||
|
case None => MoveResult.NoPiece
|
||||||
|
case Some(piece) if piece.color != turn => MoveResult.WrongColor
|
||||||
|
case Some(_) =>
|
||||||
|
if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
|
||||||
|
else if MoveValidator.isPromotionMove(board, from, to) then
|
||||||
|
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
|
||||||
|
else applyNormalMove(board, history, turn, from, to)
|
||||||
|
|
||||||
|
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||||
|
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
|
||||||
|
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
||||||
|
val (newBoard, captured) = castleOpt match
|
||||||
|
case Some(side) => (board.withCastle(turn, side), None)
|
||||||
|
case None =>
|
||||||
|
val (b, cap) = board.withMove(from, to)
|
||||||
|
if isEP then
|
||||||
|
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||||
|
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||||
|
else (b, cap)
|
||||||
|
val newHistory = history.addMove(from, to, castleOpt)
|
||||||
|
toMoveResult(newBoard, newHistory, captured, turn)
|
||||||
|
|
||||||
|
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||||
|
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
||||||
|
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
||||||
|
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
||||||
|
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
||||||
|
case PositionStatus.Drawn => MoveResult.Stalemate
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
||||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
@@ -11,12 +12,31 @@ import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
|||||||
* All user interactions must go through this engine via Commands, and all state changes
|
* All user interactions must go through this engine via Commands, and all state changes
|
||||||
* are communicated to observers via GameEvent notifications.
|
* are communicated to observers via GameEvent notifications.
|
||||||
*/
|
*/
|
||||||
class GameEngine extends Observable:
|
class GameEngine(
|
||||||
private var currentBoard: Board = Board.initial
|
initialBoard: Board = Board.initial,
|
||||||
private var currentHistory: GameHistory = GameHistory.empty
|
initialHistory: GameHistory = GameHistory.empty,
|
||||||
private var currentTurn: Color = Color.White
|
initialTurn: Color = Color.White,
|
||||||
|
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
||||||
|
GameController.completePromotion
|
||||||
|
) extends Observable:
|
||||||
|
private var currentBoard: Board = initialBoard
|
||||||
|
private var currentHistory: GameHistory = initialHistory
|
||||||
|
private var currentTurn: Color = initialTurn
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
|
/** Inner class for tracking pending promotion state */
|
||||||
|
private case class PendingPromotion(
|
||||||
|
from: Square, to: Square,
|
||||||
|
boardBefore: Board, historyBefore: GameHistory,
|
||||||
|
turn: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Current pending promotion, if any */
|
||||||
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
|
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentBoard }
|
def board: Board = synchronized { currentBoard }
|
||||||
def history: GameHistory = synchronized { currentHistory }
|
def history: GameHistory = synchronized { currentHistory }
|
||||||
@@ -115,6 +135,10 @@ class GameEngine extends Observable:
|
|||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||||
|
|
||||||
|
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||||
|
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||||
|
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last move. */
|
/** Undo the last move. */
|
||||||
@@ -127,6 +151,59 @@ class GameEngine extends Observable:
|
|||||||
performRedo()
|
performRedo()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply a player's promotion piece choice.
|
||||||
|
* Must only be called when isPendingPromotion is true.
|
||||||
|
*/
|
||||||
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||||
|
pendingPromotion match
|
||||||
|
case None =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
||||||
|
case Some(pending) =>
|
||||||
|
pendingPromotion = None
|
||||||
|
val cmd = MoveCommand(
|
||||||
|
from = pending.from,
|
||||||
|
to = pending.to,
|
||||||
|
previousBoard = Some(pending.boardBefore),
|
||||||
|
previousHistory = Some(pending.historyBefore),
|
||||||
|
previousTurn = Some(pending.turn)
|
||||||
|
)
|
||||||
|
completePromotionFn(
|
||||||
|
pending.boardBefore, pending.historyBefore,
|
||||||
|
pending.from, pending.to, piece, pending.turn
|
||||||
|
) match
|
||||||
|
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||||
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||||
|
invoker.execute(updatedCmd)
|
||||||
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
|
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
||||||
|
|
||||||
|
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||||
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||||
|
invoker.execute(updatedCmd)
|
||||||
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
|
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
||||||
|
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||||
|
|
||||||
|
case MoveResult.Checkmate(winner) =>
|
||||||
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||||
|
invoker.execute(updatedCmd)
|
||||||
|
currentBoard = Board.initial
|
||||||
|
currentHistory = GameHistory.empty
|
||||||
|
currentTurn = Color.White
|
||||||
|
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||||
|
|
||||||
|
case MoveResult.Stalemate =>
|
||||||
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||||
|
invoker.execute(updatedCmd)
|
||||||
|
currentBoard = Board.initial
|
||||||
|
currentHistory = GameHistory.empty
|
||||||
|
currentTurn = Color.White
|
||||||
|
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||||
|
|
||||||
|
case _ =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
||||||
|
}
|
||||||
|
|
||||||
/** Reset the board to initial position. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.nowchess.chess.logic
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.Square
|
import de.nowchess.api.board.Square
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
||||||
case class HistoryMove(
|
case class HistoryMove(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
castleSide: Option[CastleSide]
|
castleSide: Option[CastleSide],
|
||||||
|
promotionPiece: Option[PromotionPiece] = None
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Complete game history: ordered list of moves. */
|
/** Complete game history: ordered list of moves. */
|
||||||
@@ -17,8 +19,13 @@ case class GameHistory(moves: List[HistoryMove] = List.empty):
|
|||||||
def addMove(from: Square, to: Square): GameHistory =
|
def addMove(from: Square, to: Square): GameHistory =
|
||||||
addMove(HistoryMove(from, to, None))
|
addMove(HistoryMove(from, to, None))
|
||||||
|
|
||||||
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
|
def addMove(
|
||||||
addMove(HistoryMove(from, to, castleSide))
|
from: Square,
|
||||||
|
to: Square,
|
||||||
|
castleSide: Option[CastleSide] = None,
|
||||||
|
promotionPiece: Option[PromotionPiece] = None
|
||||||
|
): GameHistory =
|
||||||
|
addMove(HistoryMove(from, to, castleSide, promotionPiece))
|
||||||
|
|
||||||
object GameHistory:
|
object GameHistory:
|
||||||
val empty: GameHistory = GameHistory()
|
val empty: GameHistory = GameHistory()
|
||||||
|
|||||||
@@ -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) =>
|
||||||
@@ -70,9 +70,9 @@ object MoveValidator:
|
|||||||
.toSet
|
.toSet
|
||||||
|
|
||||||
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
|
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
|
||||||
val fi = from.file.ordinal
|
val fi = from.file.ordinal
|
||||||
val ri = from.rank.ordinal
|
val ri = from.rank.ordinal
|
||||||
val dir = if color == Color.White then 1 else -1
|
val dir = if color == Color.White then 1 else -1
|
||||||
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
|
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
|
||||||
|
|
||||||
val oneStep = squareAt(fi, ri + dir)
|
val oneStep = squareAt(fi, ri + dir)
|
||||||
@@ -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. */
|
||||||
@@ -41,16 +42,30 @@ object PgnParser:
|
|||||||
if isMoveNumberOrResult(token) then state
|
if isMoveNumberOrResult(token) then state
|
||||||
else
|
else
|
||||||
parseAlgebraicMove(token, board, history, color) match
|
parseAlgebraicMove(token, board, history, color) match
|
||||||
case None => state // unrecognised token — skip silently
|
case None => state // unrecognised token — skip silently
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val newBoard = move.castleSide match
|
val newBoard = applyMoveToBoard(board, move, color)
|
||||||
case Some(side) => board.withCastle(color, side)
|
|
||||||
case None => board.withMove(move.from, move.to)._1
|
|
||||||
val newHistory = history.addMove(move)
|
val newHistory = history.addMove(move)
|
||||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
(newBoard, newHistory, color.opposite, acc :+ move)
|
||||||
|
|
||||||
moves
|
moves
|
||||||
|
|
||||||
|
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
||||||
|
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
||||||
|
move.castleSide match
|
||||||
|
case Some(side) => board.withCastle(color, side)
|
||||||
|
case None =>
|
||||||
|
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
||||||
|
move.promotionPiece match
|
||||||
|
case Some(pp) =>
|
||||||
|
val pieceType = pp match
|
||||||
|
case PromotionPiece.Queen => PieceType.Queen
|
||||||
|
case PromotionPiece.Rook => PieceType.Rook
|
||||||
|
case PromotionPiece.Bishop => PieceType.Bishop
|
||||||
|
case PromotionPiece.Knight => PieceType.Knight
|
||||||
|
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
||||||
|
case None => boardAfterMove
|
||||||
|
|
||||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
private def isMoveNumberOrResult(token: String): Boolean =
|
||||||
token.matches("""\d+\.""") ||
|
token.matches("""\d+\.""") ||
|
||||||
@@ -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 && (
|
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
else true)
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
|
||||||
else true
|
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||||
)
|
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||||
|
val promotionPattern = """=([A-Z])""".r
|
||||||
|
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
||||||
|
m.group(1) match
|
||||||
|
case "Q" => Some(PromotionPiece.Queen)
|
||||||
|
case "R" => Some(PromotionPiece.Rook)
|
||||||
|
case "B" => Some(PromotionPiece.Bishop)
|
||||||
|
case "N" => Some(PromotionPiece.Knight)
|
||||||
|
case _ => None
|
||||||
|
}
|
||||||
|
|
||||||
/** Convert a piece-letter character to a PieceType. */
|
/** Convert a piece-letter character to a PieceType. */
|
||||||
private def charToPieceType(c: Char): Option[PieceType] =
|
private def charToPieceType(c: Char): Option[PieceType] =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.observer
|
package de.nowchess.chess.observer
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.chess.logic.GameHistory
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events.
|
||||||
@@ -51,6 +51,15 @@ case class InvalidMoveEvent(
|
|||||||
reason: String
|
reason: String
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||||
|
case class PromotionRequiredEvent(
|
||||||
|
board: Board,
|
||||||
|
history: GameHistory,
|
||||||
|
turn: Color,
|
||||||
|
from: Square,
|
||||||
|
to: Square
|
||||||
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the board is reset. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
board: Board,
|
board: Board,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package de.nowchess.chess.controller
|
|||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.game.CastlingRights
|
import de.nowchess.api.game.CastlingRights
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||||
|
import de.nowchess.chess.notation.FenParser
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -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,14 +63,26 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
// Game loop
|
// Game loop
|
||||||
while running do
|
while running do
|
||||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||||
input.toLowerCase match
|
synchronized {
|
||||||
case "quit" | "q" =>
|
if awaitingPromotion then
|
||||||
running = false
|
input.toLowerCase match
|
||||||
println("Game over. Goodbye!")
|
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
|
||||||
case "" =>
|
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
|
||||||
printPrompt(engine.turn)
|
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
|
||||||
case _ =>
|
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
|
||||||
engine.processUserInput(input)
|
case _ =>
|
||||||
|
println("Invalid choice. Enter q, r, b, or n.")
|
||||||
|
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||||
|
else
|
||||||
|
input.toLowerCase match
|
||||||
|
case "quit" | "q" =>
|
||||||
|
running = false
|
||||||
|
println("Game over. Goodbye!")
|
||||||
|
case "" =>
|
||||||
|
printPrompt(engine.turn)
|
||||||
|
case _ =>
|
||||||
|
engine.processUserInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
// Unsubscribe when done
|
// Unsubscribe when done
|
||||||
engine.unsubscribe(this)
|
engine.unsubscribe(this)
|
||||||
@@ -73,4 +91,3 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
val undoHint = if engine.canUndo then " [undo]" else ""
|
val undoHint = if engine.canUndo then " [undo]" else ""
|
||||||
val redoHint = if engine.canRedo then " [redo]" else ""
|
val redoHint = if engine.canRedo then " [redo]" else ""
|
||||||
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.chess.logic.GameHistory
|
||||||
|
|
||||||
class TerminalUITest extends AnyFunSuite with Matchers {
|
class TerminalUITest extends AnyFunSuite with Matchers {
|
||||||
@@ -186,4 +186,142 @@ class TerminalUITest extends AnyFunSuite with Matchers {
|
|||||||
// The move should have been processed and the board displayed
|
// The move should have been processed and the board displayed
|
||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
|
||||||
|
val out = new ByteArrayOutputStream()
|
||||||
|
val engine = new GameEngine()
|
||||||
|
val ui = new TerminalUI(engine)
|
||||||
|
|
||||||
|
Console.withOut(out) {
|
||||||
|
ui.onGameEvent(PromotionRequiredEvent(
|
||||||
|
Board(Map.empty), GameHistory(), Color.White,
|
||||||
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
out.toString should include("Promote to")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TerminalUI routes promotion choice to engine.completePromotion") {
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
|
var capturedPiece: Option[PromotionPiece] = None
|
||||||
|
|
||||||
|
val engine = new GameEngine() {
|
||||||
|
override def processUserInput(rawInput: String): Unit =
|
||||||
|
if rawInput.trim == "e7e8" then
|
||||||
|
notifyObservers(PromotionRequiredEvent(
|
||||||
|
Board(Map.empty), GameHistory.empty, Color.White,
|
||||||
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
||||||
|
))
|
||||||
|
override def completePromotion(piece: PromotionPiece): Unit =
|
||||||
|
capturedPiece = Some(piece)
|
||||||
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
||||||
|
}
|
||||||
|
|
||||||
|
val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
|
||||||
|
val out = new ByteArrayOutputStream()
|
||||||
|
val ui = new TerminalUI(engine)
|
||||||
|
|
||||||
|
Console.withIn(in) {
|
||||||
|
Console.withOut(out) {
|
||||||
|
ui.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedPiece should be(Some(PromotionPiece.Queen))
|
||||||
|
out.toString should include("Promote to")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TerminalUI re-prompts on invalid promotion choice") {
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
|
var capturedPiece: Option[PromotionPiece] = None
|
||||||
|
|
||||||
|
val engine = new GameEngine() {
|
||||||
|
override def processUserInput(rawInput: String): Unit =
|
||||||
|
if rawInput.trim == "e7e8" then
|
||||||
|
notifyObservers(PromotionRequiredEvent(
|
||||||
|
Board(Map.empty), GameHistory.empty, Color.White,
|
||||||
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
||||||
|
))
|
||||||
|
override def completePromotion(piece: PromotionPiece): Unit =
|
||||||
|
capturedPiece = Some(piece)
|
||||||
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "x" is invalid, then "r" for rook
|
||||||
|
val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
|
||||||
|
val out = new ByteArrayOutputStream()
|
||||||
|
val ui = new TerminalUI(engine)
|
||||||
|
|
||||||
|
Console.withIn(in) {
|
||||||
|
Console.withOut(out) {
|
||||||
|
ui.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedPiece should be(Some(PromotionPiece.Rook))
|
||||||
|
out.toString should include("Invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TerminalUI routes Bishop promotion choice to engine.completePromotion") {
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
|
var capturedPiece: Option[PromotionPiece] = None
|
||||||
|
|
||||||
|
val engine = new GameEngine() {
|
||||||
|
override def processUserInput(rawInput: String): Unit =
|
||||||
|
if rawInput.trim == "e7e8" then
|
||||||
|
notifyObservers(PromotionRequiredEvent(
|
||||||
|
Board(Map.empty), GameHistory.empty, Color.White,
|
||||||
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
||||||
|
))
|
||||||
|
override def completePromotion(piece: PromotionPiece): Unit =
|
||||||
|
capturedPiece = Some(piece)
|
||||||
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
||||||
|
}
|
||||||
|
|
||||||
|
val in = new ByteArrayInputStream("e7e8\nb\nquit\n".getBytes)
|
||||||
|
val out = new ByteArrayOutputStream()
|
||||||
|
val ui = new TerminalUI(engine)
|
||||||
|
|
||||||
|
Console.withIn(in) {
|
||||||
|
Console.withOut(out) {
|
||||||
|
ui.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedPiece should be(Some(PromotionPiece.Bishop))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TerminalUI routes Knight promotion choice to engine.completePromotion") {
|
||||||
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
|
var capturedPiece: Option[PromotionPiece] = None
|
||||||
|
|
||||||
|
val engine = new GameEngine() {
|
||||||
|
override def processUserInput(rawInput: String): Unit =
|
||||||
|
if rawInput.trim == "e7e8" then
|
||||||
|
notifyObservers(PromotionRequiredEvent(
|
||||||
|
Board(Map.empty), GameHistory.empty, Color.White,
|
||||||
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
||||||
|
))
|
||||||
|
override def completePromotion(piece: PromotionPiece): Unit =
|
||||||
|
capturedPiece = Some(piece)
|
||||||
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
||||||
|
}
|
||||||
|
|
||||||
|
val in = new ByteArrayInputStream("e7e8\nn\nquit\n".getBytes)
|
||||||
|
val out = new ByteArrayOutputStream()
|
||||||
|
val ui = new TerminalUI(engine)
|
||||||
|
|
||||||
|
Console.withIn(in) {
|
||||||
|
Console.withOut(out) {
|
||||||
|
ui.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedPiece should be(Some(PromotionPiece.Knight))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user