Compare commits
6 Commits
api-0.1.0
...
rule-0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 217f14f899 | |||
| 638139602c | |||
| 8f56a82104 | |||
| 51ffd7aac9 | |||
| 1b9eb471de | |||
| 45013c87a9 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||
"superpowers@claude-plugins-official": false,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": false
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
@@ -12,6 +12,8 @@
|
||||
<option value="$PROJECT_DIR$/modules" />
|
||||
<option value="$PROJECT_DIR$/modules/api" />
|
||||
<option value="$PROJECT_DIR$/modules/core" />
|
||||
<option value="$PROJECT_DIR$/modules/io" />
|
||||
<option value="$PROJECT_DIR$/modules/rule" />
|
||||
<option value="$PROJECT_DIR$/modules/ui" />
|
||||
</set>
|
||||
</option>
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</profile>
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
./gradlew test
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||
else
|
||||
python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import glob,re
|
||||
mods=['api','core','io','rule','ui']
|
||||
tot=0
|
||||
for m in mods:
|
||||
s=0
|
||||
for f in glob.glob(f'modules/{m}/build/test-results/test/TEST-*.xml'):
|
||||
txt=open(f,encoding='utf-8').read(300)
|
||||
m2=re.search(r'tests="(\d+)"',txt)
|
||||
if m2:s+=int(m2.group(1))
|
||||
print(f'{m}: {s}')
|
||||
tot+=s
|
||||
print('overall:',tot)
|
||||
@@ -6,3 +6,13 @@
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
## (2026-04-02)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
## (2026-04-03)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
|
||||
@@ -14,6 +14,9 @@ object Board:
|
||||
val captured = b.get(to)
|
||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||
(updatedBoard, captured)
|
||||
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||
val (updatedBoard, _) = b.withMove(move.from, move.to)
|
||||
updatedBoard
|
||||
def pieces: Map[Square, Piece] = b
|
||||
|
||||
val initial: Board =
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
/**
|
||||
* Unified castling rights tracker for all four sides.
|
||||
* Tracks whether castling is still available for each side and direction.
|
||||
*
|
||||
* @param whiteKingSide White's king-side castling (0-0) still legally available
|
||||
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
|
||||
* @param blackKingSide Black's king-side castling (0-0) still legally available
|
||||
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
|
||||
*/
|
||||
final case class CastlingRights(
|
||||
whiteKingSide: Boolean,
|
||||
whiteQueenSide: Boolean,
|
||||
blackKingSide: Boolean,
|
||||
blackQueenSide: Boolean
|
||||
):
|
||||
/**
|
||||
* Check if either side has any castling rights remaining.
|
||||
*/
|
||||
def hasAnyRights: Boolean =
|
||||
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||
|
||||
/**
|
||||
* Check if a specific color has any castling rights remaining.
|
||||
*/
|
||||
def hasRights(color: Color): Boolean = color match
|
||||
case Color.White => whiteKingSide || whiteQueenSide
|
||||
case Color.Black => blackKingSide || blackQueenSide
|
||||
|
||||
/**
|
||||
* Revoke all castling rights for a specific color.
|
||||
*/
|
||||
def revokeColor(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||
|
||||
/**
|
||||
* Revoke a specific castling right.
|
||||
*/
|
||||
def revokeKingSide(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteKingSide = false)
|
||||
case Color.Black => copy(blackKingSide = false)
|
||||
|
||||
/**
|
||||
* Revoke a specific castling right.
|
||||
*/
|
||||
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteQueenSide = false)
|
||||
case Color.Black => copy(blackQueenSide = false)
|
||||
|
||||
object CastlingRights:
|
||||
/** No castling rights for any side. */
|
||||
val None: CastlingRights = CastlingRights(
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
)
|
||||
|
||||
/** All castling rights available. */
|
||||
val All: CastlingRights = CastlingRights(
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = true,
|
||||
blackQueenSide = true
|
||||
)
|
||||
|
||||
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||
val Initial: CastlingRights = All
|
||||
@@ -39,3 +39,19 @@ object Square:
|
||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
||||
)
|
||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||
|
||||
val all: IndexedSeq[Square] =
|
||||
for
|
||||
r <- Rank.values.toIndexedSeq
|
||||
f <- File.values.toIndexedSeq
|
||||
yield Square(f, r)
|
||||
|
||||
/** Compute a target square by offsetting file and rank.
|
||||
* Returns None if the resulting square is outside the board (0-7 range). */
|
||||
extension (sq: Square)
|
||||
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||
val newFileOrd = sq.file.ordinal + fileDelta
|
||||
val newRankOrd = sq.rank.ordinal + rankDelta
|
||||
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
|
||||
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
|
||||
else None
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
|
||||
import de.nowchess.api.move.Move
|
||||
|
||||
/** Immutable bundle of complete game state.
|
||||
* All state changes produce new GameContext instances.
|
||||
*/
|
||||
case class GameContext(
|
||||
board: Board,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moves: List[Move]
|
||||
):
|
||||
/** Create new context with updated board. */
|
||||
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||
|
||||
/** Create new context with updated turn. */
|
||||
def withTurn(newTurn: Color): GameContext = copy(turn = newTurn)
|
||||
|
||||
/** Create new context with updated castling rights. */
|
||||
def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights)
|
||||
|
||||
/** Create new context with updated en passant square. */
|
||||
def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq)
|
||||
|
||||
/** Create new context with updated half-move clock. */
|
||||
def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock)
|
||||
|
||||
/** Create new context with move appended to history. */
|
||||
def withMove(move: Move): GameContext = copy(moves = moves :+ move)
|
||||
|
||||
object GameContext:
|
||||
/** Initial position: white to move, all castling rights, no en passant. */
|
||||
def initial: GameContext = GameContext(
|
||||
board = Board.initial,
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights.Initial,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
|
||||
/**
|
||||
* Castling availability flags for one side.
|
||||
*
|
||||
* @param kingSide king-side castling still legally available
|
||||
* @param queenSide queen-side castling still legally available
|
||||
*/
|
||||
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
|
||||
|
||||
object CastlingRights:
|
||||
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
|
||||
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
|
||||
|
||||
/** Outcome of a finished game. */
|
||||
enum GameResult:
|
||||
case WhiteWins
|
||||
case BlackWins
|
||||
case Draw
|
||||
|
||||
/** Lifecycle state of a game. */
|
||||
enum GameStatus:
|
||||
case NotStarted
|
||||
case InProgress
|
||||
case Finished(result: GameResult)
|
||||
|
||||
/**
|
||||
* A FEN-compatible snapshot of board and game state.
|
||||
*
|
||||
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
|
||||
* separated by '/'). All other fields mirror standard FEN fields.
|
||||
*
|
||||
* @param piecePlacement FEN piece-placement field, e.g.
|
||||
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
* @param activeColor side to move
|
||||
* @param castlingWhite castling rights for White
|
||||
* @param castlingBlack castling rights for Black
|
||||
* @param enPassantTarget square behind the double-pushed pawn, if any
|
||||
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
|
||||
* @param fullMoveNumber increments after Black's move, starts at 1
|
||||
* @param status current lifecycle status of the game
|
||||
*/
|
||||
final case class GameState(
|
||||
piecePlacement: String,
|
||||
activeColor: Color,
|
||||
castlingWhite: CastlingRights,
|
||||
castlingBlack: CastlingRights,
|
||||
enPassantTarget: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
fullMoveNumber: Int,
|
||||
status: GameStatus
|
||||
)
|
||||
|
||||
object GameState:
|
||||
/** Standard starting position. */
|
||||
val initial: GameState = GameState(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights.Both,
|
||||
castlingBlack = CastlingRights.Both,
|
||||
enPassantTarget = None,
|
||||
halfMoveClock = 0,
|
||||
fullMoveNumber = 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
@@ -9,7 +9,7 @@ enum PromotionPiece:
|
||||
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
||||
enum MoveType:
|
||||
/** A normal move or capture with no special rule. */
|
||||
case Normal
|
||||
case Normal(isCapture: Boolean = false)
|
||||
/** Kingside castling (O-O). */
|
||||
case CastleKingside
|
||||
/** Queenside castling (O-O-O). */
|
||||
@@ -29,5 +29,5 @@ enum MoveType:
|
||||
final case class Move(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal
|
||||
moveType: MoveType = MoveType.Normal()
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import de.nowchess.api.move.Move
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -7,13 +8,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val e2 = Square(File.E, Rank.R2)
|
||||
private val e4 = Square(File.E, Rank.R4)
|
||||
private val d7 = Square(File.D, Rank.R7)
|
||||
|
||||
test("pieceAt returns Some for occupied square") {
|
||||
test("pieceAt resolves occupied and empty squares") {
|
||||
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
}
|
||||
|
||||
test("pieceAt returns None for empty square") {
|
||||
Board.initial.pieceAt(e4) shouldBe None
|
||||
}
|
||||
|
||||
@@ -34,38 +31,20 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
board.pieceAt(from) shouldBe None
|
||||
}
|
||||
|
||||
test("pieces returns the underlying map") {
|
||||
val map = Map(e2 -> Piece.WhitePawn)
|
||||
val b = Board(map)
|
||||
b.pieces shouldBe map
|
||||
}
|
||||
|
||||
test("Board.apply constructs board from map") {
|
||||
test("Board.apply and pieces expose the wrapped map") {
|
||||
val map = Map(e2 -> Piece.WhitePawn)
|
||||
val b = Board(map)
|
||||
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
b.pieces shouldBe map
|
||||
}
|
||||
|
||||
test("initial board has 32 pieces") {
|
||||
test("initial board has expected material and pawn placement") {
|
||||
Board.initial.pieces should have size 32
|
||||
}
|
||||
|
||||
test("initial board has 16 white pieces") {
|
||||
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
||||
}
|
||||
|
||||
test("initial board has 16 black pieces") {
|
||||
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
||||
}
|
||||
|
||||
test("initial board white pawns on rank 2") {
|
||||
File.values.foreach { file =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||
}
|
||||
}
|
||||
|
||||
test("initial board black pawns on rank 7") {
|
||||
File.values.foreach { file =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
||||
}
|
||||
}
|
||||
@@ -101,17 +80,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
}
|
||||
|
||||
test("updated adds or replaces piece at square") {
|
||||
test("updated adds and replaces piece at squares") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val updated = b.updated(e4, Piece.WhiteKnight)
|
||||
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
val added = b.updated(e4, Piece.WhiteKnight)
|
||||
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
|
||||
test("updated replaces existing piece") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val updated = b.updated(e2, Piece.WhiteKnight)
|
||||
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
||||
val replaced = b.updated(e2, Piece.WhiteKnight)
|
||||
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
|
||||
test("removed deletes piece from board") {
|
||||
@@ -120,3 +96,13 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
removed.pieceAt(e2) shouldBe None
|
||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
|
||||
test("applyMove uses move.from and move.to to relocate a piece") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
|
||||
val moved = b.applyMove(Move(e2, e4))
|
||||
|
||||
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||
moved.pieceAt(e2) shouldBe None
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("hasAnyRights and hasRights reflect current flags"):
|
||||
val rights = CastlingRights(
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
)
|
||||
|
||||
rights.hasAnyRights shouldBe true
|
||||
rights.hasRights(Color.White) shouldBe true
|
||||
rights.hasRights(Color.Black) shouldBe true
|
||||
|
||||
CastlingRights.None.hasAnyRights shouldBe false
|
||||
CastlingRights.None.hasRights(Color.White) shouldBe false
|
||||
CastlingRights.None.hasRights(Color.Black) shouldBe false
|
||||
|
||||
test("revokeColor clears both castling sides for selected color"):
|
||||
val all = CastlingRights.All
|
||||
|
||||
val whiteRevoked = all.revokeColor(Color.White)
|
||||
whiteRevoked.whiteKingSide shouldBe false
|
||||
whiteRevoked.whiteQueenSide shouldBe false
|
||||
whiteRevoked.blackKingSide shouldBe true
|
||||
whiteRevoked.blackQueenSide shouldBe true
|
||||
|
||||
val blackRevoked = all.revokeColor(Color.Black)
|
||||
blackRevoked.whiteKingSide shouldBe true
|
||||
blackRevoked.whiteQueenSide shouldBe true
|
||||
blackRevoked.blackKingSide shouldBe false
|
||||
blackRevoked.blackQueenSide shouldBe false
|
||||
|
||||
test("revokeKingSide and revokeQueenSide disable only requested side"):
|
||||
val all = CastlingRights.All
|
||||
|
||||
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
|
||||
whiteKingSideRevoked.whiteKingSide shouldBe false
|
||||
whiteKingSideRevoked.whiteQueenSide shouldBe true
|
||||
|
||||
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
|
||||
whiteQueenSideRevoked.whiteKingSide shouldBe true
|
||||
whiteQueenSideRevoked.whiteQueenSide shouldBe false
|
||||
|
||||
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
|
||||
blackKingSideRevoked.blackKingSide shouldBe false
|
||||
blackKingSideRevoked.blackQueenSide shouldBe true
|
||||
|
||||
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||
|
||||
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ColorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("White.opposite returns Black") {
|
||||
Color.White.opposite shouldBe Color.Black
|
||||
}
|
||||
test("Color values expose opposite and label consistently"):
|
||||
val cases = List(
|
||||
(Color.White, Color.Black, "White"),
|
||||
(Color.Black, Color.White, "Black")
|
||||
)
|
||||
|
||||
test("Black.opposite returns White") {
|
||||
Color.Black.opposite shouldBe Color.White
|
||||
}
|
||||
|
||||
test("White.label returns 'White'") {
|
||||
Color.White.label shouldBe "White"
|
||||
}
|
||||
|
||||
test("Black.label returns 'Black'") {
|
||||
Color.Black.label shouldBe "Black"
|
||||
}
|
||||
cases.foreach { (color, opposite, label) =>
|
||||
color.opposite shouldBe opposite
|
||||
color.label shouldBe label
|
||||
}
|
||||
|
||||
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
|
||||
p.pieceType shouldBe PieceType.Queen
|
||||
}
|
||||
|
||||
test("WhitePawn convenience constant") {
|
||||
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
|
||||
}
|
||||
test("all convenience constants map to expected color and piece type") {
|
||||
val expected = List(
|
||||
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
||||
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
|
||||
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
||||
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
||||
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
|
||||
test("WhiteKnight convenience constant") {
|
||||
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight)
|
||||
}
|
||||
|
||||
test("WhiteBishop convenience constant") {
|
||||
Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop)
|
||||
}
|
||||
|
||||
test("WhiteRook convenience constant") {
|
||||
Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook)
|
||||
}
|
||||
|
||||
test("WhiteQueen convenience constant") {
|
||||
Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen)
|
||||
}
|
||||
|
||||
test("WhiteKing convenience constant") {
|
||||
Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King)
|
||||
}
|
||||
|
||||
test("BlackPawn convenience constant") {
|
||||
Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn)
|
||||
}
|
||||
|
||||
test("BlackKnight convenience constant") {
|
||||
Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight)
|
||||
}
|
||||
|
||||
test("BlackBishop convenience constant") {
|
||||
Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop)
|
||||
}
|
||||
|
||||
test("BlackRook convenience constant") {
|
||||
Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook)
|
||||
}
|
||||
|
||||
test("BlackQueen convenience constant") {
|
||||
Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen)
|
||||
}
|
||||
|
||||
test("BlackKing convenience constant") {
|
||||
Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King)
|
||||
expected.foreach { case (actual, wanted) =>
|
||||
actual shouldBe wanted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Pawn.label returns 'Pawn'") {
|
||||
PieceType.Pawn.label shouldBe "Pawn"
|
||||
}
|
||||
test("PieceType values expose the expected labels"):
|
||||
val expectedLabels = List(
|
||||
PieceType.Pawn -> "Pawn",
|
||||
PieceType.Knight -> "Knight",
|
||||
PieceType.Bishop -> "Bishop",
|
||||
PieceType.Rook -> "Rook",
|
||||
PieceType.Queen -> "Queen",
|
||||
PieceType.King -> "King"
|
||||
)
|
||||
|
||||
test("Knight.label returns 'Knight'") {
|
||||
PieceType.Knight.label shouldBe "Knight"
|
||||
}
|
||||
|
||||
test("Bishop.label returns 'Bishop'") {
|
||||
PieceType.Bishop.label shouldBe "Bishop"
|
||||
}
|
||||
|
||||
test("Rook.label returns 'Rook'") {
|
||||
PieceType.Rook.label shouldBe "Rook"
|
||||
}
|
||||
|
||||
test("Queen.label returns 'Queen'") {
|
||||
PieceType.Queen.label shouldBe "Queen"
|
||||
}
|
||||
|
||||
test("King.label returns 'King'") {
|
||||
PieceType.King.label shouldBe "King"
|
||||
}
|
||||
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||
pieceType.label shouldBe expectedLabel
|
||||
}
|
||||
|
||||
@@ -5,58 +5,33 @@ import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class SquareTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Square.toString produces lowercase file and rank number") {
|
||||
Square(File.E, Rank.R4).toString shouldBe "e4"
|
||||
}
|
||||
|
||||
test("Square.toString for a1") {
|
||||
test("toString renders algebraic notation for edge and middle squares") {
|
||||
Square(File.A, Rank.R1).toString shouldBe "a1"
|
||||
}
|
||||
|
||||
test("Square.toString for h8") {
|
||||
Square(File.E, Rank.R4).toString shouldBe "e4"
|
||||
Square(File.H, Rank.R8).toString shouldBe "h8"
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square e4") {
|
||||
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4))
|
||||
test("fromAlgebraic parses valid coordinates including case-insensitive files") {
|
||||
val expected = List(
|
||||
"a1" -> Square(File.A, Rank.R1),
|
||||
"e4" -> Square(File.E, Rank.R4),
|
||||
"h8" -> Square(File.H, Rank.R8),
|
||||
"E4" -> Square(File.E, Rank.R4)
|
||||
)
|
||||
expected.foreach { case (raw, sq) =>
|
||||
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||
}
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square a1") {
|
||||
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
|
||||
test("fromAlgebraic rejects malformed coordinates") {
|
||||
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
|
||||
Square.fromAlgebraic(raw) shouldBe None
|
||||
}
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square h8") {
|
||||
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
|
||||
test("offset returns Some in-bounds and None out-of-bounds") {
|
||||
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
|
||||
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic is case-insensitive for file") {
|
||||
Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4))
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for empty string") {
|
||||
Square.fromAlgebraic("") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for string too short") {
|
||||
Square.fromAlgebraic("e") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for string too long") {
|
||||
Square.fromAlgebraic("e42") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for invalid file character") {
|
||||
Square.fromAlgebraic("z4") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for non-digit rank") {
|
||||
Square.fromAlgebraic("ex") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for rank 0") {
|
||||
Square.fromAlgebraic("e0") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for rank 9") {
|
||||
Square.fromAlgebraic("e9") shouldBe None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
|
||||
import de.nowchess.api.move.Move
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameContextTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameContext.initial exposes expected default state"):
|
||||
val initial = GameContext.initial
|
||||
|
||||
initial.board shouldBe Board.initial
|
||||
initial.turn shouldBe Color.White
|
||||
initial.castlingRights shouldBe CastlingRights.Initial
|
||||
initial.enPassantSquare shouldBe None
|
||||
initial.halfMoveClock shouldBe 0
|
||||
initial.moves shouldBe List.empty
|
||||
|
||||
test("withBoard updates only board"):
|
||||
val square = Square(File.E, Rank.R4)
|
||||
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
||||
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||
updated.board shouldBe updatedBoard
|
||||
updated.turn shouldBe GameContext.initial.turn
|
||||
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
|
||||
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
|
||||
updated.moves shouldBe GameContext.initial.moves
|
||||
|
||||
test("withers update only targeted fields"):
|
||||
val initial = GameContext.initial
|
||||
val rights = CastlingRights(
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
)
|
||||
val square = Some(Square(File.E, Rank.R3))
|
||||
val updatedTurn = initial.withTurn(Color.Black)
|
||||
val updatedRights = initial.withCastlingRights(rights)
|
||||
val updatedEp = initial.withEnPassantSquare(square)
|
||||
val updatedClock = initial.withHalfMoveClock(17)
|
||||
|
||||
updatedTurn.turn shouldBe Color.Black
|
||||
updatedTurn.board shouldBe initial.board
|
||||
|
||||
updatedRights.castlingRights shouldBe rights
|
||||
updatedRights.turn shouldBe initial.turn
|
||||
|
||||
updatedEp.enPassantSquare shouldBe square
|
||||
updatedEp.castlingRights shouldBe initial.castlingRights
|
||||
|
||||
updatedClock.halfMoveClock shouldBe 17
|
||||
updatedClock.moves shouldBe initial.moves
|
||||
|
||||
test("withMove appends move to history"):
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameStateTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("CastlingRights.None has both flags false") {
|
||||
CastlingRights.None.kingSide shouldBe false
|
||||
CastlingRights.None.queenSide shouldBe false
|
||||
}
|
||||
|
||||
test("CastlingRights.Both has both flags true") {
|
||||
CastlingRights.Both.kingSide shouldBe true
|
||||
CastlingRights.Both.queenSide shouldBe true
|
||||
}
|
||||
|
||||
test("CastlingRights constructor sets fields") {
|
||||
val cr = CastlingRights(kingSide = true, queenSide = false)
|
||||
cr.kingSide shouldBe true
|
||||
cr.queenSide shouldBe false
|
||||
}
|
||||
|
||||
test("GameResult cases exist") {
|
||||
GameResult.WhiteWins shouldBe GameResult.WhiteWins
|
||||
GameResult.BlackWins shouldBe GameResult.BlackWins
|
||||
GameResult.Draw shouldBe GameResult.Draw
|
||||
}
|
||||
|
||||
test("GameStatus.NotStarted") {
|
||||
GameStatus.NotStarted shouldBe GameStatus.NotStarted
|
||||
}
|
||||
|
||||
test("GameStatus.InProgress") {
|
||||
GameStatus.InProgress shouldBe GameStatus.InProgress
|
||||
}
|
||||
|
||||
test("GameStatus.Finished carries result") {
|
||||
val status = GameStatus.Finished(GameResult.Draw)
|
||||
status shouldBe GameStatus.Finished(GameResult.Draw)
|
||||
status match
|
||||
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
|
||||
case _ => fail("expected Finished")
|
||||
}
|
||||
|
||||
test("GameState.initial has standard FEN piece placement") {
|
||||
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
}
|
||||
|
||||
test("GameState.initial active color is White") {
|
||||
GameState.initial.activeColor shouldBe Color.White
|
||||
}
|
||||
|
||||
test("GameState.initial white has full castling rights") {
|
||||
GameState.initial.castlingWhite shouldBe CastlingRights.Both
|
||||
}
|
||||
|
||||
test("GameState.initial black has full castling rights") {
|
||||
GameState.initial.castlingBlack shouldBe CastlingRights.Both
|
||||
}
|
||||
|
||||
test("GameState.initial en-passant target is None") {
|
||||
GameState.initial.enPassantTarget shouldBe None
|
||||
}
|
||||
|
||||
test("GameState.initial half-move clock is 0") {
|
||||
GameState.initial.halfMoveClock shouldBe 0
|
||||
}
|
||||
|
||||
test("GameState.initial full-move number is 1") {
|
||||
GameState.initial.fullMoveNumber shouldBe 1
|
||||
}
|
||||
|
||||
test("GameState.initial status is InProgress") {
|
||||
GameState.initial.status shouldBe GameStatus.InProgress
|
||||
}
|
||||
@@ -9,48 +9,26 @@ class MoveTest extends AnyFunSuite with Matchers:
|
||||
private val e2 = Square(File.E, Rank.R2)
|
||||
private val e4 = Square(File.E, Rank.R4)
|
||||
|
||||
test("Move defaults moveType to Normal") {
|
||||
val m = Move(e2, e4)
|
||||
m.moveType shouldBe MoveType.Normal
|
||||
}
|
||||
|
||||
test("Move stores from and to squares") {
|
||||
test("Move defaults to Normal and keeps from/to squares") {
|
||||
val m = Move(e2, e4)
|
||||
m.from shouldBe e2
|
||||
m.to shouldBe e4
|
||||
m.to shouldBe e4
|
||||
m.moveType shouldBe MoveType.Normal()
|
||||
}
|
||||
|
||||
test("Move with CastleKingside moveType") {
|
||||
val m = Move(e2, e4, MoveType.CastleKingside)
|
||||
m.moveType shouldBe MoveType.CastleKingside
|
||||
}
|
||||
test("Move accepts all supported move types") {
|
||||
val moveTypes = List(
|
||||
MoveType.Normal(isCapture = true),
|
||||
MoveType.CastleKingside,
|
||||
MoveType.CastleQueenside,
|
||||
MoveType.EnPassant,
|
||||
MoveType.Promotion(PromotionPiece.Queen),
|
||||
MoveType.Promotion(PromotionPiece.Rook),
|
||||
MoveType.Promotion(PromotionPiece.Bishop),
|
||||
MoveType.Promotion(PromotionPiece.Knight)
|
||||
)
|
||||
|
||||
test("Move with CastleQueenside moveType") {
|
||||
val m = Move(e2, e4, MoveType.CastleQueenside)
|
||||
m.moveType shouldBe MoveType.CastleQueenside
|
||||
}
|
||||
|
||||
test("Move with EnPassant moveType") {
|
||||
val m = Move(e2, e4, MoveType.EnPassant)
|
||||
m.moveType shouldBe MoveType.EnPassant
|
||||
}
|
||||
|
||||
test("Move with Promotion to Queen") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Knight") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Bishop") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Rook") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
moveTypes.foreach { moveType =>
|
||||
Move(e2, e4, moveType).moveType shouldBe moveType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PlayerId.apply wraps a string") {
|
||||
val id = PlayerId("player-123")
|
||||
id.value shouldBe "player-123"
|
||||
}
|
||||
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||
val raw = "player-123"
|
||||
val id = PlayerId(raw)
|
||||
|
||||
test("PlayerId.value unwraps to original string") {
|
||||
val raw = "abc-456"
|
||||
PlayerId(raw).value shouldBe raw
|
||||
}
|
||||
id.value shouldBe raw
|
||||
|
||||
test("PlayerInfo holds id and displayName") {
|
||||
val id = PlayerId("p1")
|
||||
val info = PlayerInfo(id, "Magnus")
|
||||
val playerId = PlayerId("p1")
|
||||
val info = PlayerInfo(playerId, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
}
|
||||
|
||||
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("ApiResponse.Success carries data") {
|
||||
test("ApiResponse factories and payload wrappers keep values") {
|
||||
val r = ApiResponse.Success(42)
|
||||
r.data shouldBe 42
|
||||
}
|
||||
|
||||
test("ApiResponse.Failure carries error list") {
|
||||
val err = ApiError("CODE", "msg")
|
||||
val r = ApiResponse.Failure(List(err))
|
||||
r.errors shouldBe List(err)
|
||||
}
|
||||
ApiResponse.Failure(List(err)).errors shouldBe List(err)
|
||||
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||
|
||||
test("ApiResponse.error creates single-error Failure") {
|
||||
val err = ApiError("NOT_FOUND", "not found")
|
||||
val f = ApiResponse.error(err)
|
||||
f shouldBe ApiResponse.Failure(List(err))
|
||||
}
|
||||
|
||||
test("ApiError holds code and message") {
|
||||
val e = ApiError("CODE", "message")
|
||||
e.code shouldBe "CODE"
|
||||
e.message shouldBe "message"
|
||||
e.field shouldBe None
|
||||
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
||||
}
|
||||
|
||||
test("ApiError holds optional field") {
|
||||
val e = ApiError("INVALID", "bad value", Some("email"))
|
||||
e.field shouldBe Some("email")
|
||||
}
|
||||
|
||||
test("Pagination.totalPages with exact division") {
|
||||
test("Pagination.totalPages handles normal and guarded inputs") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
||||
}
|
||||
|
||||
test("Pagination.totalPages rounds up") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when totalItems is 0") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when pageSize is 0") {
|
||||
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when pageSize is negative") {
|
||||
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=8
|
||||
MINOR=2
|
||||
PATCH=0
|
||||
|
||||
@@ -142,3 +142,49 @@
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
## (2026-04-02)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
## (2026-04-03)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
application
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
@@ -22,19 +21,10 @@ scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("de.nowchess.chess.Main")
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
tasks.named<JavaExec>("run") {
|
||||
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
||||
standardInput = System.`in`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
@@ -49,6 +39,8 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:io"))
|
||||
implementation(project(":modules:rule"))
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.api.board.{Square, Piece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone.
|
||||
* Commands encapsulate user actions and game state transitions.
|
||||
@@ -23,23 +23,22 @@ case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = ""
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
moveResult.isDefined
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||
previousContext.isDefined
|
||||
|
||||
override def description: String = s"Move from $from to $to"
|
||||
|
||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
|
||||
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
@@ -51,14 +50,12 @@ case class QuitCommand() extends Command:
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
previousContext: Option[GameContext] = None
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||
previousContext.isDefined
|
||||
|
||||
override def description: String = "Reset board"
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.*
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result ADT returned by the pure processMove function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case object Quit extends MoveResult
|
||||
case class InvalidFormat(raw: String) extends MoveResult
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove extends MoveResult
|
||||
case class PromotionRequired(
|
||||
from: Square,
|
||||
to: Square,
|
||||
boardBefore: Board,
|
||||
historyBefore: GameHistory,
|
||||
captured: Option[Piece],
|
||||
turn: Color
|
||||
) extends MoveResult
|
||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Checkmate(winner: Color) extends MoveResult
|
||||
case object Stalemate extends MoveResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
object GameController:
|
||||
|
||||
/** Pure function: interprets one raw input line against the current game context.
|
||||
* Has no I/O side effects — all output must be handled by the caller.
|
||||
*/
|
||||
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||
raw.trim match
|
||||
case "quit" | "q" => MoveResult.Quit
|
||||
case trimmed =>
|
||||
Parser.parseMove(trimmed) match
|
||||
case None => MoveResult.InvalidFormat(trimmed)
|
||||
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
|
||||
|
||||
/** Apply a previously detected promotion move with the chosen piece.
|
||||
* Called after processMove returned PromotionRequired.
|
||||
*/
|
||||
def completePromotion(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
from: Square,
|
||||
to: Square,
|
||||
piece: PromotionPiece,
|
||||
turn: Color
|
||||
): MoveResult =
|
||||
val (boardAfterMove, captured) = board.withMove(from, to)
|
||||
val promotedPieceType = piece match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||
// Promotion is always a pawn move → clock resets
|
||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||
board.pieceAt(from) match
|
||||
case None => MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn => MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !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 pieceType = board.pieceAt(from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
||||
val wasPawnMove = pieceType == PieceType.Pawn
|
||||
val wasCapture = captured.isDefined
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
|
||||
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,47 +1,37 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
||||
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||
import de.nowchess.io.{GameContextImport, GameContextExport}
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
||||
* This class is the single source of truth for the game state.
|
||||
* All user interactions must go through this engine via Commands, and all state changes
|
||||
* are communicated to observers via GameEvent notifications.
|
||||
* All rule queries delegate to the injected RuleSet.
|
||||
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
*/
|
||||
class GameEngine(
|
||||
initialBoard: Board = Board.initial,
|
||||
initialHistory: GameHistory = GameHistory.empty,
|
||||
initialTurn: Color = Color.White,
|
||||
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
||||
GameController.completePromotion
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules
|
||||
) extends Observable:
|
||||
private var currentBoard: Board = initialBoard
|
||||
private var currentHistory: GameHistory = initialHistory
|
||||
private var currentTurn: Color = initialTurn
|
||||
private var currentContext: GameContext = initialContext
|
||||
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 */
|
||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized { currentBoard }
|
||||
def history: GameHistory = synchronized { currentHistory }
|
||||
def turn: Color = synchronized { currentTurn }
|
||||
def board: Board = synchronized { currentContext.board }
|
||||
def turn: Color = synchronized { currentContext.turn }
|
||||
def context: GameContext = synchronized { currentContext }
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||
@@ -59,7 +49,6 @@ class GameEngine(
|
||||
val trimmed = rawInput.trim.toLowerCase
|
||||
trimmed match
|
||||
case "quit" | "q" =>
|
||||
// Client should handle quit logic; we just return
|
||||
()
|
||||
|
||||
case "undo" =>
|
||||
@@ -69,96 +58,55 @@ class GameEngine(
|
||||
performRedo()
|
||||
|
||||
case "draw" =>
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
if currentContext.halfMoveClock >= 100 then
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
||||
notifyObservers(DrawClaimedEvent(currentContext))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
currentContext,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
|
||||
case "" =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Please enter a valid move or command."
|
||||
)
|
||||
notifyObservers(event)
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||
|
||||
case moveInput =>
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
currentContext,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
))
|
||||
case Some((from, to)) =>
|
||||
handleParsedMove(from, to, moveInput)
|
||||
handleParsedMove(from, to)
|
||||
}
|
||||
|
||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
private def handleParsedMove(from: Square, to: Square): Unit =
|
||||
currentContext.board.pieceAt(from) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
|
||||
case Some(piece) if piece.color != currentContext.turn =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
|
||||
case Some(piece) =>
|
||||
val legal = ruleSet.legalMoves(currentContext)(from)
|
||||
// Find all legal moves going to `to`
|
||||
val candidates = legal.filter(_.to == to)
|
||||
candidates match
|
||||
case Nil =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
|
||||
case moves if isPromotionMove(piece, to) =>
|
||||
// Multiple moves (one per promotion piece) — ask user to choose
|
||||
val contextBefore = currentContext
|
||||
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
|
||||
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
|
||||
case move :: _ =>
|
||||
executeMove(move)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized {
|
||||
performUndo()
|
||||
}
|
||||
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized {
|
||||
performRedo()
|
||||
}
|
||||
private def isPromotionMove(piece: Piece, to: Square): Boolean =
|
||||
piece.pieceType == PieceType.Pawn && {
|
||||
val promoRank = if piece.color == Color.White then 7 else 0
|
||||
to.rank.ordinal == promoRank
|
||||
}
|
||||
|
||||
/** Apply a player's promotion piece choice.
|
||||
* Must only be called when isPendingPromotion is true.
|
||||
@@ -166,187 +114,205 @@ class GameEngine(
|
||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||
pendingPromotion match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "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."))
|
||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||
// Verify it's actually legal
|
||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||
if legal.contains(move) then
|
||||
executeMove(move)
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Validate and load a PGN string.
|
||||
* Each move is replayed through the command system so undo/redo is available after loading.
|
||||
* Returns Right(()) on success; Left(error) if any move is illegal or the position impossible. */
|
||||
def loadPgn(pgn: String): Either[String, Unit] = synchronized {
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(err) =>
|
||||
Left(err)
|
||||
case Right(game) =>
|
||||
val initialBoardBeforeLoad = currentBoard
|
||||
val initialHistoryBeforeLoad = currentHistory
|
||||
val initialTurnBeforeLoad = currentTurn
|
||||
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized { performUndo() }
|
||||
|
||||
var error: Option[String] = None
|
||||
import scala.util.control.Breaks._
|
||||
breakable {
|
||||
game.moves.foreach { move =>
|
||||
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
|
||||
move.promotionPiece.foreach(completePromotion)
|
||||
|
||||
// If the move failed to execute properly, stop and report
|
||||
// (validatePgn should have caught this, but we're being safe)
|
||||
if pendingPromotion.isDefined && move.promotionPiece.isEmpty then
|
||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||
break()
|
||||
}
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized { performRedo() }
|
||||
|
||||
/** Load a game using the provided importer.
|
||||
* If the imported context has moves, they are replayed through the command system.
|
||||
* Otherwise, the position is set directly.
|
||||
* Notifies observers with PgnLoadedEvent on success.
|
||||
*/
|
||||
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||
importer.importGameContext(input) match
|
||||
case Left(err) => Left(err)
|
||||
case Right(ctx) =>
|
||||
replayGame(ctx).map { _ =>
|
||||
notifyObservers(PgnLoadedEvent(currentContext))
|
||||
}
|
||||
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentBoard = initialBoardBeforeLoad
|
||||
currentHistory = initialHistoryBeforeLoad
|
||||
currentTurn = initialTurnBeforeLoad
|
||||
Left(err)
|
||||
case None =>
|
||||
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
|
||||
Right(())
|
||||
}
|
||||
|
||||
private def replayGame(ctx: GameContext): Either[String, Unit] =
|
||||
val savedContext = currentContext
|
||||
currentContext = GameContext.initial
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
|
||||
if ctx.moves.isEmpty then
|
||||
currentContext = ctx
|
||||
Right(())
|
||||
else
|
||||
replayMoves(ctx.moves, savedContext)
|
||||
|
||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||
var error: Option[String] = None
|
||||
moves.foreach: move =>
|
||||
if error.isEmpty then
|
||||
handleParsedMove(move.from, move.to)
|
||||
|
||||
move.moveType match {
|
||||
case MoveType.Promotion(pp) =>
|
||||
if pendingPromotion.isDefined then
|
||||
completePromotion(pp)
|
||||
else
|
||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||
case _ => ()
|
||||
}
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentContext = savedContext
|
||||
Left(err)
|
||||
case None =>
|
||||
Right(())
|
||||
|
||||
/** Export the current game context using the provided exporter. */
|
||||
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||
exporter.exportGameContext(currentContext)
|
||||
}
|
||||
|
||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||
def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized {
|
||||
currentBoard = board
|
||||
currentHistory = history
|
||||
currentTurn = turn
|
||||
def loadPosition(newContext: GameContext): Unit = synchronized {
|
||||
currentContext = newContext
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
|
||||
/** Reset the board to initial position. */
|
||||
def reset(): Unit = synchronized {
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
currentContext = GameContext.initial
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn
|
||||
))
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
|
||||
// ──── Private Helpers ────
|
||||
// ──── Private helpers ────
|
||||
|
||||
private def executeMove(move: Move): Unit =
|
||||
val contextBefore = currentContext
|
||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||
val captured = computeCaptured(currentContext, move)
|
||||
|
||||
val cmd = MoveCommand(
|
||||
from = move.from,
|
||||
to = move.to,
|
||||
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||
previousContext = Some(contextBefore),
|
||||
notation = translateMoveToNotation(move, contextBefore.board)
|
||||
)
|
||||
invoker.execute(cmd)
|
||||
currentContext = nextContext
|
||||
|
||||
notifyObservers(MoveExecutedEvent(
|
||||
currentContext,
|
||||
move.from.toString,
|
||||
move.to.toString,
|
||||
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
))
|
||||
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
invoker.clear()
|
||||
currentContext = GameContext.initial
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
notifyObservers(StalemateEvent(currentContext))
|
||||
invoker.clear()
|
||||
currentContext = GameContext.initial
|
||||
else if ruleSet.isCheck(currentContext) then
|
||||
notifyObservers(CheckDetectedEvent(currentContext))
|
||||
|
||||
if currentContext.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
|
||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => enPassantNotation(move)
|
||||
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
||||
|
||||
private def enPassantNotation(move: Move): String =
|
||||
s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
|
||||
private def promotionNotation(move: Move, piece: PromotionPiece): String =
|
||||
val ppChar = piece match
|
||||
case PromotionPiece.Queen => "Q"
|
||||
case PromotionPiece.Rook => "R"
|
||||
case PromotionPiece.Bishop => "B"
|
||||
case PromotionPiece.Knight => "N"
|
||||
s"${move.to}=$ppChar"
|
||||
|
||||
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
|
||||
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||
case Some(PieceType.Pawn) =>
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
else move.to.toString
|
||||
case Some(pt) =>
|
||||
val letter = pieceNotation(pt)
|
||||
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
|
||||
case None => move.to.toString
|
||||
|
||||
private[engine] def pieceNotation(pieceType: PieceType): String =
|
||||
pieceType match
|
||||
case PieceType.Knight => "N"
|
||||
case PieceType.Bishop => "B"
|
||||
case PieceType.Rook => "R"
|
||||
case PieceType.Queen => "Q"
|
||||
case PieceType.King => "K"
|
||||
case _ => ""
|
||||
|
||||
private def computeCaptured(context: GameContext, move: Move): Option[Piece] =
|
||||
move.moveType match
|
||||
case MoveType.EnPassant =>
|
||||
// Captured pawn is on the same rank as the moving pawn, same file as destination
|
||||
val capturedSquare = Square(move.to.file, move.from.rank)
|
||||
context.board.pieceAt(capturedSquare)
|
||||
case MoveType.CastleKingside | MoveType.CastleQueenside =>
|
||||
None
|
||||
case _ =>
|
||||
context.board.pieceAt(move.to)
|
||||
|
||||
private def performUndo(): Unit =
|
||||
if invoker.canUndo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
||||
moveCmd.previousContext.foreach(currentContext = _)
|
||||
invoker.undo()
|
||||
notifyObservers(MoveUndoneEvent(currentBoard, currentHistory, currentTurn, notation))
|
||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||
|
||||
private def performRedo(): Unit =
|
||||
if invoker.canRedo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
||||
updateGameState(nb, nh, nt)
|
||||
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
|
||||
currentContext = nextCtx
|
||||
invoker.redo()
|
||||
val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveRedoneEvent(currentBoard, currentHistory, currentTurn, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
|
||||
notifyObservers(MoveRedoneEvent(
|
||||
currentContext,
|
||||
moveCmd.notation,
|
||||
moveCmd.from.toString,
|
||||
moveCmd.to.toString,
|
||||
capturedDesc
|
||||
))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
||||
|
||||
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
|
||||
currentBoard = newBoard
|
||||
currentHistory = newHistory
|
||||
currentTurn = newTurn
|
||||
|
||||
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
|
||||
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveExecutedEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
newTurn,
|
||||
fromSq,
|
||||
toSq,
|
||||
capturedDesc
|
||||
))
|
||||
|
||||
private def handleFailedMove(moveInput: String): Unit =
|
||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
||||
case MoveResult.NoPiece =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"No piece on that square."
|
||||
))
|
||||
case MoveResult.WrongColor =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"That is not your piece."
|
||||
))
|
||||
case MoveResult.IllegalMove =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Illegal move."
|
||||
))
|
||||
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
enum CastleSide:
|
||||
case Kingside, Queenside
|
||||
|
||||
extension (b: Board)
|
||||
def withCastle(color: Color, side: CastleSide): Board =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val kingFrom = Square(File.E, rank)
|
||||
val (kingTo, rookFrom, rookTo) = side match
|
||||
case CastleSide.Kingside =>
|
||||
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
case CastleSide.Queenside =>
|
||||
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
|
||||
val king = b.pieceAt(kingFrom).get
|
||||
val rook = b.pieceAt(rookFrom).get
|
||||
|
||||
b.removed(kingFrom).removed(rookFrom)
|
||||
.updated(kingTo, king)
|
||||
.updated(rookTo, rook)
|
||||
@@ -1,31 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
|
||||
/** Derives castling rights from move history. */
|
||||
object CastlingRightsCalculator:
|
||||
|
||||
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
|
||||
val (kingRow, kingsideRookFile, queensideRookFile) = color match
|
||||
case Color.White => (Rank.R1, File.H, File.A)
|
||||
case Color.Black => (Rank.R8, File.H, File.A)
|
||||
|
||||
// Check if king has moved
|
||||
val kingHasMoved = history.moves.exists: move =>
|
||||
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
|
||||
|
||||
if kingHasMoved then
|
||||
CastlingRights.None
|
||||
else
|
||||
// Check if kingside rook has moved or was captured
|
||||
val kingsideLost = history.moves.exists: move =>
|
||||
move.from == Square(kingsideRookFile, kingRow) ||
|
||||
move.to == Square(kingsideRookFile, kingRow)
|
||||
|
||||
// Check if queenside rook has moved or was captured
|
||||
val queensideLost = history.moves.exists: move =>
|
||||
move.from == Square(queensideRookFile, kingRow) ||
|
||||
move.to == Square(queensideRookFile, kingRow)
|
||||
|
||||
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
|
||||
@@ -1,32 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
object EnPassantCalculator:
|
||||
|
||||
/** Returns the en passant target square if the last move was a double pawn push.
|
||||
* The target is the square the pawn passed through (e.g. e2→e4 yields e3).
|
||||
*/
|
||||
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
|
||||
history.moves.lastOption.flatMap: move =>
|
||||
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
|
||||
val isDoublePush = math.abs(rankDiff) == 2
|
||||
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
|
||||
if isDoublePush && isPawn then
|
||||
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
|
||||
Some(Square(move.to.file, Rank.values(midRankIdx)))
|
||||
else None
|
||||
|
||||
/** True if moving from→to is an en passant capture. */
|
||||
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
|
||||
enPassantTarget(board, history).contains(to) &&
|
||||
math.abs(to.file.ordinal - from.file.ordinal) == 1
|
||||
|
||||
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
|
||||
* White captures upward → captured pawn is one rank below `to`.
|
||||
* Black captures downward → captured pawn is one rank above `to`.
|
||||
*/
|
||||
def capturedPawnSquare(to: Square, color: Color): Square =
|
||||
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
|
||||
Square(to.file, Rank.values(capturedRankIdx))
|
||||
@@ -1,49 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{PieceType, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
||||
case class HistoryMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide],
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
pieceType: PieceType = PieceType.Pawn,
|
||||
isCapture: Boolean = false
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
||||
*
|
||||
* @param moves moves played so far, oldest first
|
||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
||||
*/
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
||||
|
||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
||||
*/
|
||||
def addMove(move: HistoryMove): GameHistory =
|
||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
||||
|
||||
/** Add a move by coordinates.
|
||||
*
|
||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
||||
*
|
||||
* If neither flag is set the clock increments by 1.
|
||||
*/
|
||||
def addMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
wasPawnMove: Boolean = false,
|
||||
wasCapture: Boolean = false,
|
||||
pieceType: PieceType = PieceType.Pawn
|
||||
): GameHistory =
|
||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece, pieceType, wasCapture), newClock)
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
@@ -1,47 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
|
||||
object GameRules:
|
||||
|
||||
/** True if `color`'s king is under attack on this board. */
|
||||
def isInCheck(board: Board, color: Color): Boolean =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
|
||||
.exists { kingSq =>
|
||||
board.pieces.exists { case (sq, piece) =>
|
||||
piece.color != color &&
|
||||
MoveValidator.legalTargets(board, sq).contains(kingSq)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)] =
|
||||
board.pieces
|
||||
.collect { case (from, piece) if piece.color == color => from }
|
||||
.flatMap { from =>
|
||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
||||
.filter { to =>
|
||||
val newBoard =
|
||||
if MoveValidator.isCastle(board, from, to) then
|
||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
else
|
||||
board.withMove(from, to)._1
|
||||
!isInCheck(newBoard, color)
|
||||
}
|
||||
.map(to => from -> to)
|
||||
}
|
||||
.toSet
|
||||
|
||||
/** Position status for the side whose turn it is (`color`). */
|
||||
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
|
||||
val moves = legalMoves(board, history, color)
|
||||
val inCheck = isInCheck(board, color)
|
||||
if moves.isEmpty && inCheck then PositionStatus.Mated
|
||||
else if moves.isEmpty then PositionStatus.Drawn
|
||||
else if inCheck then PositionStatus.InCheck
|
||||
else PositionStatus.Normal
|
||||
@@ -1,183 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
|
||||
object MoveValidator:
|
||||
|
||||
/** Returns true if the move is geometrically legal for the piece on `from`,
|
||||
* ignoring check/pin but respecting:
|
||||
* - correct movement pattern for the piece type
|
||||
* - cannot capture own pieces
|
||||
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
|
||||
*/
|
||||
def isLegal(board: Board, from: Square, to: Square): Boolean =
|
||||
legalTargets(board, from).contains(to)
|
||||
|
||||
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
|
||||
def legalTargets(board: Board, from: Square): Set[Square] =
|
||||
board.pieceAt(from) match
|
||||
case None => Set.empty
|
||||
case Some(piece) =>
|
||||
piece.pieceType match
|
||||
case PieceType.Pawn => pawnTargets(board, from, piece.color)
|
||||
case PieceType.Knight => knightTargets(board, from, piece.color)
|
||||
case PieceType.Bishop => slide(board, from, piece.color, diagonalDeltas)
|
||||
case PieceType.Rook => slide(board, from, piece.color, orthogonalDeltas)
|
||||
case PieceType.Queen => slide(board, from, piece.color, diagonalDeltas ++ orthogonalDeltas)
|
||||
case PieceType.King => kingTargets(board, from, piece.color)
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private val diagonalDeltas: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
||||
private val orthogonalDeltas: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val knightDeltas: List[(Int, Int)] =
|
||||
List((1, 2), (1, -2), (-1, 2), (-1, -2), (2, 1), (2, -1), (-2, 1), (-2, -1))
|
||||
|
||||
/** Try to construct a Square from integer file/rank indices (0-based). */
|
||||
private def squareAt(fileIdx: Int, rankIdx: Int): Option[Square] =
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
||||
)
|
||||
|
||||
/** True when `sq` is occupied by a piece of `color`. */
|
||||
private def isOwnPiece(board: Board, sq: Square, color: Color): Boolean =
|
||||
board.pieceAt(sq).exists(_.color == color)
|
||||
|
||||
/** True when `sq` is occupied by a piece of the opposite color. */
|
||||
private def isEnemyPiece(board: Board, sq: Square, color: Color): Boolean =
|
||||
board.pieceAt(sq).exists(_.color != color)
|
||||
|
||||
/** Sliding move generation along a list of direction deltas.
|
||||
* Each direction continues until the board edge, an own piece, or the first
|
||||
* enemy piece (which is included as a capture target).
|
||||
*/
|
||||
private def slide(board: Board, from: Square, color: Color, deltas: List[(Int, Int)]): Set[Square] =
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
deltas.flatMap: (df, dr) =>
|
||||
Iterator
|
||||
.iterate((fi + df, ri + dr)) { case (f, r) => (f + df, r + dr) }
|
||||
.takeWhile { case (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 }
|
||||
.map { case (f, r) => Square(File.values(f), Rank.values(r)) }
|
||||
.foldLeft((List.empty[Square], false)):
|
||||
case ((acc, stopped), sq) =>
|
||||
if stopped then (acc, true)
|
||||
else if isOwnPiece(board, sq, color) then (acc, true) // blocked — stop, no capture
|
||||
else if isEnemyPiece(board, sq, color) then (acc :+ sq, true) // capture — stop after
|
||||
else (acc :+ sq, false) // empty — continue
|
||||
._1
|
||||
.toSet
|
||||
|
||||
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
val dir = if color == Color.White then 1 else -1
|
||||
val startRank = if color == Color.White then Rank.R2.ordinal else Rank.R7.ordinal
|
||||
|
||||
val oneStep = squareAt(fi, ri + dir)
|
||||
|
||||
// Forward one square (only if empty)
|
||||
val forward1: Set[Square] = oneStep match
|
||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
||||
case _ => Set.empty
|
||||
|
||||
// Forward two squares from starting rank (only if both intermediate squares are empty)
|
||||
val forward2: Set[Square] =
|
||||
if ri == startRank && forward1.nonEmpty then
|
||||
squareAt(fi, ri + 2 * dir) match
|
||||
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
|
||||
case _ => Set.empty
|
||||
else Set.empty
|
||||
|
||||
// Diagonal captures (only if enemy piece present)
|
||||
val captures: Set[Square] =
|
||||
List(-1, 1).flatMap: df =>
|
||||
squareAt(fi + df, ri + dir).filter(sq => isEnemyPiece(board, sq, color))
|
||||
.toSet
|
||||
|
||||
forward1 ++ forward2 ++ captures
|
||||
|
||||
private def knightTargets(board: Board, from: Square, color: Color): Set[Square] =
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
knightDeltas.flatMap: (df, dr) =>
|
||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
||||
.toSet
|
||||
|
||||
private def kingTargets(board: Board, from: Square, color: Color): Set[Square] =
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
||||
.toSet
|
||||
|
||||
// ── Castling helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
|
||||
board.pieces.exists { case (from, piece) =>
|
||||
piece.color == attackerColor && legalTargets(board, from).contains(sq)
|
||||
}
|
||||
|
||||
def isCastle(board: Board, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
|
||||
math.abs(to.file.ordinal - from.file.ordinal) == 2
|
||||
|
||||
def castleSide(from: Square, to: Square): CastleSide =
|
||||
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
|
||||
|
||||
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val kingSq = Square(File.E, rank)
|
||||
val enemy = color.opposite
|
||||
|
||||
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
|
||||
GameRules.isInCheck(board, color) then Set.empty
|
||||
else
|
||||
val kingsideSq = Option.when(
|
||||
rights.kingSide &&
|
||||
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.G, rank))
|
||||
|
||||
val queensideSq = Option.when(
|
||||
rights.queenSide &&
|
||||
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.C, rank))
|
||||
|
||||
kingsideSq.toSet ++ queensideSq.toSet
|
||||
|
||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
||||
board.pieceAt(from) match
|
||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
||||
case Some(piece) if piece.pieceType == PieceType.Pawn =>
|
||||
pawnTargets(board, history, from, piece.color)
|
||||
case _ =>
|
||||
legalTargets(board, from)
|
||||
|
||||
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
|
||||
val existing = pawnTargets(board, from, color)
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
val dir = if color == Color.White then 1 else -1
|
||||
val epCapture: Set[Square] =
|
||||
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
|
||||
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
|
||||
.toSet
|
||||
existing ++ epCapture
|
||||
|
||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
legalTargets(board, history, from).contains(to)
|
||||
|
||||
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
|
||||
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from) match
|
||||
case Some(Piece(_, PieceType.Pawn)) =>
|
||||
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
|
||||
(from.rank == Rank.R2 && to.rank == Rank.R1)
|
||||
case _ => false
|
||||
@@ -1,54 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.{PieceType, *}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
||||
|
||||
object PgnExporter:
|
||||
|
||||
/** Export a game with headers and history to PGN format. */
|
||||
def exportGame(headers: Map[String, String], history: GameHistory): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if history.moves.isEmpty then ""
|
||||
else
|
||||
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a HistoryMove to Standard Algebraic Notation. */
|
||||
def moveToAlgebraic(move: HistoryMove): String =
|
||||
move.castleSide match
|
||||
case Some(CastleSide.Kingside) => "O-O"
|
||||
case Some(CastleSide.Queenside) => "O-O-O"
|
||||
case None =>
|
||||
val dest = move.to.toString
|
||||
val capStr = if move.isCapture then "x" else ""
|
||||
val promSuffix = move.promotionPiece match
|
||||
case Some(PromotionPiece.Queen) => "=Q"
|
||||
case Some(PromotionPiece.Rook) => "=R"
|
||||
case Some(PromotionPiece.Bishop) => "=B"
|
||||
case Some(PromotionPiece.Knight) => "=N"
|
||||
case None => ""
|
||||
move.pieceType match
|
||||
case PieceType.Pawn =>
|
||||
if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix"
|
||||
else s"$dest$promSuffix"
|
||||
case PieceType.Knight => s"N$capStr$dest$promSuffix"
|
||||
case PieceType.Bishop => s"B$capStr$dest$promSuffix"
|
||||
case PieceType.Rook => s"R$capStr$dest$promSuffix"
|
||||
case PieceType.Queen => s"Q$capStr$dest$promSuffix"
|
||||
case PieceType.King => s"K$capStr$dest$promSuffix"
|
||||
@@ -1,267 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
||||
|
||||
/** A parsed PGN game containing headers and the resolved move list. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[HistoryMove]
|
||||
)
|
||||
|
||||
object PgnParser:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
|
||||
Some(PgnGame(headers, moves))
|
||||
|
||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
|
||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
||||
|
||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
|
||||
// Fold over tokens, threading (board, history, currentColor, accumulator)
|
||||
val (_, _, _, moves) = tokens.foldLeft(
|
||||
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
|
||||
):
|
||||
case (state @ (board, history, color, acc), token) =>
|
||||
// Skip move-number markers (e.g. "1.", "2.") and result tokens
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, board, history, color) match
|
||||
case None => state // unrecognised token — skip silently
|
||||
case Some(move) =>
|
||||
val newBoard = applyMoveToBoard(board, move, color)
|
||||
val newHistory = history.addMove(move)
|
||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
||||
|
||||
moves
|
||||
|
||||
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
||||
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
||||
move.castleSide match
|
||||
case Some(side) => board.withCastle(color, side)
|
||||
case None =>
|
||||
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
||||
move.promotionPiece match
|
||||
case Some(pp) =>
|
||||
val pieceType = pp match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
||||
case None => boardAfterMove
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
|
||||
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside), pieceType = PieceType.King))
|
||||
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside), pieceType = PieceType.King))
|
||||
|
||||
case _ =>
|
||||
parseRegularMove(notation, board, history, color)
|
||||
|
||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
||||
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
// The destination square is always the last two characters
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
|
||||
|
||||
// Determine required piece type: upper-case first char = piece letter; else pawn
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
// Collect the disambiguation hint that remains after stripping the piece letter
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig // hint is file/rank info or empty
|
||||
|
||||
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
|
||||
// We prefer pieces that can actually reach the target; if none can (positionally illegal
|
||||
// PGN input), fall back to any piece of the matching type belonging to `color`.
|
||||
val reachable: Set[Square] =
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color &&
|
||||
MoveValidator.legalTargets(board, from).contains(toSquare) => from
|
||||
}.toSet
|
||||
|
||||
val candidates: Set[Square] =
|
||||
if reachable.nonEmpty then reachable
|
||||
else
|
||||
// Fallback for positionally-illegal but syntactically valid PGN notation:
|
||||
// find any piece of `color` with the correct piece type on the board.
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color => from
|
||||
}.toSet
|
||||
|
||||
// Filter by required piece type
|
||||
val byPiece = candidates.filter(from =>
|
||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
||||
)
|
||||
|
||||
// Apply disambiguation hint (file letter or rank digit)
|
||||
val disambiguated =
|
||||
if hint.isEmpty then byPiece
|
||||
else byPiece.filter(from => matchesHint(from, hint))
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
||||
val moveIsCapture = notation.contains('x')
|
||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
||||
|
||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||
else true)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||
val promotionPattern = """=([A-Z])""".r
|
||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
||||
m.group(1) match
|
||||
case "Q" => Some(PromotionPiece.Queen)
|
||||
case "R" => Some(PromotionPiece.Rook)
|
||||
case "B" => Some(PromotionPiece.Bishop)
|
||||
case "N" => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
}
|
||||
|
||||
/** Convert a piece-letter character to a PieceType. */
|
||||
private def charToPieceType(c: Char): Option[PieceType] =
|
||||
c match
|
||||
case 'N' => Some(PieceType.Knight)
|
||||
case 'B' => Some(PieceType.Bishop)
|
||||
case 'R' => Some(PieceType.Rook)
|
||||
case 'Q' => Some(PieceType.Queen)
|
||||
case 'K' => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])): Either[String, (Board, GameHistory, Color, List[HistoryMove])]) {
|
||||
case (acc, token) =>
|
||||
acc.flatMap { case (board, history, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((board, history, color, moves))
|
||||
else
|
||||
strictParseAlgebraicMove(token, board, history, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val newBoard = applyMoveToBoard(board, move, color)
|
||||
val newHistory = history.addMove(move)
|
||||
Right((newBoard, newHistory, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._4)
|
||||
|
||||
/** Strict algebraic move parse — no fallback to positionally-illegal moves. */
|
||||
private def strictParseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val dest = Square(File.G, rank)
|
||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Kingside), pieceType = PieceType.King)
|
||||
)
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val dest = Square(File.C, rank)
|
||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Queenside), pieceType = PieceType.King)
|
||||
)
|
||||
case _ =>
|
||||
strictParseRegularMove(notation, board, history, color)
|
||||
|
||||
/** Strict regular move parse — uses only legally reachable squares, no fallback. */
|
||||
private def strictParseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap { toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
// Strict: only squares from which a legal move (including en passant/castling awareness) exists.
|
||||
val reachable: Set[Square] =
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color &&
|
||||
MoveValidator.legalTargets(board, history, from).contains(toSquare) => from
|
||||
}.toSet
|
||||
|
||||
val byPiece = reachable.filter(from =>
|
||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
||||
)
|
||||
|
||||
val disambiguated =
|
||||
if hint.isEmpty then byPiece
|
||||
else byPiece.filter(from => matchesHint(from, hint))
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
||||
val moveIsCapture = notation.contains('x')
|
||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Square}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
/** Base trait for all game state events.
|
||||
* Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
sealed trait GameEvent:
|
||||
def board: Board
|
||||
def history: GameHistory
|
||||
def turn: Color
|
||||
def context: GameContext
|
||||
|
||||
/** Fired when a move is successfully executed. */
|
||||
case class MoveExecutedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
@@ -23,77 +19,57 @@ case class MoveExecutedEvent(
|
||||
|
||||
/** Fired when the current player is in check. */
|
||||
case class CheckDetectedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches checkmate. */
|
||||
case class CheckmateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
winner: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches stalemate. */
|
||||
case class StalemateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is invalid. */
|
||||
case class InvalidMoveEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
reason: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||
case class MoveUndoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
pgnNotation: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||
case class MoveRedoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
@@ -102,9 +78,7 @@ case class MoveRedoneEvent(
|
||||
|
||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||
case class PgnLoadedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
context: GameContext
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
|
||||
+103
-171
@@ -1,216 +1,148 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
// ──── Helper: Command that always fails ────
|
||||
private case class FailingCommand() extends Command:
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Failing command"
|
||||
|
||||
// ──── Helper: Command that conditionally fails on undo or execute ────
|
||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||
override def execute(): Boolean = !shouldFailOnExecute
|
||||
override def undo(): Boolean = !shouldFailOnUndo
|
||||
override def description: String = "Conditional fail"
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||
val cmd = MoveCommand(
|
||||
MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||
previousContext = Some(GameContext.initial)
|
||||
)
|
||||
cmd
|
||||
|
||||
// ──── BRANCH: execute() returns false ────
|
||||
test("CommandInvoker.execute() with failing command returns false"):
|
||||
test("execute rejects failing commands and keeps history unchanged"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = FailingCommand()
|
||||
invoker.execute(cmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.execute() does not add failed command to history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val failingCmd = FailingCommand()
|
||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(failingCmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
|
||||
invoker.execute(successCmd) shouldBe true
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.history(0) shouldBe successCmd
|
||||
invoker.history.head shouldBe successCmd
|
||||
|
||||
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
|
||||
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
||||
val invoker = new CommandInvoker()
|
||||
// currentIndex starts at -1
|
||||
invoker.undo() shouldBe false
|
||||
test("undo redo and history trimming cover all command state transitions"):
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.undo() shouldBe false
|
||||
invoker.canUndo shouldBe false
|
||||
invoker.undo() shouldBe false
|
||||
}
|
||||
|
||||
test("CommandInvoker.undo() returns false when empty history"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.canUndo shouldBe false
|
||||
invoker.undo() shouldBe false
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
invoker.undo() shouldBe false
|
||||
}
|
||||
|
||||
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
|
||||
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex now = 1, history.size = 2
|
||||
|
||||
invoker.undo() // currentIndex becomes 0
|
||||
invoker.undo() // currentIndex becomes -1
|
||||
invoker.undo() // currentIndex still -1, should fail
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||
invoker.execute(failingUndoCmd) shouldBe true
|
||||
invoker.canUndo shouldBe true
|
||||
invoker.undo() shouldBe false
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
}
|
||||
|
||||
// ──── BRANCH: undo() command returns false ────
|
||||
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
||||
val invoker = new CommandInvoker()
|
||||
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||
|
||||
invoker.execute(failingCmd) shouldBe true
|
||||
invoker.canUndo shouldBe true
|
||||
|
||||
invoker.undo() shouldBe false
|
||||
// Index should not change when undo fails
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val successUndoCmd = ConditionalFailCommand()
|
||||
invoker.execute(successUndoCmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
}
|
||||
|
||||
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
||||
val invoker = new CommandInvoker()
|
||||
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
||||
|
||||
invoker.execute(successCmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.redo() shouldBe false
|
||||
}
|
||||
|
||||
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
|
||||
test("CommandInvoker.redo() returns false when nothing to redo"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.redo() shouldBe false
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
}
|
||||
|
||||
test("CommandInvoker.redo() returns false when at end of history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(cmd)
|
||||
// currentIndex = 0, history.size = 1
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val redoFailCmd = ConditionalFailCommand()
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
redoFailCmd.shouldFailOnExecute = true
|
||||
invoker.redo() shouldBe false
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
}
|
||||
|
||||
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
}
|
||||
|
||||
// ──── BRANCH: redo() command returns false ────
|
||||
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
||||
|
||||
invoker.undo()
|
||||
// currentIndex = 0, redoFailCmd is at index 1
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
// Now modify to fail on next execute (redo)
|
||||
redoFailCmd.shouldFailOnExecute = true
|
||||
invoker.redo() shouldBe false
|
||||
// currentIndex should not change
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
// ──── BRANCH: execute() with redo history discarding (while loop) ────
|
||||
test("CommandInvoker.execute() discards redo history via while loop"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2
|
||||
|
||||
invoker.undo()
|
||||
// currentIndex = 0, size = 2
|
||||
// Redo history exists: cmd2 is at index 1
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
invoker.execute(cmd3)
|
||||
// while loop should discard cmd2
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.history(1) shouldBe cmd3
|
||||
|
||||
test("CommandInvoker.execute() discards multiple redo commands"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.execute(cmd3)
|
||||
invoker.execute(cmd4)
|
||||
// currentIndex = 3, size = 4
|
||||
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
// currentIndex = 1, size = 4
|
||||
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||
invoker.execute(newCmd)
|
||||
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
|
||||
invoker.history.size shouldBe 3
|
||||
invoker.canRedo shouldBe false
|
||||
|
||||
// ──── BRANCH: execute() with no redo history to discard ────
|
||||
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2
|
||||
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
|
||||
|
||||
invoker.canRedo shouldBe false
|
||||
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
||||
invoker.history.size shouldBe 3
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
invoker.execute(cmd3)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.history(1) shouldBe cmd3
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.execute(cmd3)
|
||||
invoker.execute(cmd4)
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||
invoker.execute(newCmd)
|
||||
invoker.history.size shouldBe 3
|
||||
invoker.canRedo shouldBe false
|
||||
}
|
||||
|
||||
@@ -1,81 +1,47 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||
MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
)
|
||||
|
||||
test("CommandInvoker executes a command and adds it to history"):
|
||||
test("execute appends commands and updates index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker executes multiple commands in sequence"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
invoker.execute(cmd1) shouldBe true
|
||||
invoker.execute(cmd2) shouldBe true
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.getCurrentIndex shouldBe 1
|
||||
|
||||
test("CommandInvoker.canUndo returns false when empty"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker.canUndo returns true after execution"):
|
||||
test("undo and redo update index and availability flags"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.canUndo shouldBe false
|
||||
invoker.execute(cmd)
|
||||
invoker.canUndo shouldBe true
|
||||
|
||||
test("CommandInvoker.undo decrements current index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.canRedo returns true after undo"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
test("CommandInvoker.redo re-executes a command"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker clear removes all history"):
|
||||
test("clear removes full history and resets index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
@@ -83,7 +49,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker discards all history when executing after undoing all"):
|
||||
test("execute after undo discards redo history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
@@ -91,33 +57,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
// After undoing twice, we're at the beginning (before any commands)
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
invoker.canRedo shouldBe true
|
||||
// Executing a new command from the beginning discards all redo history
|
||||
invoker.execute(cmd3)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.history(0) shouldBe cmd3
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker discards redo history when executing mid-history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
// After one undo, we're at the end of cmd1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
invoker.canRedo shouldBe true
|
||||
// Executing a new command discards cmd2 (the redo history)
|
||||
invoker.execute(cmd3)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.history(0) shouldBe cmd1
|
||||
invoker.history.head shouldBe cmd1
|
||||
invoker.history(1) shouldBe cmd3
|
||||
invoker.getCurrentIndex shouldBe 1
|
||||
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.collection.mutable
|
||||
|
||||
class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||
MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
||||
val invoker = new CommandInvoker()
|
||||
@volatile var raceDetected = false
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
|
||||
// Thread 1: executes commands
|
||||
val executorThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for i <- 1 to 1000 do
|
||||
val cmd = createMoveCommand(
|
||||
sq(File.E, Rank.R2),
|
||||
sq(File.E, Rank.R4)
|
||||
)
|
||||
invoker.execute(cmd)
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: reads history during execution
|
||||
val readerThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 1000 do
|
||||
val _ = invoker.history
|
||||
val _ = invoker.getCurrentIndex
|
||||
Thread.sleep(0) // Yield to increase contention
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
readerThread.start()
|
||||
executorThread.join()
|
||||
readerThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
|
||||
val invoker = new CommandInvoker()
|
||||
@volatile var raceDetected = false
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
|
||||
// Pre-populate with some commands
|
||||
for _ <- 1 to 5 do
|
||||
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
// Thread 1: executes new commands
|
||||
val executorThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: undoes commands
|
||||
val undoThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
if invoker.canUndo then
|
||||
invoker.undo()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 3: redoes commands
|
||||
val redoThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
if invoker.canRedo then
|
||||
invoker.redo()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
undoThread.start()
|
||||
redoThread.start()
|
||||
executorThread.join()
|
||||
undoThread.join()
|
||||
redoThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
@@ -1,52 +1,24 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("QuitCommand can be created"):
|
||||
test("QuitCommand properties and behavior"):
|
||||
val cmd = QuitCommand()
|
||||
cmd shouldNot be(null)
|
||||
|
||||
test("QuitCommand execute returns true"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.execute() shouldBe true
|
||||
|
||||
test("QuitCommand undo returns false (cannot undo quit)"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("QuitCommand description"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.description shouldBe "Quit game"
|
||||
|
||||
test("ResetCommand with no prior state"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("ResetCommand with prior state can undo"):
|
||||
val cmd = ResetCommand(
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe true
|
||||
|
||||
test("ResetCommand with partial state cannot undo"):
|
||||
val cmd = ResetCommand(
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = None, // missing
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("ResetCommand description"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.description shouldBe "Reset board"
|
||||
test("ResetCommand behavior depends on previousContext"):
|
||||
val noState = ResetCommand()
|
||||
noState.execute() shouldBe true
|
||||
noState.undo() shouldBe false
|
||||
noState.description shouldBe "Reset board"
|
||||
|
||||
val withState = ResetCommand(previousContext = Some(GameContext.initial))
|
||||
withState.execute() shouldBe true
|
||||
withState.undo() shouldBe true
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
|
||||
val cmd1 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
|
||||
// Create second command with filled state
|
||||
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
|
||||
val cmd2 = cmd1.copy(
|
||||
moveResult = Some(result),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
|
||||
// Original should be unchanged
|
||||
cmd1.moveResult shouldBe None
|
||||
cmd1.previousBoard shouldBe None
|
||||
cmd1.previousHistory shouldBe None
|
||||
cmd1.previousTurn shouldBe None
|
||||
|
||||
// New should have values
|
||||
cmd2.moveResult shouldBe Some(result)
|
||||
cmd2.previousBoard shouldBe Some(Board.initial)
|
||||
cmd2.previousHistory shouldBe Some(GameHistory.empty)
|
||||
cmd2.previousTurn shouldBe Some(Color.White)
|
||||
|
||||
test("MoveCommand equals and hashCode respect immutability"):
|
||||
val cmd1 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousBoard = None,
|
||||
previousHistory = None,
|
||||
previousTurn = None
|
||||
)
|
||||
|
||||
val cmd2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousBoard = None,
|
||||
previousHistory = None,
|
||||
previousTurn = None
|
||||
)
|
||||
|
||||
// Same values should be equal
|
||||
cmd1 shouldBe cmd2
|
||||
cmd1.hashCode shouldBe cmd2.hashCode
|
||||
|
||||
// Hash should be consistent (required for use as map keys)
|
||||
val hash1 = cmd1.hashCode
|
||||
val hash2 = cmd1.hashCode
|
||||
hash1 shouldBe hash2
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("MoveCommand defaults to empty optional state and false execute/undo"):
|
||||
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||
cmd.moveResult shouldBe None
|
||||
cmd.previousContext shouldBe None
|
||||
cmd.execute() shouldBe false
|
||||
cmd.undo() shouldBe false
|
||||
cmd.description shouldBe "Move from e2 to e4"
|
||||
|
||||
test("MoveCommand execute/undo succeed when state is present"):
|
||||
val executable = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
||||
)
|
||||
executable.execute() shouldBe true
|
||||
|
||||
val undoable = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
)
|
||||
undoable.undo() shouldBe true
|
||||
|
||||
test("MoveCommand is immutable and preserves equality/hash semantics"):
|
||||
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
||||
|
||||
val result = MoveResult.Successful(GameContext.initial, None)
|
||||
val cmd2 = cmd1.copy(
|
||||
moveResult = Some(result),
|
||||
previousContext = Some(GameContext.initial)
|
||||
)
|
||||
|
||||
cmd1.moveResult shouldBe None
|
||||
cmd1.previousContext shouldBe None
|
||||
|
||||
cmd2.moveResult shouldBe Some(result)
|
||||
cmd2.previousContext shouldBe Some(GameContext.initial)
|
||||
|
||||
val eq1 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
)
|
||||
|
||||
val eq2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
)
|
||||
|
||||
eq1 shouldBe eq2
|
||||
eq1.hashCode shouldBe eq2.hashCode
|
||||
|
||||
val hash1 = eq1.hashCode
|
||||
val hash2 = eq1.hashCode
|
||||
hash1 shouldBe hash2
|
||||
@@ -1,526 +0,0 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||
GameController.processMove(board, history, turn, raw)
|
||||
|
||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
|
||||
// ──── processMove ────────────────────────────────────────────────────
|
||||
|
||||
test("processMove: 'quit' input returns Quit"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: 'q' input returns Quit"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: quit with surrounding whitespace returns Quit"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: unparseable input returns InvalidFormat"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
||||
|
||||
test("processMove: valid format but empty square returns NoPiece"):
|
||||
// E3 is empty in the initial position
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
||||
|
||||
test("processMove: piece of wrong color returns WrongColor"):
|
||||
// E7 has a Black pawn; it is White's turn
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
||||
|
||||
test("processMove: geometrically illegal move returns IllegalMove"):
|
||||
// White pawn at E2 cannot jump three squares to E5
|
||||
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"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
||||
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
||||
captured shouldBe None
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: legal capture returns Moved with the captured piece"):
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.H, Rank.R1) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
||||
))
|
||||
processMove(board, GameHistory.empty, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
captured shouldBe Some(Piece.BlackPawn)
|
||||
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
||||
|
||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
|
||||
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "a1a8") match
|
||||
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected MovedInCheck, got $other")
|
||||
|
||||
test("processMove: legal move that results in checkmate returns Checkmate"):
|
||||
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
|
||||
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
|
||||
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "a1h8") match
|
||||
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
|
||||
case other => fail(s"Expected Checkmate(White), got $other")
|
||||
|
||||
test("processMove: legal move that results in stalemate returns Stalemate"):
|
||||
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
|
||||
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
|
||||
val b = Board(Map(
|
||||
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "b1b6") match
|
||||
case MoveResult.Stalemate => succeed
|
||||
case other => fail(s"Expected Stalemate, got $other")
|
||||
|
||||
// ──── castling execution ─────────────────────────────────────────────
|
||||
|
||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
||||
captured shouldBe None
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1c1") match
|
||||
case MoveResult.Moved(newBoard, _, _, _) =>
|
||||
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
// ──── rights revocation ──────────────────────────────────────────────
|
||||
|
||||
test("processMove: e1g1 revokes both white castling rights"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: moving rook from h1 revokes white kingside right"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "h1h4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving king from e1 revokes both white rights"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1e2") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: enemy capture on h1 revokes white kingside right"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: castle attempt when rights revoked returns IllegalMove"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
|
||||
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
|
||||
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
|
||||
test("processMove: moving king from e8 revokes both black rights"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving rook from a8 revokes black queenside right"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving rook from h8 revokes black kingside right"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: enemy capture on a1 revokes white queenside right"):
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
// ──── en passant ────────────────────────────────────────────────────────
|
||||
|
||||
test("en passant capture removes the captured pawn from the board"):
|
||||
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
|
||||
val b = Board(Map(
|
||||
Square(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
Square(File.E, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
|
||||
val result = GameController.processMove(b, h, Color.White, "e5d6")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
|
||||
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
|
||||
captured shouldBe Some(Piece.BlackPawn)
|
||||
case other => fail(s"Expected Moved but got $other")
|
||||
|
||||
test("en passant capture by black removes the captured white pawn"):
|
||||
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
|
||||
val b = Board(Map(
|
||||
Square(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
Square(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
Square(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
Square(File.E, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val result = GameController.processMove(b, h, Color.Black, "d4e3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
|
||||
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
||||
captured shouldBe Some(Piece.WhitePawn)
|
||||
case other => fail(s"Expected Moved but got $other")
|
||||
|
||||
// ──── pawn promotion detection ───────────────────────────────────────────
|
||||
|
||||
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
||||
from should be (sq(File.E, Rank.R7))
|
||||
to should be (sq(File.E, Rank.R8))
|
||||
turn should be (Color.White)
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
|
||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
||||
from should be (sq(File.E, Rank.R2))
|
||||
to should be (sq(File.E, Rank.R1))
|
||||
turn should be (Color.Black)
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
|
||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
// ──── completePromotion ──────────────────────────────────────────────────
|
||||
|
||||
test("completePromotion applies move and places queen"):
|
||||
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case _: MoveResult.Moved => }
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
test("completePromotion with rook underpromotion"):
|
||||
// Black king on h1: not attacked by rook on e8 (different file and rank)
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Rook, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
case _ => fail("Expected Moved with Rook")
|
||||
|
||||
test("completePromotion with bishop underpromotion"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Bishop, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
||||
case _ => fail("Expected Moved with Bishop")
|
||||
|
||||
test("completePromotion with knight underpromotion"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
case _ => fail("Expected Moved with Knight")
|
||||
|
||||
test("completePromotion captures opponent piece"):
|
||||
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
|
||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
case _ => fail("Expected Moved with captured piece")
|
||||
|
||||
test("completePromotion for black pawn to R1"):
|
||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
|
||||
PromotionPiece.Knight, Color.Black
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
test("completePromotion evaluates check after promotion"):
|
||||
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
||||
|
||||
test("completePromotion full round-trip via processMove then completePromotion"):
|
||||
// Black king on h1: not attacked by queen on e8
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
||||
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
||||
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
||||
result should matchPattern { case _: MoveResult.Moved => }
|
||||
result match
|
||||
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
||||
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
case _ => fail("Expected Moved")
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("completePromotion results in checkmate when promotion delivers checkmate"):
|
||||
// Black king a8, white pawn h7, white king b6.
|
||||
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
|
||||
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case MoveResult.Checkmate(_) => }
|
||||
result match
|
||||
case MoveResult.Checkmate(winner) => winner should be (Color.White)
|
||||
case _ => fail("Expected Checkmate")
|
||||
|
||||
test("completePromotion results in stalemate when promotion stalemates opponent"):
|
||||
// Black king a8, white pawn b7, white bishop c7, white king b6.
|
||||
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
|
||||
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result should be (MoveResult.Stalemate)
|
||||
|
||||
// ──── half-move clock propagation ────────────────────────────────────
|
||||
|
||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
||||
// g1f3 is a knight move — not a pawn, not a capture
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 1
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: capture resets halfMoveClock to 0"):
|
||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val history = GameHistory(halfMoveClock = 10)
|
||||
processMove(board, history, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
||||
val history = GameHistory(halfMoveClock = 5)
|
||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 6
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import scala.collection.mutable
|
||||
|
||||
object EngineTestHelpers:
|
||||
|
||||
def makeEngine(): GameEngine =
|
||||
new GameEngine(ruleSet = DefaultRules)
|
||||
|
||||
def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine =
|
||||
GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||
|
||||
def loadFen(engine: GameEngine, fen: String): Unit =
|
||||
engine.loadGame(FenParser, fen)
|
||||
|
||||
def captureEvents(engine: GameEngine): mutable.ListBuffer[GameEvent] =
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||
events
|
||||
|
||||
class MockObserver extends Observer:
|
||||
private val _events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
def events: mutable.ListBuffer[GameEvent] = _events
|
||||
def eventCount: Int = _events.length
|
||||
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
||||
_events.exists(ct.runtimeClass.isInstance(_))
|
||||
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||
_events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
_events += event
|
||||
|
||||
def clear(): Unit =
|
||||
_events.clear()
|
||||
@@ -1,214 +0,0 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests for GameEngine edge cases and uncovered paths */
|
||||
class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles empty input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Please enter a valid move or command")
|
||||
|
||||
test("GameEngine processes quit command"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("quit")
|
||||
// Quit just returns, no events
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine processes q command (short form)"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("q")
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine handles uppercase quit"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("QUIT")
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine handles undo on empty history"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("undo")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Nothing to undo")
|
||||
|
||||
test("GameEngine handles redo on empty redo history"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("redo")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Nothing to redo")
|
||||
|
||||
test("GameEngine parses invalid move format"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("invalid_move_format")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Invalid move format")
|
||||
|
||||
test("GameEngine handles lowercase input normalization"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput(" UNDO ") // With spaces and uppercase
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent] // No moves to undo yet
|
||||
|
||||
test("GameEngine preserves board state on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialBoard = engine.board
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
|
||||
engine.board shouldBe initialBoard
|
||||
|
||||
test("GameEngine preserves turn on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
|
||||
engine.turn shouldBe initialTurn
|
||||
|
||||
test("GameEngine undo with no commands available"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Make a valid move
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
// Undo it
|
||||
engine.processUserInput("undo")
|
||||
|
||||
// Board should be reset
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine redo after undo"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
val turnAfterMove = engine.turn
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
engine.board shouldBe boardAfterMove
|
||||
engine.turn shouldBe turnAfterMove
|
||||
|
||||
test("GameEngine canUndo flag tracks state correctly"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canUndo shouldBe true
|
||||
engine.processUserInput("undo")
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canRedo flag tracks state correctly"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("undo")
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
test("GameEngine command history is accessible"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.commandHistory.isEmpty shouldBe true
|
||||
engine.processUserInput("e2e4")
|
||||
engine.commandHistory.size shouldBe 1
|
||||
|
||||
test("GameEngine processes multiple moves in sequence"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
|
||||
observer.events.size shouldBe 2
|
||||
engine.commandHistory.size shouldBe 2
|
||||
|
||||
test("GameEngine can undo multiple moves"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine thread-safe operations"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
// Access from synchronized methods
|
||||
val board = engine.board
|
||||
val history = engine.history
|
||||
val turn = engine.turn
|
||||
val canUndo = engine.canUndo
|
||||
val canRedo = engine.canRedo
|
||||
|
||||
board shouldBe Board.initial
|
||||
canUndo shouldBe false
|
||||
canRedo shouldBe false
|
||||
|
||||
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
@@ -2,7 +2,6 @@ package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -22,12 +21,11 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||
observer.events.last shouldBe a[CheckmateEvent]
|
||||
|
||||
// Verify CheckmateEvent
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[CheckmateEvent]
|
||||
|
||||
val event = observer.events.head.asInstanceOf[CheckmateEvent]
|
||||
val event = observer.events.last.asInstanceOf[CheckmateEvent]
|
||||
event.winner shouldBe Color.Black
|
||||
|
||||
// Board should be reset after checkmate
|
||||
@@ -50,7 +48,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
|
||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
||||
checkEvents.size shouldBe 1
|
||||
checkEvents.head.turn shouldBe Color.Black // Black is now in check
|
||||
checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
|
||||
|
||||
// Shortest known stalemate is 19 moves. Here is a faster one:
|
||||
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests to maximize handleFailedMove coverage */
|
||||
class GameEngineHandleFailedMoveTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles InvalidFormat error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("not_a_valid_move_format")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg1 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg1 should include("Invalid move format")
|
||||
|
||||
test("GameEngine handles NoPiece error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg2 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg2 should include("No piece on that square")
|
||||
|
||||
test("GameEngine handles WrongColor error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4") // White move
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("a1b2") // Try to move black's rook position with white's move (wrong color)
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg3 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg3 should include("That is not your piece")
|
||||
|
||||
test("GameEngine handles IllegalMove error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e1") // Try pawn backward
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg4 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg4 should include("Illegal move")
|
||||
|
||||
test("GameEngine invalid move message for InvalidFormat"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("xyz123")
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("coordinate notation")
|
||||
|
||||
test("GameEngine invalid move message for NoPiece"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("a3a4") // a3 is empty
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("No piece")
|
||||
|
||||
test("GameEngine invalid move message for WrongColor"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("e4e5") // e4 has white pawn, it's black's turn
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("not your piece")
|
||||
|
||||
test("GameEngine invalid move message for IllegalMove"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e1") // Pawn can't move backward
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Illegal move")
|
||||
|
||||
test("GameEngine board unchanged after each type of invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initial = engine.board
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
engine.board shouldBe initial
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
engine.board shouldBe initial
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.board shouldBe initial
|
||||
@@ -0,0 +1,178 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
|
||||
import de.nowchess.io.GameContextImport
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(alg: String): Square =
|
||||
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
|
||||
|
||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||
val events = collection.mutable.ListBuffer[GameEvent]()
|
||||
engine.subscribe((event: GameEvent) => events += event)
|
||||
events
|
||||
|
||||
test("accessors expose redo availability and command history"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.canRedo shouldBe false
|
||||
engine.commandHistory shouldBe empty
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.commandHistory.nonEmpty shouldBe true
|
||||
|
||||
test("processUserInput handles undo redo empty and malformed commands"):
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("")
|
||||
engine.processUserInput("oops")
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
|
||||
|
||||
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e5")
|
||||
|
||||
events.exists {
|
||||
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
|
||||
case _ => false
|
||||
} shouldBe true
|
||||
|
||||
test("loadGame returns Left when importer fails"):
|
||||
|
||||
val engine = new GameEngine()
|
||||
val failingImporter = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
|
||||
|
||||
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
|
||||
|
||||
test("loadPosition replaces context clears history and notifies reset"):
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
val target = GameContext.initial.withTurn(Color.Black)
|
||||
engine.loadPosition(target)
|
||||
|
||||
engine.context shouldBe target
|
||||
engine.commandHistory shouldBe empty
|
||||
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
|
||||
|
||||
test("redo event includes captured piece description when replaying a capture"):
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
|
||||
events.clear()
|
||||
|
||||
engine.processUserInput("a1h1")
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
|
||||
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
|
||||
|
||||
test("loadGame replay handles promotion moves when pending promotion exists"):
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
|
||||
val permissiveRules = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
if square == sq("e2") then List(promotionMove) else List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
|
||||
|
||||
val engine = new GameEngine(ruleSet = permissiveRules)
|
||||
val importer = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Right(GameContext.initial.copy(moves = List(promotionMove)))
|
||||
|
||||
engine.loadGame(importer, "ignored") shouldBe Right(())
|
||||
engine.context.moves.lastOption shouldBe Some(promotionMove)
|
||||
|
||||
test("loadGame replay restores previous context when promotion cannot be completed"):
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val noLegalMoves = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||
|
||||
val engine = new GameEngine(ruleSet = noLegalMoves)
|
||||
engine.processUserInput("e2e4")
|
||||
val saved = engine.context
|
||||
|
||||
val importer = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Right(GameContext.initial.copy(moves = List(promotionMove)))
|
||||
|
||||
val result = engine.loadGame(importer, "ignored")
|
||||
|
||||
result.isLeft shouldBe true
|
||||
result.left.toOption.get should include("Promotion required")
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
|
||||
engine.context.moves.lastOption shouldBe Some(normalMove)
|
||||
|
||||
test("replayMoves skips later moves after the first move triggers an error"):
|
||||
val engine = new GameEngine()
|
||||
val saved = engine.context
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||
engine.context shouldBe saved
|
||||
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
val engine = new GameEngine()
|
||||
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
||||
|
||||
result shouldBe "e4"
|
||||
|
||||
test("pieceNotation default branch returns empty string"):
|
||||
val engine = new GameEngine()
|
||||
val result = engine.pieceNotation(PieceType.Pawn)
|
||||
|
||||
result shouldBe ""
|
||||
|
||||
test("observerCount reflects subscribe and unsubscribe operations"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit = ()
|
||||
|
||||
engine.observerCount shouldBe 0
|
||||
engine.subscribe(observer)
|
||||
engine.observerCount shouldBe 1
|
||||
engine.unsubscribe(observer)
|
||||
engine.observerCount shouldBe 0
|
||||
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests for GameEngine invalid move handling via handleFailedMove */
|
||||
class GameEngineInvalidMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles no piece at source square"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Try to move from h1 which may be empty or not have our piece
|
||||
// We'll try from a clearly empty square
|
||||
engine.processUserInput("h1h2")
|
||||
|
||||
// Should get an InvalidMoveEvent about NoPiece
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles moving wrong color piece"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White moves first
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
// White tries to move again (should fail - it's black's turn)
|
||||
// But we need to try a move that looks legal but has wrong color
|
||||
// This is hard to test because we'd need to be black and move white's piece
|
||||
// Let's skip this for now and focus on testable cases
|
||||
|
||||
// Actually, let's try moving a square that definitely has the wrong piece
|
||||
// Move a white pawn as black by reaching that position
|
||||
engine.processUserInput("e7e5")
|
||||
observer.events.clear()
|
||||
|
||||
// Now try to move white's e4 pawn as black (it's black's turn but e4 is white)
|
||||
engine.processUserInput("e4e5")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
val event = observer.events.head
|
||||
event shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles illegal move"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// A pawn can't move backward
|
||||
engine.processUserInput("e2e1")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Illegal move")
|
||||
|
||||
test("GameEngine handles pawn trying to move 3 squares"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Pawn can only move 1 or 2 squares on first move, not 3
|
||||
engine.processUserInput("e2e5")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles moving from empty square"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// h3 is empty in starting position
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("No piece on that square")
|
||||
|
||||
test("GameEngine processes valid move after invalid attempt"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Try invalid move
|
||||
engine.processUserInput("h3h4")
|
||||
observer.events.clear()
|
||||
|
||||
// Make valid move
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[MoveExecutedEvent]
|
||||
|
||||
test("GameEngine maintains state after failed move attempt"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
val initialBoard = engine.board
|
||||
|
||||
// Try invalid move
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
// State should not change
|
||||
engine.turn shouldBe initialTurn
|
||||
engine.board shouldBe initialBoard
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
|
||||
import de.nowchess.io.pgn.PgnParser
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
import de.nowchess.io.fen.FenExporter
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
|
||||
val engine = new GameEngine()
|
||||
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
|
||||
val result = engine.loadGame(PgnParser, pgn)
|
||||
result shouldBe Right(())
|
||||
engine.context.moves.size shouldBe 2
|
||||
engine.canUndo shouldBe true
|
||||
|
||||
test("loadGame with FenParser: loads position without replaying moves"):
|
||||
val engine = new GameEngine()
|
||||
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
||||
val result = engine.loadGame(FenParser, fen)
|
||||
result shouldBe Right(())
|
||||
engine.context.moves.isEmpty shouldBe true
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("exportGame with PgnExporter: exports current game as PGN"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
val pgn = engine.exportGame(PgnExporter)
|
||||
pgn.contains("e4") shouldBe true
|
||||
pgn.contains("e5") shouldBe true
|
||||
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
@@ -1,165 +0,0 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private class EventCapture extends Observer:
|
||||
val events: mutable.Buffer[GameEvent] = mutable.Buffer.empty
|
||||
def onGameEvent(event: GameEvent): Unit = events += event
|
||||
def lastEvent: GameEvent = events.last
|
||||
|
||||
// ── loadPgn happy path ────────────────────────────────────────────────────
|
||||
|
||||
test("loadPgn: valid PGN returns Right and updates board/history"):
|
||||
val engine = new GameEngine()
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. e4 e5
|
||||
"""
|
||||
val result = engine.loadPgn(pgn)
|
||||
result shouldBe Right(())
|
||||
engine.history.moves.length shouldBe 2
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("loadPgn: emits PgnLoadedEvent on success"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
val pgn = "[Event \"T\"]\n\n1. e4 e5\n"
|
||||
engine.loadPgn(pgn)
|
||||
cap.events.last shouldBe a[PgnLoadedEvent]
|
||||
|
||||
test("loadPgn: after load canUndo is true and canRedo is false"):
|
||||
val engine = new GameEngine()
|
||||
val pgn = "[Event \"T\"]\n\n1. e4 e5\n"
|
||||
engine.loadPgn(pgn) shouldBe Right(())
|
||||
engine.canUndo shouldBe true
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
test("loadPgn: undo works after loading PGN"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
val pgn = "[Event \"T\"]\n\n1. e4 e5\n"
|
||||
engine.loadPgn(pgn)
|
||||
cap.events.clear()
|
||||
engine.undo()
|
||||
cap.events.last shouldBe a[MoveUndoneEvent]
|
||||
engine.history.moves.length shouldBe 1
|
||||
|
||||
test("loadPgn: undo then redo restores position after PGN load"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
val pgn = "[Event \"T\"]\n\n1. e4 e5\n"
|
||||
engine.loadPgn(pgn)
|
||||
val boardAfterLoad = engine.board
|
||||
engine.undo()
|
||||
engine.redo()
|
||||
cap.events.last shouldBe a[MoveRedoneEvent]
|
||||
engine.board shouldBe boardAfterLoad
|
||||
engine.history.moves.length shouldBe 2
|
||||
|
||||
test("loadPgn: longer game loads all moves into command history"):
|
||||
val engine = new GameEngine()
|
||||
val pgn =
|
||||
"""[Event "Ruy Lopez"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
||||
"""
|
||||
engine.loadPgn(pgn) shouldBe Right(())
|
||||
engine.history.moves.length shouldBe 6
|
||||
engine.commandHistory.length shouldBe 6
|
||||
|
||||
test("loadPgn: invalid PGN returns Left and does not change state"):
|
||||
val engine = new GameEngine()
|
||||
val initial = engine.board
|
||||
val result = engine.loadPgn("[Event \"T\"]\n\n1. Qd4\n")
|
||||
result.isLeft shouldBe true
|
||||
// state is reset to initial (reset happens before replay, which fails)
|
||||
engine.history.moves shouldBe empty
|
||||
|
||||
// ── undo/redo notation events ─────────────────────────────────────────────
|
||||
|
||||
test("undo emits MoveUndoneEvent with pgnNotation"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
engine.processUserInput("e2e4")
|
||||
cap.events.clear()
|
||||
engine.undo()
|
||||
cap.events.last shouldBe a[MoveUndoneEvent]
|
||||
val evt = cap.events.last.asInstanceOf[MoveUndoneEvent]
|
||||
evt.pgnNotation should not be empty
|
||||
evt.pgnNotation shouldBe "e4" // pawn to e4
|
||||
|
||||
test("redo emits MoveRedoneEvent with pgnNotation"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
cap.events.clear()
|
||||
engine.redo()
|
||||
cap.events.last shouldBe a[MoveRedoneEvent]
|
||||
val evt = cap.events.last.asInstanceOf[MoveRedoneEvent]
|
||||
evt.pgnNotation should not be empty
|
||||
evt.pgnNotation shouldBe "e4"
|
||||
|
||||
test("undo emits MoveUndoneEvent with empty notation when history is empty (after checkmate reset)"):
|
||||
// Simulate state where canUndo=true but currentHistory is empty (board reset on checkmate).
|
||||
// We achieve this by examining the branch: provide a MoveCommand with empty history saved.
|
||||
// The simplest proxy: undo a move that reset history (stalemate/checkmate). We'll
|
||||
// use a contrived engine state by direct command manipulation — instead, just verify
|
||||
// that after a normal move-and-undo the notation is present; the empty-history branch
|
||||
// is exercised internally when gameEnd resets state. We cover it via a castling undo.
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
// Play moves that let white castle kingside: e4 e5 Nf3 Nc6 Bc4 Bc5 O-O
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g1f3")
|
||||
engine.processUserInput("b8c6")
|
||||
engine.processUserInput("f1c4")
|
||||
engine.processUserInput("f8c5")
|
||||
engine.processUserInput("e1g1") // white castles kingside
|
||||
cap.events.clear()
|
||||
engine.undo()
|
||||
val evt = cap.events.last.asInstanceOf[MoveUndoneEvent]
|
||||
evt.pgnNotation shouldBe "O-O"
|
||||
|
||||
test("redo emits MoveRedoneEvent with from/to squares and capturedPiece"):
|
||||
val engine = new GameEngine()
|
||||
val cap = new EventCapture()
|
||||
engine.subscribe(cap)
|
||||
// White builds a capture on the a-file: b4, ... a6, b5, ... h6, bxa6
|
||||
engine.processUserInput("b2b4")
|
||||
engine.processUserInput("a7a6")
|
||||
engine.processUserInput("b4b5")
|
||||
engine.processUserInput("h7h6")
|
||||
engine.processUserInput("b5a6") // white pawn captures black pawn
|
||||
engine.undo()
|
||||
cap.events.clear()
|
||||
engine.redo()
|
||||
val evt = cap.events.last.asInstanceOf[MoveRedoneEvent]
|
||||
evt.fromSquare shouldBe "b5"
|
||||
evt.toSquare shouldBe "a6"
|
||||
evt.capturedPiece.isDefined shouldBe true
|
||||
|
||||
test("loadPgn: clears previous game state before loading"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val pgn = "[Event \"T\"]\n\n1. d4 d5\n"
|
||||
engine.loadPgn(pgn) shouldBe Right(())
|
||||
// First move should be d4, not e4
|
||||
engine.history.moves.head.to shouldBe de.nowchess.api.board.Square(
|
||||
de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests that exercise moveToPgn branches not covered by other test files:
|
||||
* - CastleQueenside (line 223)
|
||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||
* - Promotion(Bishop) notation (line 230)
|
||||
* - King normal move notation (line 246)
|
||||
*/
|
||||
class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||
val buf = collection.mutable.ListBuffer[GameEvent]()
|
||||
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = buf += e })
|
||||
buf
|
||||
|
||||
// ── Queenside castling (line 223) ──────────────────────────────────
|
||||
|
||||
test("undo after queenside castling emits MoveUndoneEvent with O-O-O notation"):
|
||||
// FEN: White king on e1, queenside rook on a1, b1/c1/d1 clear, black king away
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
|
||||
// Castling rights: white queen-side only (no king-side rook present)
|
||||
val castlingRights = de.nowchess.api.board.CastlingRights(
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
)
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
.withCastlingRights(castlingRights)
|
||||
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// White castles queenside: e1c1
|
||||
engine.processUserInput("e1c1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
evt.pgnNotation shouldBe "O-O-O"
|
||||
|
||||
// ── En passant notation + computeCaptured (lines 224-225, 254-255) ─
|
||||
|
||||
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
|
||||
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
|
||||
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||
val epSquare = Square.fromAlgebraic("d6")
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
.withEnPassantSquare(epSquare)
|
||||
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
|
||||
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// White pawn on e5 captures en passant to d6
|
||||
engine.processUserInput("e5d6")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
|
||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||
moveEvt.capturedPiece shouldBe defined
|
||||
moveEvt.capturedPiece.get should include ("Black")
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val undoEvt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
undoEvt.pgnNotation shouldBe "exd6"
|
||||
|
||||
// ── Bishop underpromotion notation (line 230) ──────────────────────
|
||||
|
||||
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
|
||||
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
evt.pgnNotation shouldBe "e8=B"
|
||||
|
||||
// ── King normal move notation (line 246) ───────────────────────────
|
||||
|
||||
test("undo after king move emits MoveUndoneEvent with K notation"):
|
||||
// White king on e1, no castling rights, black king far away
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
.withTurn(Color.White)
|
||||
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
|
||||
|
||||
val engine = new GameEngine(ctx)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// King moves e1 -> f1
|
||||
engine.processUserInput("e1f1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
evt.pgnNotation should startWith ("K")
|
||||
evt.pgnNotation should include ("f1")
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── Checkmate ───────────────────────────────────────────────────
|
||||
|
||||
test("checkmate ends game with CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
test("checkmate with white winner"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("f1c4")
|
||||
engine.processUserInput("b8c6")
|
||||
engine.processUserInput("d1h5")
|
||||
engine.processUserInput("g8f6")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("h5f7")
|
||||
|
||||
val evt = observer.getEvent[CheckmateEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.winner shouldBe Color.White
|
||||
|
||||
// ── Stalemate ───────────────────────────────────────────────────
|
||||
|
||||
test("stalemate ends game with StalemateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6"
|
||||
)
|
||||
moves.foreach(engine.processUserInput)
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("c8e6")
|
||||
|
||||
observer.hasEvent[StalemateEvent] shouldBe true
|
||||
|
||||
test("stalemate when king has no moves and no pieces"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
)
|
||||
|
||||
moves.foreach(engine.processUserInput)
|
||||
|
||||
observer.hasEvent[StalemateEvent] shouldBe true
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────
|
||||
|
||||
test("check detected after move puts king in check"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("f1c4")
|
||||
engine.processUserInput("g8f6")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("c4f7")
|
||||
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("check by knight"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/4K3 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("d4f5")
|
||||
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move rule triggers when half-move clock reaches 100"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 99 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("g1f3")
|
||||
|
||||
observer.hasEvent[FiftyMoveRuleAvailableEvent] shouldBe true
|
||||
|
||||
test("fifty-move rule clock resets on pawn move"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 50 1")
|
||||
engine.processUserInput("a2a3")
|
||||
|
||||
// Clock should reset to 0 after pawn move
|
||||
engine.context.halfMoveClock shouldBe 0
|
||||
|
||||
test("fifty-move rule clock resets on capture"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
// FEN: white pawn on e5, black pawn on d6, clock at 50
|
||||
EngineTestHelpers.loadFen(engine, "4k3/8/3p4/4P3/8/8/8/4K3 w - - 50 1")
|
||||
engine.processUserInput("e5d6")
|
||||
|
||||
// Clock should reset to 0 after capture
|
||||
engine.context.halfMoveClock shouldBe 0
|
||||
|
||||
// ── Draw claim ────────────────────────────────────────────────
|
||||
|
||||
test("draw can be claimed when fifty-move rule is available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 100 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||
|
||||
test("draw cannot be claimed when not available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
+55
-25
@@ -1,10 +1,12 @@
|
||||
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.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -17,9 +19,12 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||
events
|
||||
|
||||
private def engineWith(board: Board, turn: Color = Color.White): GameEngine =
|
||||
new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
|
||||
|
||||
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 engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -30,7 +35,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
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)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -45,7 +50,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
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 engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -54,13 +59,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
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))
|
||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(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)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -81,7 +86,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
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 engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -91,9 +96,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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 engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
@@ -106,10 +110,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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 engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
@@ -120,10 +122,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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 engine = engineWith(board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
@@ -134,10 +134,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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 engine = engineWith(board, Color.Black)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
@@ -149,19 +147,51 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
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
|
||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||
// Custom RuleSet: delegates all methods to StandardRules except legalMoves,
|
||||
// which strips Promotion move types and returns Normal moves instead.
|
||||
// This makes completePromotion unable to find Move(from, to, Promotion(Queen)),
|
||||
// triggering the "Error completing promotion." branch.
|
||||
val delegatingRuleSet: RuleSet = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
DefaultRules.candidateMoves(context)(square)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
DefaultRules.legalMoves(context)(square).map { m =>
|
||||
m.moveType match
|
||||
case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal())
|
||||
case _ => m
|
||||
}
|
||||
def allLegalMoves(context: GameContext): List[Move] =
|
||||
DefaultRules.allLegalMoves(context)
|
||||
def isCheck(context: GameContext): Boolean =
|
||||
DefaultRules.isCheck(context)
|
||||
def isCheckmate(context: GameContext): Boolean =
|
||||
DefaultRules.isCheckmate(context)
|
||||
def isStalemate(context: GameContext): Boolean =
|
||||
DefaultRules.isStalemate(context)
|
||||
def isInsufficientMaterial(context: GameContext): Boolean =
|
||||
DefaultRules.isInsufficientMaterial(context)
|
||||
def isFiftyMoveRule(context: GameContext): Boolean =
|
||||
DefaultRules.isFiftyMoveRule(context)
|
||||
def applyMove(context: GameContext)(move: Move): GameContext =
|
||||
DefaultRules.applyMove(context)(move)
|
||||
|
||||
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 initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be (true)
|
||||
|
||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||
// but only Normal moves exist → fires InvalidMoveEvent
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||
invalidEvt.reason should include ("Error completing promotion")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.collection.mutable
|
||||
|
||||
class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── Observer wiring ────────────────────────────────────────────
|
||||
|
||||
test("observer subscribe and unsubscribe behavior"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
val countBeforeUnsubscribe = observer.eventCount
|
||||
engine.subscribe(observer)
|
||||
engine.unsubscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
observer.eventCount shouldBe countBeforeUnsubscribe
|
||||
|
||||
// ── Initial state ──────────────────────────────────────────────
|
||||
|
||||
test("initial engine state is standard"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
engine.board.pieceAt(Square(File.E, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Quit command ──────────────────────────────────────────────
|
||||
|
||||
test("quit aliases and reset keep engine responsive"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
engine.processUserInput("quit")
|
||||
engine.processUserInput("q")
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
engine.reset()
|
||||
|
||||
engine.board.pieceAt(Square(File.E, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Turn toggling ──────────────────────────────────────────────
|
||||
|
||||
test("turn toggles across valid move sequence"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.turn shouldBe Color.Black
|
||||
engine.processUserInput("e7e5")
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Invalid moves (minimal) ────────────────────────────────────
|
||||
|
||||
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
engine.turn shouldBe Color.White // turn unchanged
|
||||
|
||||
engine.processUserInput("e7e5") // try to move black pawn on white's turn
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e5e4") // pawn backward
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
// ── Undo/Redo ────────────────────────────────────────────────
|
||||
|
||||
test("undo redo success and empty-history failures"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.undo()
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
engine.undo()
|
||||
|
||||
engine.board.pieceAt(Square(File.E, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
engine.redo()
|
||||
|
||||
engine.board.pieceAt(Square(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
||||
engine.turn shouldBe Color.Black
|
||||
observer.clear()
|
||||
engine.redo()
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move event and draw claim success/failure"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Load FEN with half-move clock at 99
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 99 1")
|
||||
observer.clear()
|
||||
|
||||
// Use a legal non-pawn non-capture move so the clock increments to 100.
|
||||
engine.processUserInput("g1f3")
|
||||
|
||||
observer.hasEvent[FiftyMoveRuleAvailableEvent] shouldBe true
|
||||
|
||||
// Load position with sufficient move history for draw claim
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 100 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||
|
||||
// Initial position has no draw available
|
||||
observer.clear()
|
||||
engine.reset()
|
||||
engine.processUserInput("draw")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── Castling ────────────────────────────────────────────────────
|
||||
|
||||
test("kingside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white king on e1, rook on h1, f1/g1 clear
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e1g1")
|
||||
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("queenside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white king on e1, rook on a1, b1/c1/d1 clear
|
||||
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e1c1")
|
||||
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("undo castling emits PGN notation"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "k7/8/8/8/8/8/8/R3K3 w Q - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e1c1")
|
||||
observer.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = observer.getEvent[MoveUndoneEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.pgnNotation shouldBe "O-O-O"
|
||||
|
||||
// ── En passant ──────────────────────────────────────────────────
|
||||
|
||||
test("en passant capture executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white pawn e5, black pawn d5 (just pushed), en passant square d6
|
||||
EngineTestHelpers.loadFen(engine, "k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e5d6")
|
||||
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
val moveEvt = observer.getEvent[MoveExecutedEvent]
|
||||
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
|
||||
|
||||
test("undo en passant emits file-x-destination notation"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e5d6")
|
||||
observer.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = observer.getEvent[MoveUndoneEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.pgnNotation shouldBe "exd6"
|
||||
|
||||
// ── Pawn promotion ─────────────────────────────────────────────
|
||||
|
||||
test("pawn reaching back rank requires promotion"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/8 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
observer.hasEvent[PromotionRequiredEvent] shouldBe true
|
||||
engine.isPendingPromotion shouldBe true
|
||||
|
||||
test("completePromotion to Queen executes move"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Rook executes move"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Bishop executes move"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("completePromotion to Knight executes move"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/8/8/8/8/k7/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: white pawn e7, black king e6, white king e1
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// FEN: known promotion-mate pattern
|
||||
EngineTestHelpers.loadFen(engine, "k7/7P/1K6/8/8/8/8/8 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
test("undo promotion emits notation with piece suffix"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/8 w - - 0 1")
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Bishop)
|
||||
observer.clear()
|
||||
|
||||
engine.undo()
|
||||
|
||||
val evt = observer.getEvent[MoveUndoneEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.pgnNotation shouldBe "e8=B"
|
||||
|
||||
test("black pawn promotion executes"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "8/8/8/8/8/4k3/4p3/8 b - - 0 1")
|
||||
engine.processUserInput("e2e1")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion shouldBe false
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ── Promotion capturing ────────────────────────────────────────
|
||||
|
||||
test("pawn promotion with capture executes"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
|
||||
EngineTestHelpers.loadFen(engine, "3n4/4P3/4k3/8/8/8/8/4K3 w - - 0 1")
|
||||
engine.processUserInput("e7d8")
|
||||
|
||||
engine.isPendingPromotion shouldBe true
|
||||
@@ -1,351 +0,0 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent, MoveUndoneEvent, MoveRedoneEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEngineTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine starts with initial board state"):
|
||||
val engine = new GameEngine()
|
||||
engine.board shouldBe Board.initial
|
||||
engine.history shouldBe GameHistory.empty
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine accepts Observer subscription"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.observerCount shouldBe 1
|
||||
|
||||
test("GameEngine notifies observers on valid move"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.processUserInput("e2e4")
|
||||
mockObserver.events.size shouldBe 1
|
||||
mockObserver.events.head shouldBe a[MoveExecutedEvent]
|
||||
|
||||
test("GameEngine updates state after valid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
engine.processUserInput("e2e4")
|
||||
engine.turn shouldNot be(initialTurn)
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("GameEngine notifies observers on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.processUserInput("invalid_move")
|
||||
mockObserver.events.size shouldBe 1
|
||||
|
||||
test("GameEngine notifies multiple observers"):
|
||||
val engine = new GameEngine()
|
||||
val observer1 = new MockObserver()
|
||||
val observer2 = new MockObserver()
|
||||
engine.subscribe(observer1)
|
||||
engine.subscribe(observer2)
|
||||
engine.processUserInput("e2e4")
|
||||
observer1.events.size shouldBe 1
|
||||
observer2.events.size shouldBe 1
|
||||
|
||||
test("GameEngine allows observer unsubscription"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.unsubscribe(mockObserver)
|
||||
engine.observerCount shouldBe 0
|
||||
|
||||
test("GameEngine unsubscribed observer receives no events"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.unsubscribe(mockObserver)
|
||||
engine.processUserInput("e2e4")
|
||||
mockObserver.events.size shouldBe 0
|
||||
|
||||
test("GameEngine reset notifies observers and resets state"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.reset()
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
observer.events.size shouldBe 1
|
||||
|
||||
test("GameEngine processes sequence of moves"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
observer.events.size shouldBe 2
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine is thread-safe for synchronized operations"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
val t = new Thread(() => engine.processUserInput("e2e4"))
|
||||
t.start()
|
||||
t.join()
|
||||
observer.events.size shouldBe 1
|
||||
|
||||
test("GameEngine canUndo returns false initially"):
|
||||
val engine = new GameEngine()
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canUndo returns true after move"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canUndo shouldBe true
|
||||
|
||||
test("GameEngine canRedo returns false initially"):
|
||||
val engine = new GameEngine()
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
test("GameEngine undo restores previous state"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.undo()
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine undo notifies observers"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
engine.undo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[MoveUndoneEvent]
|
||||
|
||||
test("GameEngine redo replays undone move"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.undo()
|
||||
engine.redo()
|
||||
engine.board shouldBe boardAfterMove
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("GameEngine canUndo false when nothing to undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canRedo true after undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
test("GameEngine canRedo false after redo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.redo()
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
test("GameEngine undo on empty history sends invalid event"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.undo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine redo on empty redo sends invalid event"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.redo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine undo via processUserInput"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.processUserInput("undo")
|
||||
engine.board shouldBe Board.initial
|
||||
|
||||
test("GameEngine redo via processUserInput"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
engine.board shouldBe boardAfterMove
|
||||
|
||||
test("GameEngine handles empty input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine multiple undo/redo sequence"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g1f3")
|
||||
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
engine.undo()
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
engine.undo()
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
engine.undo()
|
||||
engine.turn shouldBe Color.White
|
||||
engine.board shouldBe Board.initial
|
||||
|
||||
test("GameEngine redo after multiple undos"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g1f3")
|
||||
|
||||
engine.undo()
|
||||
engine.undo()
|
||||
engine.undo()
|
||||
|
||||
engine.redo()
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
engine.redo()
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
engine.redo()
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("GameEngine new move after undo clears redo history"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.undo()
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
engine.processUserInput("e7e6") // Different move
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
test("GameEngine command history tracking"):
|
||||
val engine = new GameEngine()
|
||||
engine.commandHistory.size shouldBe 0
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.commandHistory.size shouldBe 1
|
||||
|
||||
engine.processUserInput("e7e5")
|
||||
engine.commandHistory.size shouldBe 2
|
||||
|
||||
test("GameEngine quit input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
val initialEvents = observer.events.size
|
||||
engine.processUserInput("quit")
|
||||
// quit should not produce an event
|
||||
observer.events.size shouldBe initialEvents
|
||||
|
||||
test("GameEngine quit via q"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
val initialEvents = observer.events.size
|
||||
engine.processUserInput("q")
|
||||
observer.events.size shouldBe initialEvents
|
||||
|
||||
test("GameEngine undo notifies with MoveUndoneEvent after successful undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
|
||||
engine.undo()
|
||||
|
||||
// Should have received a MoveUndoneEvent on undo
|
||||
observer.events.size should be > 0
|
||||
observer.events.exists(_.isInstanceOf[MoveUndoneEvent]) shouldBe true
|
||||
|
||||
test("GameEngine redo notifies with MoveRedoneEvent after successful redo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
val boardAfterSecondMove = engine.board
|
||||
|
||||
engine.undo()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
|
||||
engine.redo()
|
||||
|
||||
// Should have received a MoveRedoneEvent for the redo
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[MoveRedoneEvent]
|
||||
engine.board shouldBe boardAfterSecondMove
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ──── 50-move rule ───────────────────────────────────────────────────
|
||||
|
||||
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[DrawClaimedEvent]
|
||||
|
||||
test("GameEngine: state resets to initial after draw claimed"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
engine.processUserInput("draw")
|
||||
engine.board shouldBe Board.initial
|
||||
engine.history shouldBe GameHistory.empty
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
|
||||
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3") // knight move on initial board
|
||||
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3")
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
|
||||
|
||||
// Mock Observer for testing
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
// Tests for MoveCommand with default parameter values
|
||||
test("MoveCommand with no moveResult defaults to None"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
cmd.moveResult shouldBe None
|
||||
cmd.execute() shouldBe false
|
||||
|
||||
test("MoveCommand with no previousBoard defaults to None"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
cmd.previousBoard shouldBe None
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("MoveCommand with no previousHistory defaults to None"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
cmd.previousHistory shouldBe None
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("MoveCommand with no previousTurn defaults to None"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
cmd.previousTurn shouldBe None
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("MoveCommand description is always returned"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4)
|
||||
)
|
||||
cmd.description shouldBe "Move from e2 to e4"
|
||||
|
||||
test("MoveCommand execute returns false when moveResult is None"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.A, Rank.R1),
|
||||
to = sq(File.B, Rank.R3)
|
||||
)
|
||||
cmd.execute() shouldBe false
|
||||
|
||||
test("MoveCommand undo returns false when any previous state is None"):
|
||||
// Missing previousBoard
|
||||
val cmd1 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = None,
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd1.undo() shouldBe false
|
||||
|
||||
// Missing previousHistory
|
||||
val cmd2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = None,
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd2.undo() shouldBe false
|
||||
|
||||
// Missing previousTurn
|
||||
val cmd3 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = None
|
||||
)
|
||||
cmd3.undo() shouldBe false
|
||||
|
||||
test("MoveCommand execute returns true when moveResult is defined"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
|
||||
test("MoveCommand undo returns true when all previous states are defined"):
|
||||
val cmd = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd.undo() shouldBe true
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CastlingRightsCalculatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("Empty history gives full castling rights"):
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(GameHistory.empty, Color.White)
|
||||
rights shouldBe CastlingRights.Both
|
||||
|
||||
test("White loses kingside rights after h1 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R1), sq(File.H, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.kingSide shouldBe false
|
||||
rights.queenSide shouldBe true
|
||||
|
||||
test("White loses queenside rights after a1 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.queenSide shouldBe false
|
||||
rights.kingSide shouldBe true
|
||||
|
||||
test("White loses all rights after king moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Black loses kingside rights after h8 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R8), sq(File.H, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights.kingSide shouldBe false
|
||||
rights.queenSide shouldBe true
|
||||
|
||||
test("Black loses queenside rights after a8 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R8), sq(File.A, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights.queenSide shouldBe false
|
||||
rights.kingSide shouldBe true
|
||||
|
||||
test("Black loses all rights after king moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R8), sq(File.E, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Castle move revokes all castling rights"):
|
||||
val history = GameHistory.empty.addMove(
|
||||
sq(File.E, Rank.R1),
|
||||
sq(File.G, Rank.R1),
|
||||
Some(CastleSide.Kingside)
|
||||
)
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Other pieces moving does not revoke castling rights"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.Both
|
||||
|
||||
test("Multiple moves preserve white kingside but lose queenside"):
|
||||
val history = GameHistory.empty
|
||||
.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) // White queenside rook moves
|
||||
.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) // Black pawn moves
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.kingSide shouldBe true
|
||||
rights.queenSide shouldBe false
|
||||
@@ -1,101 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
// ──── enPassantTarget ────────────────────────────────────────────────
|
||||
|
||||
test("enPassantTarget returns None for empty history"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was a single pawn push"):
|
||||
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was not a pawn"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
|
||||
|
||||
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
|
||||
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
|
||||
|
||||
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
|
||||
|
||||
// ──── capturedPawnSquare ─────────────────────────────────────────────
|
||||
|
||||
test("capturedPawnSquare for white capturing on e6 returns e5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
|
||||
|
||||
test("capturedPawnSquare for black capturing on e3 returns e4"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
|
||||
|
||||
test("capturedPawnSquare for white capturing on d6 returns d5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
|
||||
|
||||
// ──── isEnPassant ────────────────────────────────────────────────────
|
||||
|
||||
test("isEnPassant returns true for valid white en passant capture"):
|
||||
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("isEnPassant returns true for valid black en passant capture"):
|
||||
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("isEnPassant returns false when no en passant target in history"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when piece at from is not a pawn"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when to does not match ep target"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when from square is empty"):
|
||||
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
@@ -1,104 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameHistoryTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("GameHistory starts empty"):
|
||||
val history = GameHistory.empty
|
||||
history.moves shouldBe empty
|
||||
|
||||
test("GameHistory can add a move"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
history.moves should have length 1
|
||||
history.moves.head.from shouldBe sq(File.E, Rank.R2)
|
||||
history.moves.head.to shouldBe sq(File.E, Rank.R4)
|
||||
history.moves.head.castleSide shouldBe None
|
||||
|
||||
test("GameHistory can add multiple moves in order"):
|
||||
val h1 = GameHistory.empty
|
||||
val h2 = h1.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val h3 = h2.addMove(sq(File.C, Rank.R7), sq(File.C, Rank.R5))
|
||||
h3.moves should have length 2
|
||||
h3.moves(0).from shouldBe sq(File.E, Rank.R2)
|
||||
h3.moves(1).from shouldBe sq(File.C, Rank.R7)
|
||||
|
||||
test("GameHistory can add a castle move"):
|
||||
val history = GameHistory.empty.addMove(
|
||||
sq(File.E, Rank.R1),
|
||||
sq(File.G, Rank.R1),
|
||||
Some(CastleSide.Kingside)
|
||||
)
|
||||
history.moves.head.castleSide shouldBe Some(CastleSide.Kingside)
|
||||
|
||||
test("GameHistory.addMove with two arguments uses None for castleSide default"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
history.moves should have length 1
|
||||
history.moves.head.castleSide shouldBe None
|
||||
|
||||
test("Move with promotion records the promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
|
||||
move.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
test("Normal move has no promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), None, None)
|
||||
move.promotionPiece should be (None)
|
||||
|
||||
test("addMove with promotion stores promotionPiece"):
|
||||
val history = GameHistory.empty
|
||||
val newHistory = history.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
|
||||
test("addMove with castleSide only uses promotionPiece default (None)"):
|
||||
val history = GameHistory.empty
|
||||
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
|
||||
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside))
|
||||
newHistory.moves.head.promotionPiece should be (None)
|
||||
|
||||
test("addMove using named parameters with only promotion, using castleSide default"):
|
||||
val history = GameHistory.empty
|
||||
val newHistory = history.addMove(from = sq(File.E, Rank.R7), to = sq(File.E, Rank.R8), promotionPiece = Some(PromotionPiece.Queen))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (None)
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
// ──── half-move clock ────────────────────────────────────────────────
|
||||
|
||||
test("halfMoveClock starts at 0"):
|
||||
GameHistory.empty.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock increments on a non-pawn non-capture move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("halfMoveClock resets to 0 on a pawn move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 on a capture"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock carries across multiple moves"):
|
||||
val h = GameHistory.empty
|
||||
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
|
||||
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
|
||||
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
|
||||
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("GameHistory can be initialised with a non-zero halfMoveClock"):
|
||||
val h = GameHistory(halfMoveClock = 42)
|
||||
h.halfMoveClock shouldBe 42
|
||||
@@ -1,161 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
/** 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)] =
|
||||
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color)
|
||||
|
||||
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
|
||||
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
|
||||
|
||||
// ──── isInCheck ──────────────────────────────────────────────────────
|
||||
|
||||
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(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.BlackRook
|
||||
)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe true
|
||||
|
||||
test("isInCheck: king not attacked"):
|
||||
// Black Rook A3 does not cover E1
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R3) -> Piece.BlackRook
|
||||
)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
||||
|
||||
test("isInCheck: no king on board returns false"):
|
||||
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
||||
|
||||
// ──── legalMoves ─────────────────────────────────────────────────────
|
||||
|
||||
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(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R4) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
)(Color.White)
|
||||
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
|
||||
|
||||
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(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
)(Color.White)
|
||||
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
|
||||
|
||||
// ──── gameStatus ──────────────────────────────────────────────────────
|
||||
|
||||
test("gameStatus: checkmate returns Mated"):
|
||||
// White Qh8, Ka6; Black Ka8
|
||||
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
|
||||
testGameStatus(
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)(Color.Black) shouldBe PositionStatus.Mated
|
||||
|
||||
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(
|
||||
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)(Color.Black) shouldBe PositionStatus.Drawn
|
||||
|
||||
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(
|
||||
sq(File.A, Rank.R8) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
)(Color.Black) shouldBe PositionStatus.InCheck
|
||||
|
||||
test("gameStatus: normal starting position returns Normal"):
|
||||
GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
|
||||
|
||||
test("legalMoves: includes castling destination when available"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
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))
|
||||
|
||||
test("legalMoves: excludes castling when king is in check"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
||||
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))
|
||||
|
||||
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(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.F, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
// 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"):
|
||||
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
val result = b.withCastle(Color.White, CastleSide.Queenside)
|
||||
result.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
result.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
result.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
result.pieceAt(sq(File.A, Rank.R1)) shouldBe None
|
||||
|
||||
test("CastleSide.withCastle correctly positions pieces for Black Kingside castling"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
)
|
||||
val result = b.withCastle(Color.Black, CastleSide.Kingside)
|
||||
result.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
result.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
result.pieceAt(sq(File.H, Rank.R8)) shouldBe None
|
||||
|
||||
test("CastleSide.withCastle correctly positions pieces for Black Queenside castling"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
)
|
||||
val result = b.withCastle(Color.Black, CastleSide.Queenside)
|
||||
result.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
result.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
result.pieceAt(sq(File.A, Rank.R8)) shouldBe None
|
||||
@@ -1,280 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MoveValidatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
// ──── Empty square ───────────────────────────────────────────────────
|
||||
|
||||
test("legalTargets returns empty set when no piece at from square"):
|
||||
MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty
|
||||
|
||||
// ──── isLegal delegates to legalTargets ──────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
test("isLegal returns false for an invalid move"):
|
||||
MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false
|
||||
|
||||
// ──── Pawn – White ───────────────────────────────────────────────────
|
||||
|
||||
test("white pawn on starting rank can move forward one square"):
|
||||
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))
|
||||
|
||||
test("white pawn on starting rank can move forward two squares"):
|
||||
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))
|
||||
|
||||
test("white pawn not on starting rank cannot move two squares"):
|
||||
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)
|
||||
|
||||
test("white pawn is blocked by piece directly in front, and cannot jump over it"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
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.R4)
|
||||
|
||||
test("white pawn on starting rank cannot move two squares if destination square is occupied"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R4) -> Piece.BlackPawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2))
|
||||
targets should contain(sq(File.E, Rank.R3))
|
||||
targets should not contain sq(File.E, Rank.R4)
|
||||
|
||||
test("white pawn can capture diagonally when enemy piece is present"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
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"):
|
||||
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)
|
||||
|
||||
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 targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2))
|
||||
targets should contain(sq(File.A, Rank.R3))
|
||||
targets should contain(sq(File.A, Rank.R4))
|
||||
targets.size shouldBe 2
|
||||
|
||||
// ──── Pawn – Black ───────────────────────────────────────────────────
|
||||
|
||||
test("black pawn on starting rank can move forward one and two squares"):
|
||||
val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
||||
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.R5))
|
||||
|
||||
test("black pawn not on starting rank cannot move two squares"):
|
||||
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)
|
||||
|
||||
test("black pawn can capture diagonally when enemy piece is present"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R7) -> Piece.BlackPawn,
|
||||
sq(File.F, Rank.R6) -> Piece.WhitePawn
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6))
|
||||
|
||||
// ──── Knight ─────────────────────────────────────────────────────────
|
||||
|
||||
test("knight in center has 8 possible moves"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
|
||||
|
||||
test("knight in corner has only 2 possible moves"):
|
||||
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight)
|
||||
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2
|
||||
|
||||
test("knight cannot land on own piece"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
||||
sq(File.F, Rank.R5) -> Piece.WhiteRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5)
|
||||
|
||||
test("knight can capture enemy piece"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
||||
sq(File.F, Rank.R5) -> Piece.BlackRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5))
|
||||
|
||||
// ──── Bishop ─────────────────────────────────────────────────────────
|
||||
|
||||
test("bishop slides diagonally across an empty board"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
targets should contain(sq(File.E, Rank.R5))
|
||||
targets should contain(sq(File.H, Rank.R8))
|
||||
targets should contain(sq(File.C, Rank.R3))
|
||||
targets should contain(sq(File.A, Rank.R1))
|
||||
|
||||
test("bishop is blocked by own piece and squares beyond are unreachable"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
||||
sq(File.F, Rank.R6) -> Piece.WhiteRook
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
targets should contain(sq(File.E, Rank.R5))
|
||||
targets should not contain sq(File.F, Rank.R6)
|
||||
targets should not contain sq(File.G, Rank.R7)
|
||||
|
||||
test("bishop captures enemy piece and cannot slide further"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
||||
sq(File.F, Rank.R6) -> Piece.BlackRook
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
targets should contain(sq(File.E, Rank.R5))
|
||||
targets should contain(sq(File.F, Rank.R6))
|
||||
targets should not contain sq(File.G, Rank.R7)
|
||||
|
||||
// ──── Rook ───────────────────────────────────────────────────────────
|
||||
|
||||
test("rook slides orthogonally across an empty board"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
|
||||
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.R1))
|
||||
targets should contain(sq(File.A, Rank.R4))
|
||||
targets should contain(sq(File.H, Rank.R4))
|
||||
|
||||
test("rook is blocked by own piece and squares beyond are unreachable"):
|
||||
val b = board(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.C, Rank.R1) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.A, 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.D, Rank.R1)
|
||||
|
||||
test("rook captures enemy piece and cannot slide further"):
|
||||
val b = board(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.C, Rank.R1) -> Piece.BlackPawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1))
|
||||
targets should contain(sq(File.B, Rank.R1))
|
||||
targets should contain(sq(File.C, Rank.R1))
|
||||
targets should not contain sq(File.D, Rank.R1)
|
||||
|
||||
// ──── Queen ──────────────────────────────────────────────────────────
|
||||
|
||||
test("queen combines rook and bishop movement for 27 squares from d4"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
|
||||
val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
targets should contain(sq(File.D, Rank.R8))
|
||||
targets should contain(sq(File.H, Rank.R4))
|
||||
targets should contain(sq(File.H, Rank.R8))
|
||||
targets should contain(sq(File.A, Rank.R1))
|
||||
targets.size shouldBe 27
|
||||
|
||||
// ──── King ───────────────────────────────────────────────────────────
|
||||
|
||||
test("king moves one step in all 8 directions from center"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8
|
||||
|
||||
test("king at corner has only 3 reachable squares"):
|
||||
val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing)
|
||||
MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3
|
||||
|
||||
test("king cannot capture own piece"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R4) -> Piece.WhiteRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4)
|
||||
|
||||
test("king can capture enemy piece"):
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R4) -> Piece.BlackRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
|
||||
|
||||
// ──── Pawn – en passant targets ──────────────────────────────────────
|
||||
|
||||
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(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
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))
|
||||
|
||||
test("white pawn does not include ep target without a preceding double push"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
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)
|
||||
|
||||
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(
|
||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
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))
|
||||
|
||||
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(
|
||||
sq(File.A, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
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)
|
||||
|
||||
// ──── History-aware legalTargets fallback 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 h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
|
||||
// ──── isPromotionMove ────────────────────────────────────────────────
|
||||
|
||||
test("White pawn reaching R8 is a promotion move"):
|
||||
val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
|
||||
|
||||
test("Black pawn reaching R1 is a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
|
||||
|
||||
test("Pawn capturing to back rank is a promotion move"):
|
||||
val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
|
||||
|
||||
test("Pawn not reaching back rank is not a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
|
||||
|
||||
test("Non-pawn piece is never a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
|
||||
@@ -1,88 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export initial position to FEN"):
|
||||
val gameState = GameState.initial
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
test("export position after e4"):
|
||||
val gameState = GameState(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
|
||||
activeColor = Color.Black,
|
||||
castlingWhite = CastlingRights.Both,
|
||||
castlingBlack = CastlingRights.Both,
|
||||
enPassantTarget = Some(Square(File.E, Rank.R3)),
|
||||
halfMoveClock = 0,
|
||||
fullMoveNumber = 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||
|
||||
test("export position with no castling"):
|
||||
val gameState = GameState(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights.None,
|
||||
castlingBlack = CastlingRights.None,
|
||||
enPassantTarget = None,
|
||||
halfMoveClock = 0,
|
||||
fullMoveNumber = 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
|
||||
test("export position with partial castling"):
|
||||
val gameState = GameState(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights(kingSide = true, queenSide = false),
|
||||
castlingBlack = CastlingRights(kingSide = false, queenSide = true),
|
||||
enPassantTarget = None,
|
||||
halfMoveClock = 5,
|
||||
fullMoveNumber = 3,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
||||
|
||||
test("export position with en passant and move counts"):
|
||||
val gameState = GameState(
|
||||
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights.Both,
|
||||
castlingBlack = CastlingRights.Both,
|
||||
enPassantTarget = Some(Square(File.C, Rank.R6)),
|
||||
halfMoveClock = 2,
|
||||
fullMoveNumber = 3,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||
|
||||
test("halfMoveClock round-trips through FEN export and import"):
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
val history = GameHistory(halfMoveClock = 42)
|
||||
val gameState = GameState(
|
||||
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
|
||||
activeColor = Color.White,
|
||||
castlingWhite = CastlingRights.Both,
|
||||
castlingBlack = CastlingRights.Both,
|
||||
enPassantTarget = None,
|
||||
halfMoveClock = history.halfMoveClock,
|
||||
fullMoveNumber = 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
FenParser.parseFen(fen) match
|
||||
case Some(gs) => gs.halfMoveClock shouldBe 42
|
||||
case None => fail("FEN parsing failed")
|
||||
@@ -1,134 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard: initial position places pieces on correct squares"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R7))) shouldBe Some(Some(Piece.BlackPawn))
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Some(Some(Piece.WhiteKing))
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
test("parseBoard: empty board has no pieces"):
|
||||
val fen = "8/8/8/8/8/8/8/8"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe defined
|
||||
board.get.pieces.size shouldBe 0
|
||||
|
||||
test("parseBoard: returns None for missing rank (only 7 ranks)"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: returns None for invalid piece character"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNX"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: partial position with two kings placed correctly"):
|
||||
val fen = "8/8/4k3/8/4K3/8/8/8"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||
board.map(_.pieceAt(Square(File.E, Rank.R4))) shouldBe Some(Some(Piece.WhiteKing))
|
||||
|
||||
test("testRoundTripInitialPosition"):
|
||||
val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
val exportedFen = board.map(FenExporter.boardToFen)
|
||||
|
||||
exportedFen shouldBe Some(originalFen)
|
||||
|
||||
test("testRoundTripEmptyBoard"):
|
||||
val originalFen = "8/8/8/8/8/8/8/8"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
val exportedFen = board.map(FenExporter.boardToFen)
|
||||
|
||||
exportedFen shouldBe Some(originalFen)
|
||||
|
||||
test("testRoundTripPartialPosition"):
|
||||
val originalFen = "8/8/4k3/8/4K3/8/8/8"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
val exportedFen = board.map(FenExporter.boardToFen)
|
||||
|
||||
exportedFen shouldBe Some(originalFen)
|
||||
|
||||
test("parse full FEN - initial position"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe true
|
||||
gameState.get.activeColor shouldBe Color.White
|
||||
gameState.get.castlingWhite.kingSide shouldBe true
|
||||
gameState.get.castlingWhite.queenSide shouldBe true
|
||||
gameState.get.castlingBlack.kingSide shouldBe true
|
||||
gameState.get.castlingBlack.queenSide shouldBe true
|
||||
gameState.get.enPassantTarget shouldBe None
|
||||
gameState.get.halfMoveClock shouldBe 0
|
||||
gameState.get.fullMoveNumber shouldBe 1
|
||||
|
||||
test("parse full FEN - after e4"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.get.activeColor shouldBe Color.Black
|
||||
gameState.get.enPassantTarget shouldBe Some(Square(File.E, Rank.R3))
|
||||
|
||||
test("parse full FEN - invalid parts count"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe false
|
||||
|
||||
test("parse full FEN - invalid color"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe false
|
||||
|
||||
test("parse full FEN - invalid castling"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe false
|
||||
|
||||
test("parseFen: castling '-' produces CastlingRights.None for both sides"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe true
|
||||
gameState.get.castlingWhite.kingSide shouldBe false
|
||||
gameState.get.castlingWhite.queenSide shouldBe false
|
||||
gameState.get.castlingBlack.kingSide shouldBe false
|
||||
gameState.get.castlingBlack.queenSide shouldBe false
|
||||
|
||||
test("parseBoard: returns None when a rank has too many files (overflow beyond 8)"):
|
||||
// "9" alone would advance fileIdx to 9, exceeding 8 → None
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN9"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: returns None when a rank fails to parse (invalid middle rank)"):
|
||||
// Invalid character 'X' in rank 4 should cause failure
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/XXXXXXXX/8/PPPPPPPP/RNBQKBNR"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: returns None when a rank has 9 piece characters (fileIdx > 7)"):
|
||||
// 9 pawns in one rank triggers fileIdx > 7 guard (line 78)
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/PPPPPPPPP"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
@@ -1,114 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.{PieceType, *}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export empty game") {
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val history = GameHistory.empty
|
||||
val pgn = PgnExporter.exportGame(headers, history)
|
||||
|
||||
pgn.contains("[Event \"Test\"]") shouldBe true
|
||||
pgn.contains("[White \"A\"]") shouldBe true
|
||||
pgn.contains("[Black \"B\"]") shouldBe true
|
||||
}
|
||||
|
||||
test("export single move") {
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(headers, history)
|
||||
|
||||
pgn.contains("1. e4") shouldBe true
|
||||
}
|
||||
|
||||
test("export castling") {
|
||||
val headers = Map("Event" -> "Test")
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some(CastleSide.Kingside)))
|
||||
val pgn = PgnExporter.exportGame(headers, history)
|
||||
|
||||
pgn.contains("O-O") shouldBe true
|
||||
}
|
||||
|
||||
test("export game sequence") {
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0")
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
.addMove(HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None))
|
||||
.addMove(HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None, pieceType = PieceType.Knight))
|
||||
val pgn = PgnExporter.exportGame(headers, history)
|
||||
|
||||
pgn.contains("1. e4 c5") shouldBe true
|
||||
pgn.contains("2. Nf3") shouldBe true
|
||||
}
|
||||
|
||||
test("export game with no headers returns only move text") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
|
||||
pgn shouldBe "1. e4 *"
|
||||
}
|
||||
|
||||
test("export queenside castling") {
|
||||
val headers = Map("Event" -> "Test")
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some(CastleSide.Queenside)))
|
||||
val pgn = PgnExporter.exportGame(headers, history)
|
||||
|
||||
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 ("e8=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 ("e8=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 ("e8=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 ("e8=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 ("e4")
|
||||
pgn should not include ("=")
|
||||
}
|
||||
|
||||
test("exportGame uses Result header as termination marker"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
|
||||
pgn should endWith("1/2-1/2")
|
||||
|
||||
test("exportGame with no Result header still uses * as default"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn shouldBe "1. e4 *"
|
||||
@@ -1,451 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse PGN headers only") {
|
||||
val pgn = """[Event "Test Game"]
|
||||
[Site "Earth"]
|
||||
[Date "2026.03.28"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
game.get.headers("Event") shouldBe "Test Game"
|
||||
game.get.headers("White") shouldBe "Alice"
|
||||
game.get.headers("Result") shouldBe "1-0"
|
||||
game.get.moves shouldBe List()
|
||||
}
|
||||
|
||||
test("parse PGN simple game") {
|
||||
val pgn = """[Event "Test"]
|
||||
[Site "?"]
|
||||
[Date "2026.03.28"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
[Result "*"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
game.get.moves.length shouldBe 6
|
||||
// e4: e2-e4
|
||||
game.get.moves(0).from shouldBe Square(File.E, Rank.R2)
|
||||
game.get.moves(0).to shouldBe Square(File.E, Rank.R4)
|
||||
}
|
||||
|
||||
test("parse PGN move with capture") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. e4 e5 2. Nxe5
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
game.get.moves.length shouldBe 3
|
||||
// Nxe5: knight captures on e5
|
||||
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||
}
|
||||
|
||||
test("parse PGN castling") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
// O-O is kingside castling: king e1-g1
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.from shouldBe Square(File.E, Rank.R1)
|
||||
lastMove.to shouldBe Square(File.G, Rank.R1)
|
||||
lastMove.castleSide.isDefined shouldBe true
|
||||
}
|
||||
|
||||
test("parse PGN empty moves") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
[Result "1-0"]
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
game.get.moves.length shouldBe 0
|
||||
}
|
||||
|
||||
test("parse PGN black kingside castling O-O") {
|
||||
// After e4 e5 Nf3 Nc6 Bc4 Bc5, black can castle kingside
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val blackCastle = game.get.moves.last
|
||||
blackCastle.castleSide shouldBe Some(CastleSide.Kingside)
|
||||
blackCastle.from shouldBe Square(File.E, Rank.R8)
|
||||
blackCastle.to shouldBe Square(File.G, Rank.R8)
|
||||
}
|
||||
|
||||
test("parse PGN result tokens are skipped") {
|
||||
// Result tokens like 1-0, 0-1, 1/2-1/2, * should be silently skipped
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. e4 e5 1-0
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
game.get.moves.length shouldBe 2
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: unrecognised token returns None and is skipped") {
|
||||
val board = Board.initial
|
||||
val history = GameHistory.empty
|
||||
// "zzz" is not valid algebraic notation
|
||||
val result = PgnParser.parseAlgebraicMove("zzz", board, history, Color.White)
|
||||
result shouldBe None
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: piece moves use charToPieceType for N B R Q K") {
|
||||
// Test that piece type characters are recognised
|
||||
val board = Board.initial
|
||||
val history = GameHistory.empty
|
||||
|
||||
// Nf3 - knight move
|
||||
val nMove = PgnParser.parseAlgebraicMove("Nf3", board, history, Color.White)
|
||||
nMove.isDefined shouldBe true
|
||||
nMove.get.to shouldBe Square(File.F, Rank.R3)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: single char that is too short returns None") {
|
||||
val board = Board.initial
|
||||
val history = GameHistory.empty
|
||||
// Single char that is not castling and cleaned length < 2
|
||||
val result = PgnParser.parseAlgebraicMove("e", board, history, Color.White)
|
||||
result shouldBe None
|
||||
}
|
||||
|
||||
test("parse PGN with file disambiguation hint") {
|
||||
// Use a position where two rooks can reach the same square to test file hint
|
||||
// Rooks on a1 and h1, destination d1 - "Rad1" uses file 'a' to disambiguate
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val history = GameHistory.empty
|
||||
|
||||
val result = PgnParser.parseAlgebraicMove("Rad1", board, history, Color.White)
|
||||
result.isDefined shouldBe true
|
||||
result.get.from shouldBe Square(File.A, Rank.R1)
|
||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||
}
|
||||
|
||||
test("parse PGN with rank disambiguation hint") {
|
||||
// Two rooks on a1 and a4 can reach a3 - "R1a3" uses rank '1' to disambiguate
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val history = GameHistory.empty
|
||||
|
||||
val result = PgnParser.parseAlgebraicMove("R1a3", board, history, Color.White)
|
||||
result.isDefined shouldBe true
|
||||
result.get.from shouldBe Square(File.A, Rank.R1)
|
||||
result.get.to shouldBe Square(File.A, Rank.R3)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: charToPieceType covers all piece letters including B R Q K") {
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
// Bishop move
|
||||
val piecesForBishop: Map[Square, Piece] = Map(
|
||||
Square(File.C, Rank.R1) -> Piece(Color.White, PieceType.Bishop),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val boardBishop = Board(piecesForBishop)
|
||||
val bResult = PgnParser.parseAlgebraicMove("Bd2", boardBishop, GameHistory.empty, Color.White)
|
||||
bResult.isDefined shouldBe true
|
||||
|
||||
// Rook move
|
||||
val piecesForRook: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val boardRook = Board(piecesForRook)
|
||||
val rResult = PgnParser.parseAlgebraicMove("Ra4", boardRook, GameHistory.empty, Color.White)
|
||||
rResult.isDefined shouldBe true
|
||||
|
||||
// Queen move
|
||||
val piecesForQueen: Map[Square, Piece] = Map(
|
||||
Square(File.D, Rank.R1) -> Piece(Color.White, PieceType.Queen),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val boardQueen = Board(piecesForQueen)
|
||||
val qResult = PgnParser.parseAlgebraicMove("Qd4", boardQueen, GameHistory.empty, Color.White)
|
||||
qResult.isDefined shouldBe true
|
||||
|
||||
// King move
|
||||
val piecesForKing: Map[Square, Piece] = Map(
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val boardKing = Board(piecesForKing)
|
||||
val kResult = PgnParser.parseAlgebraicMove("Ke2", boardKing, GameHistory.empty, Color.White)
|
||||
kResult.isDefined shouldBe true
|
||||
}
|
||||
|
||||
test("parse PGN queenside castling O-O-O") {
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
|
||||
lastMove.from shouldBe Square(File.E, Rank.R1)
|
||||
lastMove.to shouldBe Square(File.C, Rank.R1)
|
||||
}
|
||||
|
||||
test("parse PGN black queenside castling O-O-O") {
|
||||
// After sufficient moves, black castles queenside
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
|
||||
lastMove.from shouldBe Square(File.E, Rank.R8)
|
||||
lastMove.to shouldBe Square(File.C, Rank.R8)
|
||||
}
|
||||
|
||||
test("parse PGN with unrecognised token in move text is silently skipped") {
|
||||
// "INVALID" is not valid PGN; it should be skipped and remaining moves parsed
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. e4 INVALID e5
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
// e4 parsed, INVALID skipped, e5 parsed
|
||||
game.get.moves.length shouldBe 2
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: file+rank disambiguation with piece letter") {
|
||||
// "Rae1" notation: piece R, disambig "a" -> hint is "a", piece letter is uppercase first char of disambig
|
||||
// But since disambig="a" which is not uppercase, the piece letter comes from clean.head
|
||||
// Test "Rae1" style: R is clean.head uppercase, disambig "a" is the hint
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val history = GameHistory.empty
|
||||
|
||||
// "Rae4" - Rook from a-file to e4; disambig = "a", clean.head = 'R' uppercase
|
||||
val result = PgnParser.parseAlgebraicMove("Rae4", board, history, Color.White)
|
||||
result.isDefined shouldBe true
|
||||
result.get.from shouldBe Square(File.A, Rank.R4)
|
||||
result.get.to shouldBe Square(File.E, Rank.R4)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: charToPieceType returns None for unknown character") {
|
||||
// 'Z' is not a valid piece letter - the regex clean should return None
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val board = Board.initial
|
||||
val history = GameHistory.empty
|
||||
|
||||
// "Ze4" - Z is not a valid piece, charToPieceType('Z') returns None
|
||||
// The result will be None because requiredPieceType is None and filtering by None.forall = true
|
||||
// so it finds any piece that can reach e4, but since clean="Ze4" -> destStr="e4", disambig="Z"
|
||||
// disambig.head.isUpper so charToPieceType('Z') is called
|
||||
val result = PgnParser.parseAlgebraicMove("Ze4", board, history, Color.White)
|
||||
// With None piece type, forall(pt => ...) is vacuously true so any piece reaching e4 is candidate
|
||||
// But there's no piece named Z so requiredPieceType=None, meaning any piece can match
|
||||
// This tests that charToPieceType('Z') returns None without crashing
|
||||
result shouldBe defined // will find a pawn or whatever reaches e4
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: uppercase dest-only notation hits clean.head.isUpper and charToPieceType unknown char") {
|
||||
// "E4" - clean = "E4", disambig = "", clean.head = 'E' is upper, charToPieceType('E') returns None
|
||||
// This exercises line 97 (else if clean.head.isUpper) and line 152 (case _ => None)
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val board = Board.initial
|
||||
val history = GameHistory.empty
|
||||
// 'E' is not a valid piece type but we still get a result since requiredPieceType is None
|
||||
val result = PgnParser.parseAlgebraicMove("E4", board, history, Color.White)
|
||||
// Result may be defined (pawn that can reach e4) or None; main goal is no crash and line coverage
|
||||
result should not be null // just verifies code path executes without exception
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove: rank disambiguation with digit outside 1-8 hits matchesHint else-true branch") {
|
||||
// Build a board with a Rook that can be targeted with a disambiguation hint containing '9'
|
||||
// hint = "9" → c = '9', not in a-h, not in 1-8, triggers else true
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val history = GameHistory.empty
|
||||
|
||||
// "R9d1" - clean = "R9d1", destStr = "d1", disambig = "R9"
|
||||
// disambig.head = 'R' is upper -> charToPieceType('R') = Rook, hint = "9"
|
||||
// matchesHint called with hint "9" -> '9' not in a-h, not in 1-8 -> else true
|
||||
val result = PgnParser.parseAlgebraicMove("R9d1", board, history, Color.White)
|
||||
// Should find a rook (hint "9" matches everything)
|
||||
result.isDefined shouldBe true
|
||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
||||
result.isDefined should be (true)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
result.get.to should be (Square(File.E, Rank.R8))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Rook") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Bishop") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Knight") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
}
|
||||
|
||||
test("parsePgn applies promoted piece to board for subsequent moves") {
|
||||
// Build a board with a white pawn on e7 plus the two kings
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.E, Rank.R7) -> Piece(Color.White, PieceType.Pawn),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
||||
move.isDefined should be (true)
|
||||
move.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
// After applying the promotion the square e8 should hold a White Queen
|
||||
val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to)
|
||||
val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen))
|
||||
promotedBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
}
|
||||
|
||||
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
|
||||
// This test exercises lines 53-58 in PgnParser.parseMovesText which contain
|
||||
// the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight
|
||||
val pgn = """[Event "Promotion Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
// Move 10 is h2h1=Q (black pawn promotes to queen)
|
||||
val blackPromotionToQ = game.get.moves(9) // 0-indexed
|
||||
blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen)
|
||||
|
||||
// Move 11 is a7a8=R (white pawn promotes to rook)
|
||||
val whitePromotionToR = game.get.moves(10)
|
||||
whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Bishop through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Knight through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight)
|
||||
}
|
||||
|
||||
test("extractPromotion returns None for invalid promotion letter") {
|
||||
// Regex =([A-Z]) now captures any uppercase letter, so =X is matched but case _ => None fires
|
||||
val result = PgnParser.extractPromotion("e7e8=X")
|
||||
result shouldBe None
|
||||
}
|
||||
|
||||
test("extractPromotion returns None when no promotion in notation") {
|
||||
val result = PgnParser.extractPromotion("e7e8")
|
||||
result shouldBe None
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("validatePgn: valid simple game returns Right with correct moves"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) =>
|
||||
game.moves.length shouldBe 4
|
||||
game.headers("Event") shouldBe "Test"
|
||||
game.moves(0).from shouldBe Square(File.E, Rank.R2)
|
||||
game.moves(0).to shouldBe Square(File.E, Rank.R4)
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: empty move text returns Right with no moves"):
|
||||
val pgn = "[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n"
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) => game.moves shouldBe empty
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: impossible position returns Left"):
|
||||
// "Nf6" without any preceding moves — there is no knight that can reach f6 from f3 yet
|
||||
// but e4 e5 Nf3 is OK; then Nd4 — knight on f3 can go to d4
|
||||
// Let's use a clearly impossible move: "Qd4" from the initial position (queen can't move)
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. Qd4
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(_) => succeed
|
||||
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
|
||||
|
||||
test("validatePgn: unrecognised token returns Left"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. e4 GARBAGE e5
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(_) => succeed
|
||||
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
|
||||
|
||||
test("validatePgn: result tokens are skipped (not treated as errors)"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. e4 e5 1-0
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) => game.moves.length shouldBe 2
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: valid kingside castling is accepted"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) =>
|
||||
game.moves.last.castleSide shouldBe Some(CastleSide.Kingside)
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: castling when not legal returns Left"):
|
||||
// Try to castle on move 1 — impossible from initial position (pieces in the way)
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. O-O
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(_) => succeed
|
||||
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
|
||||
|
||||
test("validatePgn: valid queenside castling is accepted"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) =>
|
||||
game.moves.last.castleSide shouldBe Some(CastleSide.Queenside)
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: disambiguation with two rooks is accepted"):
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
// Build PGN from this custom board is hard, so test strictParseAlgebraicMove directly
|
||||
val board = Board(pieces)
|
||||
// Both rooks can reach d1 — "Rad1" should pick the a-file rook
|
||||
val result = PgnParser.validatePgn("[Event \"T\"]\n\n1. e4")
|
||||
// This tests the main flow; below we test disambiguation in isolation
|
||||
result.isRight shouldBe true
|
||||
|
||||
test("validatePgn: ambiguous move without disambiguation returns Left"):
|
||||
// Set up a position where two identical pieces can reach the same square
|
||||
// We can test this via the strict path: two rooks, target square, no disambiguation hint
|
||||
// Build it through a sequence that leads to two rooks on same file targeting same square
|
||||
// This is hard to construct via PGN alone; verify via a known impossible disambiguation
|
||||
val pgn = "[Event \"T\"]\n\n1. e4"
|
||||
PgnParser.validatePgn(pgn).isRight shouldBe true
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.collection.mutable
|
||||
|
||||
class ObservableThreadSafetyTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private class TestObservable extends Observable:
|
||||
def testNotifyObservers(event: GameEvent): Unit =
|
||||
notifyObservers(event)
|
||||
|
||||
private class CountingObserver extends Observer:
|
||||
@volatile private var eventCount = 0
|
||||
@volatile private var lastEvent: Option[GameEvent] = None
|
||||
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
eventCount += 1
|
||||
lastEvent = Some(event)
|
||||
|
||||
private def createTestEvent(): GameEvent =
|
||||
BoardResetEvent(
|
||||
board = Board.initial,
|
||||
history = GameHistory.empty,
|
||||
turn = Color.White
|
||||
)
|
||||
|
||||
test("Observable is thread-safe for concurrent subscribe and notify"):
|
||||
val observable = new TestObservable()
|
||||
val testEvent = createTestEvent()
|
||||
@volatile var raceConditionCaught = false
|
||||
|
||||
// Thread 1: repeatedly notifies observers with long iteration
|
||||
val notifierThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500000 do
|
||||
observable.testNotifyObservers(testEvent)
|
||||
} catch {
|
||||
case _: java.util.ConcurrentModificationException =>
|
||||
raceConditionCaught = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: rapidly subscribes/unsubscribes observers during notify
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500000 do
|
||||
val obs = new CountingObserver()
|
||||
observable.subscribe(obs)
|
||||
observable.unsubscribe(obs)
|
||||
} catch {
|
||||
case _: java.util.ConcurrentModificationException =>
|
||||
raceConditionCaught = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
notifierThread.start()
|
||||
subscriberThread.start()
|
||||
notifierThread.join()
|
||||
subscriberThread.join()
|
||||
|
||||
raceConditionCaught shouldBe false
|
||||
|
||||
test("Observable is thread-safe for concurrent subscribe, unsubscribe, and notify"):
|
||||
val observable = new TestObservable()
|
||||
val testEvent = createTestEvent()
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
val observers = mutable.ListBuffer[CountingObserver]()
|
||||
|
||||
// Pre-subscribe some observers
|
||||
for _ <- 1 to 10 do
|
||||
val obs = new CountingObserver()
|
||||
observers += obs
|
||||
observable.subscribe(obs)
|
||||
|
||||
// Thread 1: notifies observers
|
||||
val notifierThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 5000 do
|
||||
observable.testNotifyObservers(testEvent)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: subscribes new observers
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 5000 do
|
||||
val obs = new CountingObserver()
|
||||
observable.subscribe(obs)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 3: unsubscribes observers
|
||||
val unsubscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for i <- 1 to 5000 do
|
||||
if observers.nonEmpty then
|
||||
val obs = observers(i % observers.size)
|
||||
observable.unsubscribe(obs)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
notifierThread.start()
|
||||
subscriberThread.start()
|
||||
unsubscriberThread.start()
|
||||
notifierThread.join()
|
||||
subscriberThread.join()
|
||||
unsubscriberThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
|
||||
test("Observable.observerCount is thread-safe during concurrent modifications"):
|
||||
val observable = new TestObservable()
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
val countResults = mutable.ListBuffer[Int]()
|
||||
|
||||
// Thread 1: subscribes observers
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
observable.subscribe(new CountingObserver())
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: reads observer count
|
||||
val readerThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
val count = observable.observerCount
|
||||
countResults += count
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
subscriberThread.start()
|
||||
readerThread.start()
|
||||
subscriberThread.join()
|
||||
readerThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
// Count should never go backwards
|
||||
for i <- 1 until countResults.size do
|
||||
countResults(i) >= countResults(i - 1) shouldBe true
|
||||
@@ -1,43 +0,0 @@
|
||||
package de.nowchess.chess.view
|
||||
|
||||
import de.nowchess.api.board.Piece
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PieceUnicodeTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("White King maps to ♔"):
|
||||
Piece.WhiteKing.unicode shouldBe "\u2654"
|
||||
|
||||
test("White Queen maps to ♕"):
|
||||
Piece.WhiteQueen.unicode shouldBe "\u2655"
|
||||
|
||||
test("White Rook maps to ♖"):
|
||||
Piece.WhiteRook.unicode shouldBe "\u2656"
|
||||
|
||||
test("White Bishop maps to ♗"):
|
||||
Piece.WhiteBishop.unicode shouldBe "\u2657"
|
||||
|
||||
test("White Knight maps to ♘"):
|
||||
Piece.WhiteKnight.unicode shouldBe "\u2658"
|
||||
|
||||
test("White Pawn maps to ♙"):
|
||||
Piece.WhitePawn.unicode shouldBe "\u2659"
|
||||
|
||||
test("Black King maps to ♚"):
|
||||
Piece.BlackKing.unicode shouldBe "\u265A"
|
||||
|
||||
test("Black Queen maps to ♛"):
|
||||
Piece.BlackQueen.unicode shouldBe "\u265B"
|
||||
|
||||
test("Black Rook maps to ♜"):
|
||||
Piece.BlackRook.unicode shouldBe "\u265C"
|
||||
|
||||
test("Black Bishop maps to ♝"):
|
||||
Piece.BlackBishop.unicode shouldBe "\u265D"
|
||||
|
||||
test("Black Knight maps to ♞"):
|
||||
Piece.BlackKnight.unicode shouldBe "\u265E"
|
||||
|
||||
test("Black Pawn maps to ♟"):
|
||||
Piece.BlackPawn.unicode shouldBe "\u265F"
|
||||
@@ -1,41 +0,0 @@
|
||||
package de.nowchess.chess.view
|
||||
|
||||
import de.nowchess.api.board.{Board, File, Piece, Rank, Square}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class RendererTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("render contains column header with all file labels"):
|
||||
Renderer.render(Board.initial) should include("a b c d e f g h")
|
||||
|
||||
test("render output begins with the column header"):
|
||||
Renderer.render(Board.initial) should startWith(" a b c d e f g h")
|
||||
|
||||
test("render contains rank labels 1 through 8"):
|
||||
val output = Renderer.render(Board.initial)
|
||||
for rank <- 1 to 8 do output should include(s"$rank ")
|
||||
|
||||
test("render shows white king unicode symbol for initial board"):
|
||||
Renderer.render(Board.initial) should include("\u2654")
|
||||
|
||||
test("render shows black king unicode symbol for initial board"):
|
||||
Renderer.render(Board.initial) should include("\u265A")
|
||||
|
||||
test("render contains ANSI light-square background code"):
|
||||
Renderer.render(Board.initial) should include("\u001b[48;5;223m")
|
||||
|
||||
test("render contains ANSI dark-square background code"):
|
||||
Renderer.render(Board.initial) should include("\u001b[48;5;130m")
|
||||
|
||||
test("render uses white-piece foreground color for white pieces"):
|
||||
Renderer.render(Board.initial) should include("\u001b[97m")
|
||||
|
||||
test("render uses black-piece foreground color for black pieces"):
|
||||
Renderer.render(Board.initial) should include("\u001b[30m")
|
||||
|
||||
test("render of empty board contains no piece unicode"):
|
||||
val output = Renderer.render(Board(Map.empty))
|
||||
output should include("a b c d e f g h")
|
||||
output should not include "\u2654"
|
||||
output should not include "\u265A"
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=8
|
||||
MINOR=10
|
||||
PATCH=0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
## (2026-04-06)
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:rule"))
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
trait GameContextExport:
|
||||
|
||||
def exportGameContext(context: GameContext): String
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
trait GameContextImport:
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext]
|
||||
+22
-18
@@ -1,10 +1,10 @@
|
||||
package de.nowchess.chess.notation
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.{CastlingRights, GameState}
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
|
||||
object FenExporter:
|
||||
object FenExporter extends GameContextExport:
|
||||
|
||||
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
|
||||
def boardToFen(board: Board): String =
|
||||
@@ -24,32 +24,35 @@ object FenExporter:
|
||||
if emptyCount > 0 then
|
||||
rankChars += emptyCount.toString.charAt(0)
|
||||
emptyCount = 0
|
||||
rankChars += pieceToPgnChar(piece)
|
||||
rankChars += pieceToFenChar(piece)
|
||||
case None =>
|
||||
emptyCount += 1
|
||||
|
||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
||||
rankChars.mkString
|
||||
|
||||
/** Convert a GameState to a complete FEN string. */
|
||||
def gameStateToFen(state: GameState): String =
|
||||
val piecePlacement = state.piecePlacement
|
||||
val activeColor = if state.activeColor == Color.White then "w" else "b"
|
||||
val castling = castlingString(state.castlingWhite, state.castlingBlack)
|
||||
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
|
||||
/** Convert a GameContext to a complete FEN string. */
|
||||
def gameContextToFen(context: GameContext): String =
|
||||
val piecePlacement = boardToFen(context.board)
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val fullMoveNumber = 1 + (context.moves.length / 2)
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||
|
||||
def exportGameContext(context: GameContext): String = gameContextToFen(context)
|
||||
|
||||
/** Convert castling rights to FEN notation. */
|
||||
private def castlingString(white: CastlingRights, black: CastlingRights): String =
|
||||
val wk = if white.kingSide then "K" else ""
|
||||
val wq = if white.queenSide then "Q" else ""
|
||||
val bk = if black.kingSide then "k" else ""
|
||||
val bq = if black.queenSide then "q" else ""
|
||||
private def castlingString(rights: CastlingRights): String =
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val result = s"$wk$wq$bk$bq"
|
||||
if result.isEmpty then "-" else result
|
||||
|
||||
/** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */
|
||||
private def pieceToPgnChar(piece: Piece): Char =
|
||||
private def pieceToFenChar(piece: Piece): Char =
|
||||
val base = piece.pieceType match
|
||||
case PieceType.Pawn => 'p'
|
||||
case PieceType.Knight => 'n'
|
||||
@@ -58,3 +61,4 @@ object FenExporter:
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
|
||||
+36
-28
@@ -1,48 +1,55 @@
|
||||
package de.nowchess.chess.notation
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
|
||||
object FenParser:
|
||||
object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse a complete FEN string into a GameState.
|
||||
* Returns None if the format is invalid. */
|
||||
def parseFen(fen: String): Option[GameState] =
|
||||
/** Parse a complete FEN string into a GameContext.
|
||||
* Returns Left with error message if the format is invalid. */
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
val parts = fen.trim.split("\\s+")
|
||||
Option.when(parts.length == 6)(parts).flatMap: parts =>
|
||||
if parts.length != 6 then
|
||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
else
|
||||
for
|
||||
_ <- parseBoard(parts(0))
|
||||
activeColor <- parseColor(parts(1))
|
||||
castlingRights <- parseCastling(parts(2))
|
||||
enPassant <- parseEnPassant(parts(3))
|
||||
halfMoveClock <- parts(4).toIntOption
|
||||
fullMoveNumber <- parts(5).toIntOption
|
||||
if halfMoveClock >= 0 && fullMoveNumber >= 1
|
||||
yield GameState(
|
||||
piecePlacement = parts(0),
|
||||
activeColor = activeColor,
|
||||
castlingWhite = castlingRights._1,
|
||||
castlingBlack = castlingRights._2,
|
||||
enPassantTarget = enPassant,
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = activeColor,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassant,
|
||||
halfMoveClock = halfMoveClock,
|
||||
fullMoveNumber = fullMoveNumber,
|
||||
status = GameStatus.InProgress
|
||||
moves = List.empty
|
||||
)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
|
||||
/** Parse active color ("w" or "b"). */
|
||||
private def parseColor(s: String): Option[Color] =
|
||||
if s == "w" then Some(Color.White)
|
||||
else if s == "b" then Some(Color.Black)
|
||||
else None
|
||||
|
||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
|
||||
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
|
||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
||||
private def parseCastling(s: String): Option[CastlingRights] =
|
||||
if s == "-" then
|
||||
Some((CastlingRights.None, CastlingRights.None))
|
||||
Some(CastlingRights.None)
|
||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
|
||||
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
|
||||
Some((white, black))
|
||||
Some(CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
))
|
||||
else
|
||||
None
|
||||
|
||||
@@ -101,3 +108,4 @@ object FenParser:
|
||||
case 'k' => Some(PieceType.King)
|
||||
case _ => None
|
||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
object PgnExporter extends GameContextExport:
|
||||
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*"
|
||||
)
|
||||
|
||||
exportGame(headers, context.moves)
|
||||
|
||||
/** Export a game with headers and moves to PGN format. */
|
||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if moves.isEmpty then ""
|
||||
else
|
||||
var ctx = GameContext.initial
|
||||
val sanMoves = moves.map { move =>
|
||||
val algebraic = moveToAlgebraic(move, ctx.board)
|
||||
ctx = DefaultRules.applyMove(ctx)(move)
|
||||
algebraic
|
||||
}
|
||||
|
||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a Move to Standard Algebraic Notation using the board state before the move. */
|
||||
private def moveToAlgebraic(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
case MoveType.Promotion(pp) =>
|
||||
val promSuffix = pp match
|
||||
case PromotionPiece.Queen => "=Q"
|
||||
case PromotionPiece.Rook => "=R"
|
||||
case PromotionPiece.Bishop => "=B"
|
||||
case PromotionPiece.Knight => "=N"
|
||||
val isCapture = boardBefore.pieceAt(move.to).isDefined
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$promSuffix"
|
||||
else s"${move.to}$promSuffix"
|
||||
case MoveType.Normal(isCapture) =>
|
||||
val dest = move.to.toString
|
||||
val capStr = if isCapture then "x" else ""
|
||||
boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
|
||||
case PieceType.Pawn =>
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x$dest"
|
||||
else dest
|
||||
case PieceType.Knight => s"N$capStr$dest"
|
||||
case PieceType.Bishop => s"B$capStr$dest"
|
||||
case PieceType.Rook => s"R$capStr$dest"
|
||||
case PieceType.Queen => s"Q$capStr$dest"
|
||||
case PieceType.King => s"K$capStr$dest"
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
/** A parsed PGN game containing headers and the resolved move list. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[Move]
|
||||
)
|
||||
|
||||
object PgnParser extends GameContextImport:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||
}
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
Some(PgnGame(headers, moves))
|
||||
|
||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*]$""".r
|
||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
||||
|
||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved Moves. */
|
||||
private def parseMovesText(moveText: String): List[Move] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
val (_, _, moves) = tokens.foldLeft(
|
||||
(GameContext.initial, Color.White, List.empty[Move])
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
(nextCtx, color.opposite, acc :+ move)
|
||||
moves
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside)
|
||||
Option.when(DefaultRules.legalMoves(ctx)(Square(File.E, rank)).contains(move))(move)
|
||||
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside)
|
||||
Option.when(DefaultRules.legalMoves(ctx)(Square(File.E, rank)).contains(move))(move)
|
||||
|
||||
case _ =>
|
||||
parseRegularMove(notation, ctx, color)
|
||||
|
||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
||||
private def parseRegularMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board.pieceAt(move.from).exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType)
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
|
||||
candidates.headOption
|
||||
|
||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
hint.forall(c =>
|
||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||
else true
|
||||
)
|
||||
|
||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||
promotion match
|
||||
case None => move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
private[pgn] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||
val promotionPattern = """=([A-Z])""".r
|
||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
||||
m.group(1) match
|
||||
case "Q" => Some(PromotionPiece.Queen)
|
||||
case "R" => Some(PromotionPiece.Rook)
|
||||
case "B" => Some(PromotionPiece.Bishop)
|
||||
case "N" => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
}
|
||||
|
||||
/** Convert a piece-letter character to a PieceType. */
|
||||
private def charToPieceType(c: Char): Option[PieceType] =
|
||||
c match
|
||||
case 'N' => Some(PieceType.Knight)
|
||||
case 'B' => Some(PieceType.Bishop)
|
||||
case 'R' => Some(PieceType.Rook)
|
||||
case 'Q' => Some(PieceType.Queen)
|
||||
case 'K' => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
||||
case (acc, token) =>
|
||||
acc.flatMap { case (ctx, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def context(
|
||||
piecePlacement: String,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moveCount: Int
|
||||
): GameContext =
|
||||
val board = FenParser.parseBoard(piecePlacement).getOrElse(
|
||||
fail(s"Invalid test board FEN: $piecePlacement")
|
||||
)
|
||||
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = turn,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.fill(moveCount)(dummyMove)
|
||||
)
|
||||
|
||||
test("exportGameContextToFen handles initial and typical developed position"):
|
||||
FenExporter.gameContextToFen(GameContext.initial) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
val gameContext = context(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
|
||||
turn = Color.Black,
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
)
|
||||
FenExporter.gameContextToFen(gameContext) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||
|
||||
test("export handles castling rights variants and en-passant with counters"):
|
||||
val noCastling = context(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights.None,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
)
|
||||
FenExporter.gameContextToFen(noCastling) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
|
||||
val partialCastling = context(
|
||||
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights(
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 5,
|
||||
moveCount = 4
|
||||
)
|
||||
FenExporter.gameContextToFen(partialCastling) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
||||
|
||||
val withEnPassant = context(
|
||||
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
||||
halfMoveClock = 2,
|
||||
moveCount = 4
|
||||
)
|
||||
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
||||
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||
|
||||
test("halfMoveClock round-trips through FEN export and import"):
|
||||
val gameContext = GameContext(
|
||||
board = Board.initial,
|
||||
turn = Color.White,
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 42,
|
||||
moves = List.empty
|
||||
)
|
||||
val fen = FenExporter.gameContextToFen(gameContext)
|
||||
FenParser.parseFen(fen) match
|
||||
case Right(ctx) => ctx.halfMoveClock shouldBe 42
|
||||
case Left(err) => fail(s"FEN parsing failed: $err")
|
||||
|
||||
test("exportGameContext forwards to gameContextToFen"):
|
||||
val ctx = GameContext.initial
|
||||
|
||||
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
FenParser.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||
FenParser.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
FenParser.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
|
||||
|
||||
test("importGameContext returns Right for valid and Left for invalid FEN"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
FenParser.importGameContext(fen).isRight shouldBe true
|
||||
FenParser.importGameContext("invalid fen string").isLeft shouldBe true
|
||||
|
||||
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||
FenParser.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGame renders headers and basic move text"):
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
|
||||
emptyPgn.contains("[Event \"Test\"]") shouldBe true
|
||||
emptyPgn.contains("[White \"A\"]") shouldBe true
|
||||
emptyPgn.contains("[Black \"B\"]") shouldBe true
|
||||
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
|
||||
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
||||
|
||||
test("exportGame renders castling grouping and result markers"):
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
|
||||
|
||||
val seq = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
|
||||
)
|
||||
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
||||
grouped should include("1. e4 c5")
|
||||
grouped should include("2. Nf3")
|
||||
|
||||
val oneMove = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
|
||||
PgnExporter.exportGame(Map.empty, oneMove) shouldBe "1. e4 *"
|
||||
PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), oneMove) should endWith("1/2-1/2")
|
||||
|
||||
test("exportGame handles promotion suffixes and normal move formatting"):
|
||||
List(
|
||||
PromotionPiece.Queen -> "=Q",
|
||||
PromotionPiece.Rook -> "=R",
|
||||
PromotionPiece.Bishop -> "=B",
|
||||
PromotionPiece.Knight -> "=N"
|
||||
).foreach { (piece, suffix) =>
|
||||
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
||||
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
|
||||
}
|
||||
|
||||
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
normal should include("e4")
|
||||
normal should not include "="
|
||||
|
||||
test("exportGameContext preserves moves and default headers"):
|
||||
val moves = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
|
||||
)
|
||||
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
||||
withMoves.contains("e4") shouldBe true
|
||||
withMoves.contains("e5") shouldBe true
|
||||
|
||||
val empty = PgnExporter.exportGameContext(GameContext.initial)
|
||||
empty.contains("[Event") shouldBe true
|
||||
empty.contains("*") shouldBe true
|
||||
|
||||
private def sq(alg: String): Square =
|
||||
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
|
||||
|
||||
test("exportGame emits notation for all normal piece types and captures"):
|
||||
val moves = List(
|
||||
Move(sq("e2"), sq("e4")),
|
||||
Move(sq("a7"), sq("a6")),
|
||||
Move(sq("g1"), sq("f3")),
|
||||
Move(sq("b7"), sq("b6")),
|
||||
Move(sq("f1"), sq("b5"), MoveType.Normal(true)),
|
||||
Move(sq("g8"), sq("f6")),
|
||||
Move(sq("a1"), sq("a8"), MoveType.Normal(true)),
|
||||
Move(sq("c7"), sq("c6")),
|
||||
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
||||
)
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||
|
||||
pgn should include("e4")
|
||||
pgn should include("Nf3")
|
||||
pgn should include("Bxb5")
|
||||
pgn should include("Rxa8")
|
||||
pgn should include("Qxd7")
|
||||
pgn should include("Kxe2")
|
||||
|
||||
test("exportGame emits en-passant and promotion capture notation"):
|
||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
||||
|
||||
pgn should include("exd3")
|
||||
pgn should include("exf8=Q")
|
||||
pawnCapturePgn should include("exd3")
|
||||
quietPromotionPgn should include("e8=Q")
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
|
||||
val headerOnly = """[Event "Test Game"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]"""
|
||||
val onlyHeaders = PgnParser.parsePgn(headerOnly)
|
||||
onlyHeaders.isDefined shouldBe true
|
||||
onlyHeaders.get.headers("Event") shouldBe "Test Game"
|
||||
onlyHeaders.get.headers("White") shouldBe "Alice"
|
||||
|
||||
val simple = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6""")
|
||||
simple.map(_.moves.length) shouldBe Some(4)
|
||||
|
||||
val capture = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. Nf3 e5 2. Nxe5""")
|
||||
capture.map(_.moves.length) shouldBe Some(3)
|
||||
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||
|
||||
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
|
||||
whiteKs.moveType shouldBe MoveType.CastleKingside
|
||||
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
||||
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
||||
|
||||
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
|
||||
whiteQs.moveType shouldBe MoveType.CastleQueenside
|
||||
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
||||
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
||||
|
||||
val blackKs = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
|
||||
blackKs.moveType shouldBe MoveType.CastleKingside
|
||||
blackKs.from shouldBe Square(File.E, Rank.R8)
|
||||
|
||||
val blackQs = PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
|
||||
blackQs.moveType shouldBe MoveType.CastleQueenside
|
||||
blackQs.from shouldBe Square(File.E, Rank.R8)
|
||||
blackQs.to shouldBe Square(File.C, Rank.R8)
|
||||
|
||||
PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
|
||||
PgnParser.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
|
||||
|
||||
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
||||
val board = Board.initial
|
||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
|
||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
||||
|
||||
val rookPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val rankPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
|
||||
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||
king.isDefined shouldBe true
|
||||
king.get.from shouldBe Square(File.E, Rank.R1)
|
||||
king.get.to shouldBe Square(File.E, Rank.R2)
|
||||
|
||||
test("parseAlgebraicMove handles all promotion targets"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
|
||||
test("importGameContext accepts valid and empty PGN"):
|
||||
val pgn = """[Event "Test"]
|
||||
|
||||
1. e4 e5"""
|
||||
PgnParser.importGameContext(pgn).isRight shouldBe true
|
||||
PgnParser.importGameContext("").isRight shouldBe true
|
||||
|
||||
test("parser edge cases: uppercase token hint chars and promotion mismatch handling"):
|
||||
PgnParser.parseAlgebraicMove("E5", GameContext.initial, Color.White) shouldBe None
|
||||
PgnParser.parseAlgebraicMove("N?f3", GameContext.initial, Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
||||
PgnParser.extractPromotion("e7e8=X") shouldBe None
|
||||
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White) shouldBe None
|
||||
|
||||
test("parseAlgebraicMove rejects too-short notation and invalid piece letters"):
|
||||
val initial = GameContext.initial
|
||||
|
||||
PgnParser.parseAlgebraicMove("e", initial, Color.White) shouldBe None
|
||||
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
||||
|
||||
test("parseAlgebraicMove rejects notation with invalid promotion piece"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||
val context = GameContext.initial.withBoard(board)
|
||||
|
||||
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
||||
|
||||
test("parsePgn silently skips unknown tokens"):
|
||||
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||
|
||||
parsed.map(_.moves.size) shouldBe Some(2)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.MoveType
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("validatePgn accepts valid games including castling and result tokens"):
|
||||
val pgn =
|
||||
"""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6
|
||||
"""
|
||||
val valid = PgnParser.validatePgn(pgn)
|
||||
valid.isRight shouldBe true
|
||||
valid.toOption.get.moves.length shouldBe 4
|
||||
valid.toOption.get.moves.head.from shouldBe Square(File.E, Rank.R2)
|
||||
|
||||
val withResult = PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 1-0
|
||||
""")
|
||||
withResult.map(_.moves.length) shouldBe Right(2)
|
||||
|
||||
val kCastle = PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
|
||||
""")
|
||||
kCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleKingside)
|
||||
|
||||
val qCastle = PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
|
||||
""")
|
||||
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
||||
|
||||
test("validatePgn rejects impossible illegal and garbage tokens"):
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. Qd4
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. O-O
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
|
||||
1. e4 GARBAGE e5
|
||||
""").isLeft shouldBe true
|
||||
|
||||
test("validatePgn accepts empty move text and minimal valid header"):
|
||||
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
||||
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=1
|
||||
@@ -0,0 +1 @@
|
||||
## (2026-04-06)
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
|
||||
testImplementation(project(":modules:io"))
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.nowchess.rules
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.move.Move
|
||||
|
||||
/** Extension point for chess rule variants (standard, Chess960, etc.).
|
||||
* All rule queries are stateless: given a GameContext, return the answer.
|
||||
*/
|
||||
trait RuleSet:
|
||||
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move]
|
||||
|
||||
/** Legal moves for `square`: candidates that don't leave own king in check. */
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move]
|
||||
|
||||
/** All legal moves for the side to move. */
|
||||
def allLegalMoves(context: GameContext): List[Move]
|
||||
|
||||
/** True if the side to move's king is in check. */
|
||||
def isCheck(context: GameContext): Boolean
|
||||
|
||||
/** True if the side to move is in check and has no legal moves. */
|
||||
def isCheckmate(context: GameContext): Boolean
|
||||
|
||||
/** True if the side to move is not in check and has no legal moves. */
|
||||
def isStalemate(context: GameContext): Boolean
|
||||
|
||||
/** True if neither side has enough material to checkmate. */
|
||||
def isInsufficientMaterial(context: GameContext): Boolean
|
||||
|
||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||
def isFiftyMoveRule(context: GameContext): Boolean
|
||||
|
||||
/** Apply a legal move to produce the next game context.
|
||||
* Handles all special move types: castling, en passant, promotion.
|
||||
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
def applyMove(context: GameContext)(move: Move): GameContext
|
||||
@@ -0,0 +1,387 @@
|
||||
package de.nowchess.rules.sets
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.rules.RuleSet
|
||||
|
||||
import scala.annotation.tailrec
|
||||
|
||||
/** Standard chess rules implementation.
|
||||
* Handles move generation, validation, check/checkmate/stalemate detection.
|
||||
*/
|
||||
object DefaultRules extends RuleSet:
|
||||
|
||||
// ── Direction vectors ──────────────────────────────────────────────
|
||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||
private val KnightJumps: List[(Int, Int)] =
|
||||
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
|
||||
|
||||
// ── Pawn configuration helpers ─────────────────────────────────────
|
||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
||||
private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
|
||||
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
||||
if piece.color != context.turn then List.empty[Move]
|
||||
else piece.pieceType match
|
||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
||||
}
|
||||
|
||||
override def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
candidateMoves(context)(square).filter { move =>
|
||||
!leavesKingInCheck(context, move)
|
||||
}
|
||||
|
||||
override def allLegalMoves(context: GameContext): List[Move] =
|
||||
Square.all.flatMap(sq => legalMoves(context)(sq)).toList
|
||||
|
||||
override def isCheck(context: GameContext): Boolean =
|
||||
kingSquare(context.board, context.turn)
|
||||
.fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite))
|
||||
|
||||
override def isCheckmate(context: GameContext): Boolean =
|
||||
isCheck(context) && allLegalMoves(context).isEmpty
|
||||
|
||||
override def isStalemate(context: GameContext): Boolean =
|
||||
!isCheck(context) && allLegalMoves(context).isEmpty
|
||||
|
||||
override def isInsufficientMaterial(context: GameContext): Boolean =
|
||||
insufficientMaterial(context.board)
|
||||
|
||||
override def isFiftyMoveRule(context: GameContext): Boolean =
|
||||
context.halfMoveClock >= 100
|
||||
|
||||
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
|
||||
|
||||
private def slidingMoves(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dirs: List[(Int, Int)]
|
||||
): List[Move] =
|
||||
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
||||
|
||||
private def castRay(
|
||||
board: Board,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dir: (Int, Int)
|
||||
): List[Move] =
|
||||
@tailrec
|
||||
def loop(sq: Square, acc: List[Move]): List[Move] =
|
||||
sq.offset(dir._1, dir._2) match
|
||||
case None => acc
|
||||
case Some(next) =>
|
||||
board.pieceAt(next) match
|
||||
case None => loop(next, Move(from, next) :: acc)
|
||||
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
|
||||
case Some(_) => acc
|
||||
loop(from, Nil).reverse
|
||||
|
||||
// ── Knight ─────────────────────────────────────────────────────────
|
||||
|
||||
private def knightCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
): List[Move] =
|
||||
KnightJumps.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
// ── King ───────────────────────────────────────────────────────────
|
||||
|
||||
private def kingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
): List[Move] =
|
||||
val steps = QueenDirs.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
steps ++ castlingCandidates(context, from, color)
|
||||
|
||||
// ── Castling ───────────────────────────────────────────────────────
|
||||
|
||||
private case class CastlingMove(
|
||||
kingFromAlg: String,
|
||||
kingToAlg: String,
|
||||
middleAlg: String,
|
||||
rookFromAlg: String,
|
||||
moveType: MoveType
|
||||
)
|
||||
|
||||
private def castlingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
): List[Move] =
|
||||
color match
|
||||
case Color.White => whiteCastles(context, from)
|
||||
case Color.Black => blackCastles(context, from)
|
||||
|
||||
private def whiteCastles(context: GameContext, from: Square): List[Move] =
|
||||
val expected = Square.fromAlgebraic("e1").getOrElse(from)
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
|
||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
|
||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
|
||||
moves.toList
|
||||
|
||||
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
||||
val expected = Square.fromAlgebraic("e8").getOrElse(from)
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.blackKingSide,
|
||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
||||
moves.toList
|
||||
|
||||
private def addCastleMove(
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
castlingRight: Boolean,
|
||||
castlingMove: CastlingMove
|
||||
): Unit =
|
||||
if castlingRight then
|
||||
val clearSqs = List(castlingMove.middleAlg, castlingMove.kingToAlg).flatMap(Square.fromAlgebraic)
|
||||
if squaresEmpty(context.board, clearSqs) then
|
||||
for
|
||||
kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
|
||||
km <- Square.fromAlgebraic(castlingMove.middleAlg)
|
||||
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
|
||||
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
|
||||
do
|
||||
val color = context.turn
|
||||
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
|
||||
val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
|
||||
val squaresSafe =
|
||||
!isAttackedBy(context.board, kf, color.opposite) &&
|
||||
!isAttackedBy(context.board, km, color.opposite) &&
|
||||
!isAttackedBy(context.board, kt, color.opposite)
|
||||
|
||||
if kingPresent && rookPresent && squaresSafe then
|
||||
moves += Move(kf, kt, castlingMove.moveType)
|
||||
|
||||
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
||||
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
||||
|
||||
// ── Pawn ───────────────────────────────────────────────────────────
|
||||
|
||||
private def pawnCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
): List[Move] =
|
||||
val fwd = pawnForward(color)
|
||||
val startRank = pawnStartRank(color)
|
||||
val promoRank = pawnPromoRank(color)
|
||||
|
||||
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
val double = Option.when(from.rank.ordinal == startRank) {
|
||||
from.offset(0, fwd).flatMap { mid =>
|
||||
Option.when(context.board.pieceAt(mid).isEmpty) {
|
||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
}.flatten
|
||||
}
|
||||
}.flatten
|
||||
|
||||
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
||||
from.offset(df, fwd).flatMap { to =>
|
||||
context.board.pieceAt(to).filter(_.color != color).map(_ => to)
|
||||
}
|
||||
}
|
||||
|
||||
val epCaptures: List[Move] = context.enPassantSquare.toList.flatMap { epSq =>
|
||||
List(-1, 1).flatMap { df =>
|
||||
from.offset(df, fwd).filter(_ == epSq).map { to =>
|
||||
Move(from, epSq, MoveType.EnPassant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||
if dest.rank.ordinal == promoRank then
|
||||
List(
|
||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
||||
stepMoves ++ captureMoves ++ epCaptures
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────────
|
||||
|
||||
private def kingSquare(board: Board, color: Color): Option[Square] =
|
||||
Square.all.find(sq =>
|
||||
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
|
||||
)
|
||||
|
||||
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
||||
Square.all.exists { sq =>
|
||||
board.pieceAt(sq).fold(false) { p =>
|
||||
p.color == attacker && squareAttacks(board, sq, p, target)
|
||||
}
|
||||
}
|
||||
|
||||
private def squareAttacks(board: Board, from: Square, piece: Piece, target: Square): Boolean =
|
||||
val fwd = pawnForward(piece.color)
|
||||
piece.pieceType match
|
||||
case PieceType.Pawn =>
|
||||
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
||||
case PieceType.Knight =>
|
||||
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||
case PieceType.King =>
|
||||
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
|
||||
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
||||
dirs.exists { dir =>
|
||||
@tailrec
|
||||
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
|
||||
case None => false
|
||||
case Some(next) if next == target => true
|
||||
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
|
||||
case Some(_) => false
|
||||
loop(from)
|
||||
}
|
||||
|
||||
private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
|
||||
val nextBoard = context.board.applyMove(move)
|
||||
val nextContext = context.withBoard(nextBoard)
|
||||
isCheck(nextContext)
|
||||
|
||||
// ── Move application ───────────────────────────────────────────────
|
||||
|
||||
override def applyMove(context: GameContext)(move: Move): GameContext =
|
||||
val color = context.turn
|
||||
val board = context.board
|
||||
|
||||
val newBoard = move.moveType match
|
||||
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
|
||||
case MoveType.CastleQueenside => applyCastle(board, color, kingside = false)
|
||||
case MoveType.EnPassant => applyEnPassant(board, move)
|
||||
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
||||
case MoveType.Normal(_) => board.applyMove(move)
|
||||
|
||||
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||
val newEnPassantSquare = computeEnPassantSquare(board, move)
|
||||
val isCapture = move.moveType match
|
||||
case MoveType.Normal(capture) => capture
|
||||
case MoveType.EnPassant => true
|
||||
case _ => board.pieceAt(move.to).isDefined
|
||||
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
|
||||
context
|
||||
.withBoard(newBoard)
|
||||
.withTurn(color.opposite)
|
||||
.withCastlingRights(newCastlingRights)
|
||||
.withEnPassantSquare(newEnPassantSquare)
|
||||
.withHalfMoveClock(newClock)
|
||||
.withMove(move)
|
||||
|
||||
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||
if kingside then
|
||||
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
else
|
||||
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||
board
|
||||
.removed(kingFrom).removed(rookFrom)
|
||||
.updated(kingTo, king)
|
||||
.updated(rookTo, rook)
|
||||
|
||||
private def applyEnPassant(board: Board, move: Move): Board =
|
||||
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||
val capturedSquare = Square(move.to.file, capturedRank)
|
||||
board.applyMove(move).removed(capturedSquare)
|
||||
|
||||
private def applyPromotion(board: Board, move: Move, color: Color, pp: PromotionPiece): Board =
|
||||
val promotedType = pp match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
||||
|
||||
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
||||
val piece = board.pieceAt(move.from)
|
||||
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
||||
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
||||
|
||||
// Helper to check if a square is a rook's starting square
|
||||
val whiteKingsideRook = Square(File.H, Rank.R1)
|
||||
val whiteQueensideRook = Square(File.A, Rank.R1)
|
||||
val blackKingsideRook = Square(File.H, Rank.R8)
|
||||
val blackQueensideRook = Square(File.A, Rank.R8)
|
||||
|
||||
var r = rights
|
||||
if isKingMove then r = r.revokeColor(color)
|
||||
else if isRookMove then
|
||||
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
// Also revoke if a rook is captured
|
||||
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
r
|
||||
|
||||
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
|
||||
val piece = board.pieceAt(move.from)
|
||||
val isDoublePawnPush = piece.exists(_.pieceType == PieceType.Pawn) &&
|
||||
math.abs(move.to.rank.ordinal - move.from.rank.ordinal) == 2
|
||||
if isDoublePawnPush then
|
||||
// EP square is the square the pawn passed through
|
||||
val epRankOrd = (move.from.rank.ordinal + move.to.rank.ordinal) / 2
|
||||
Some(Square(move.from.file, Rank.values(epRankOrd)))
|
||||
else None
|
||||
|
||||
// ── Insufficient material ──────────────────────────────────────────
|
||||
|
||||
private def insufficientMaterial(board: Board): Boolean =
|
||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
||||
pieces match
|
||||
case Nil => true
|
||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case List(p1, p2)
|
||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||
&& p1.color != p2.color => true
|
||||
case _ => false
|
||||
@@ -0,0 +1,300 @@
|
||||
package de.nowchess.rule
|
||||
|
||||
import de.nowchess.api.board.{CastlingRights, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def contextFromFen(fen: String): GameContext =
|
||||
FenParser.parseFen(fen).fold(err => fail(err), identity)
|
||||
|
||||
private def sq(alg: String): Square =
|
||||
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
|
||||
|
||||
test("isCheckmate returns true for a known mate pattern"):
|
||||
val context = contextFromFen("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3")
|
||||
|
||||
DefaultRules.isCheck(context) shouldBe true
|
||||
DefaultRules.isCheckmate(context) shouldBe true
|
||||
DefaultRules.allLegalMoves(context) shouldBe empty
|
||||
|
||||
test("isStalemate returns true for a known stalemate pattern"):
|
||||
val context = contextFromFen("7k/5K2/6Q1/8/8/8/8/8 b - - 0 1")
|
||||
|
||||
DefaultRules.isCheck(context) shouldBe false
|
||||
DefaultRules.isStalemate(context) shouldBe true
|
||||
DefaultRules.allLegalMoves(context) shouldBe empty
|
||||
|
||||
test("isInsufficientMaterial returns true for king versus king"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns true for king and bishop versus king"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/3BK3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("isInsufficientMaterial returns false for king and rook versus king"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe false
|
||||
|
||||
test("isFiftyMoveRule returns true when halfMoveClock is 100"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 100 1")
|
||||
|
||||
DefaultRules.isFiftyMoveRule(context) shouldBe true
|
||||
|
||||
test("applyMove toggles turn and records move"):
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
val next = DefaultRules.applyMove(GameContext.initial)(move)
|
||||
|
||||
next.turn shouldBe Color.Black
|
||||
next.moves.lastOption shouldBe Some(move)
|
||||
|
||||
test("applyMove sets en passant square after double pawn push"):
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
val next = DefaultRules.applyMove(GameContext.initial)(move)
|
||||
|
||||
next.enPassantSquare shouldBe Some(sq("e3"))
|
||||
|
||||
test("applyMove clears en passant square for non double pawn push"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
|
||||
val move = Move(sq("e2"), sq("e3"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.enPassantSquare shouldBe None
|
||||
|
||||
test("applyMove resets halfMoveClock on pawn move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.halfMoveClock shouldBe 0
|
||||
|
||||
test("applyMove increments halfMoveClock on quiet non pawn move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
|
||||
val move = Move(sq("g1"), sq("f3"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.halfMoveClock shouldBe 8
|
||||
|
||||
test("applyMove resets halfMoveClock on capture"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.halfMoveClock shouldBe 0
|
||||
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
|
||||
test("applyMove updates castling rights after king move"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||
val move = Move(sq("e1"), sq("e2"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.whiteKingSide shouldBe false
|
||||
next.castlingRights.whiteQueenSide shouldBe false
|
||||
next.castlingRights.blackKingSide shouldBe true
|
||||
next.castlingRights.blackQueenSide shouldBe true
|
||||
|
||||
test("applyMove updates castling rights after rook move from h1"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
|
||||
val move = Move(sq("h1"), sq("h2"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.whiteKingSide shouldBe false
|
||||
next.castlingRights.whiteQueenSide shouldBe true
|
||||
|
||||
test("applyMove revokes opponent castling right when rook on starting square is captured"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.blackQueenSide shouldBe false
|
||||
|
||||
test("applyMove executes kingside castling and repositions king and rook"):
|
||||
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.board.pieceAt(sq("g1")) shouldBe Some(Piece(Color.White, PieceType.King))
|
||||
next.board.pieceAt(sq("f1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
next.board.pieceAt(sq("e1")) shouldBe None
|
||||
next.board.pieceAt(sq("h1")) shouldBe None
|
||||
|
||||
test("applyMove executes queenside castling and repositions king and rook"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
|
||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.board.pieceAt(sq("c1")) shouldBe Some(Piece(Color.White, PieceType.King))
|
||||
next.board.pieceAt(sq("d1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
next.board.pieceAt(sq("e1")) shouldBe None
|
||||
next.board.pieceAt(sq("a1")) shouldBe None
|
||||
|
||||
test("applyMove executes en passant and removes captured pawn"):
|
||||
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.board.pieceAt(sq("d6")) shouldBe Some(Piece(Color.White, PieceType.Pawn))
|
||||
next.board.pieceAt(sq("d5")) shouldBe None
|
||||
next.board.pieceAt(sq("e5")) shouldBe None
|
||||
|
||||
test("applyMove executes promotion with selected piece type"):
|
||||
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Knight))
|
||||
next.board.pieceAt(sq("a7")) shouldBe None
|
||||
|
||||
test("candidateMoves returns empty for opponent piece on selected square"):
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
|
||||
DefaultRules.candidateMoves(context)(sq("e2")) shouldBe empty
|
||||
|
||||
test("legalMoves keeps king safe by filtering pinned bishop moves"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/r1B1K3/8 w - - 0 1")
|
||||
|
||||
val bishopMoves = DefaultRules.legalMoves(context)(sq("c2"))
|
||||
|
||||
bishopMoves shouldBe empty
|
||||
|
||||
test("applyMove preserves black castling rights after white kingside castling"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.whiteKingSide shouldBe false
|
||||
next.castlingRights.whiteQueenSide shouldBe false
|
||||
next.castlingRights.blackKingSide shouldBe true
|
||||
next.castlingRights.blackQueenSide shouldBe true
|
||||
|
||||
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||
val context = GameContext(
|
||||
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||
turn = Color.Black,
|
||||
castlingRights = CastlingRights(true, true, false, false),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
)
|
||||
|
||||
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
||||
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||
|
||||
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
||||
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
||||
|
||||
test("isInsufficientMaterial returns true for opposite color bishops only"):
|
||||
val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
|
||||
|
||||
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||
|
||||
test("candidateMoves for rook includes enemy capture move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
|
||||
|
||||
val rookMoves = DefaultRules.candidateMoves(context)(sq("a1"))
|
||||
|
||||
rookMoves.exists(m => m.to == sq("h1") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
|
||||
|
||||
test("candidateMoves for knight includes enemy capture move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/3p4/5N2/4K3 w - - 0 1")
|
||||
|
||||
val knightMoves = DefaultRules.candidateMoves(context)(sq("f2"))
|
||||
|
||||
knightMoves.exists(m => m.to == sq("d3") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
|
||||
|
||||
test("candidateMoves includes black kingside and queenside castling options"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
|
||||
val kingMoves = DefaultRules.candidateMoves(context)(sq("e8"))
|
||||
|
||||
kingMoves.exists(_.moveType == MoveType.CastleKingside) shouldBe true
|
||||
kingMoves.exists(_.moveType == MoveType.CastleQueenside) shouldBe true
|
||||
|
||||
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.board.pieceAt(sq("g8")) shouldBe Some(Piece(Color.Black, PieceType.King))
|
||||
next.board.pieceAt(sq("f8")) shouldBe Some(Piece(Color.Black, PieceType.Rook))
|
||||
next.board.pieceAt(sq("e8")) shouldBe None
|
||||
next.board.pieceAt(sq("h8")) shouldBe None
|
||||
|
||||
test("applyMove revokes black castling rights when black rook moves from h8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("h8"), sq("h7"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.blackKingSide shouldBe false
|
||||
next.castlingRights.blackQueenSide shouldBe true
|
||||
|
||||
test("applyMove revokes black queenside castling right when black rook moves from a8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("a8"), sq("a7"))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.blackKingSide shouldBe true
|
||||
next.castlingRights.blackQueenSide shouldBe false
|
||||
|
||||
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
|
||||
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
|
||||
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
next.castlingRights.blackKingSide shouldBe false
|
||||
|
||||
test("candidateMoves creates all promotion move variants for black pawn"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
|
||||
val to = sq("a1")
|
||||
|
||||
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
|
||||
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
|
||||
|
||||
promotions.toSet shouldBe Set(
|
||||
PromotionPiece.Queen,
|
||||
PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop,
|
||||
PromotionPiece.Knight
|
||||
)
|
||||
|
||||
test("applyMove promotion supports queen rook and bishop targets"):
|
||||
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
|
||||
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||
|
||||
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.nowchess.rule
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val rules = DefaultRules
|
||||
|
||||
// ── Pawn moves ──────────────────────────────────────────────────
|
||||
|
||||
test("pawn can move forward one square"):
|
||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("pawn can move forward two squares from starting position"):
|
||||
val context = GameContext.initial
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
|
||||
|
||||
test("pawn can capture diagonally"):
|
||||
// FEN: white pawn e4, black pawn d5
|
||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
|
||||
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
||||
|
||||
test("pawn cannot move backward"):
|
||||
// FEN: white pawn on e4
|
||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
|
||||
|
||||
// ── King in check filtering ──────────────────────────────────────
|
||||
|
||||
test("moving king out of check removes it from legal moves if king stays in check"):
|
||||
// FEN: white king e1, black rook e8, white tries to move away
|
||||
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
|
||||
moves.filter(m => m.from == Square(File.E, Rank.R1)).nonEmpty shouldBe true
|
||||
|
||||
test("king cannot move to square attacked by opponent"):
|
||||
// FEN: white king e1, black rook e2 defended by black king e3
|
||||
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King cannot move to e2 (occupied and attacked)
|
||||
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
|
||||
kingMovesToE2.isEmpty shouldBe true
|
||||
|
||||
// ── Castling legality ────────────────────────────────────────────
|
||||
|
||||
test("castling kingside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling queenside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when castling rights are false"):
|
||||
// FEN: king and rook in position, but castling rights disabled
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when king is in check"):
|
||||
// FEN: white king e1 in check from black rook e8
|
||||
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when path has piece in the way"):
|
||||
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
// ── En passant legality ──────────────────────────────────────────
|
||||
|
||||
test("en passant is legal when en passant square is set"):
|
||||
// FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("en passant is illegal when en passant square is none"):
|
||||
// FEN: white pawn e5, black pawn d5, but no en passant square
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.isEmpty shouldBe true
|
||||
|
||||
// ── Pinned pieces ────────────────────────────────────────────────
|
||||
|
||||
test("pinned piece cannot move and expose king to check"):
|
||||
// FEN: white king e1, white bishop d2 (pinned), black rook a2
|
||||
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// Bishop on d2 is pinned by rook on a2; it cannot move
|
||||
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
|
||||
bishopMoves.isEmpty shouldBe true
|
||||
|
||||
test("piece blocking a check is legal"):
|
||||
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
|
||||
// Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2
|
||||
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// White is in check; only moves that block or move the king are legal
|
||||
moves.nonEmpty shouldBe true
|
||||
@@ -0,0 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=1
|
||||
@@ -17,3 +17,19 @@
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
## (2026-04-02)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
## (2026-04-03)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import org.gradle.api.file.DuplicatesStrategy
|
||||
import org.gradle.jvm.tasks.Jar
|
||||
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage")
|
||||
@@ -21,7 +24,9 @@ scala {
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedPackages.set(listOf(
|
||||
"de.nowchess.ui.gui"
|
||||
"de.nowchess.ui.gui",
|
||||
"de.nowchess.ui.terminal",
|
||||
"de.nowchess.ui.Main",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -38,6 +43,10 @@ tasks.named<JavaExec>("run") {
|
||||
standardInput = System.`in`
|
||||
}
|
||||
|
||||
tasks.named<Jar>("jar") {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
@@ -52,7 +61,9 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:core"))
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:io"))
|
||||
|
||||
// ScalaFX dependencies
|
||||
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
import scalafx.Includes.*
|
||||
import scalafx.application.Platform
|
||||
import scalafx.geometry.{Insets, Pos}
|
||||
import scalafx.scene.control.{Button, ButtonType, ChoiceDialog, Label}
|
||||
import scalafx.scene.layout.{BorderPane, GridPane, HBox, VBox, StackPane}
|
||||
import scalafx.scene.layout.{BorderPane, GridPane, HBox, StackPane, VBox}
|
||||
import scalafx.scene.paint.Color as FXColor
|
||||
import scalafx.scene.shape.Rectangle
|
||||
import scalafx.scene.text.{Font, Text}
|
||||
import scalafx.stage.Stage
|
||||
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square, File, Rank}
|
||||
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.command.{MoveCommand, MoveResult}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.logic.{CastlingRightsCalculator, EnPassantCalculator, GameHistory, GameRules, withCastle}
|
||||
import de.nowchess.chess.notation.{FenExporter, FenParser, PgnExporter, PgnParser}
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||
import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||
|
||||
/** ScalaFX chess board view that displays the game state.
|
||||
* Uses chess sprites and color palette.
|
||||
@@ -36,6 +38,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
private var selectedSquare: Option[Square] = None
|
||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||
|
||||
private var undoButton: Button = uninitialized
|
||||
private var redoButton: Button = uninitialized
|
||||
|
||||
// Initialize UI
|
||||
initializeBoard()
|
||||
|
||||
@@ -69,15 +74,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
spacing = 10
|
||||
alignment = Pos.Center
|
||||
children = Seq(
|
||||
new Button("Undo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canUndo then engine.undo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
||||
{
|
||||
undoButton = new Button("Undo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canUndo then engine.undo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
||||
disable = !engine.canUndo
|
||||
}
|
||||
undoButton
|
||||
},
|
||||
new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canRedo then engine.redo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||
{
|
||||
redoButton = new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => if engine.canRedo then engine.redo()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||
disable = !engine.canRedo
|
||||
}
|
||||
redoButton
|
||||
},
|
||||
new Button("Reset") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
@@ -164,10 +177,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
if piece.color == currentTurn then
|
||||
selectedSquare = Some(clickedSquare)
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
val legalDests = GameRules.legalMoves(currentBoard, engine.history, currentTurn)
|
||||
.collect { case (`clickedSquare`, to) => to }
|
||||
|
||||
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +230,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
|
||||
stackPane.children = children
|
||||
}
|
||||
|
||||
|
||||
updateUndoRedoButtons()
|
||||
|
||||
def updateUndoRedoButtons(): Unit =
|
||||
if undoButton != null then undoButton.disable = !engine.canUndo
|
||||
if redoButton != null then redoButton.disable = !engine.canRedo
|
||||
|
||||
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
|
||||
squareViews.get((rank, file)).foreach { stackPane =>
|
||||
val bgRect = new Rectangle {
|
||||
@@ -258,56 +278,32 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
|
||||
private def doFenExport(): Unit =
|
||||
val state = GameState(
|
||||
piecePlacement = FenExporter.boardToFen(currentBoard),
|
||||
activeColor = currentTurn,
|
||||
castlingWhite = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.White),
|
||||
castlingBlack = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.Black),
|
||||
enPassantTarget = EnPassantCalculator.enPassantTarget(currentBoard, engine.history),
|
||||
halfMoveClock = 0,
|
||||
fullMoveNumber = engine.history.moves.size / 2 + 1,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
showCopyDialog("FEN Export", FenExporter.gameStateToFen(state))
|
||||
doExport(FenExporter, "FEN")
|
||||
|
||||
private def doFenImport(): Unit =
|
||||
showInputDialog("FEN Import", rows = 1).foreach { fen =>
|
||||
FenParser.parseFen(fen) match
|
||||
case None => showMessage("Invalid FEN")
|
||||
case Some(state) =>
|
||||
FenParser.parseBoard(state.piecePlacement) match
|
||||
case None => showMessage("Invalid FEN board")
|
||||
case Some(board) => engine.loadPosition(board, GameHistory.empty, state.activeColor)
|
||||
}
|
||||
doImport(FenParser, "FEN")
|
||||
|
||||
private def doPgnExport(): Unit =
|
||||
showCopyDialog("PGN Export", PgnExporter.exportGame(Map.empty, engine.history))
|
||||
doExport(PgnExporter, "PGN")
|
||||
|
||||
private def doPgnImport(): Unit =
|
||||
showInputDialog("PGN Import", rows = 6).foreach { pgn =>
|
||||
PgnParser.parsePgn(pgn) match
|
||||
case None => showMessage("Invalid PGN")
|
||||
case Some(pgnGame) =>
|
||||
val (finalBoard, finalHistory) = pgnGame.moves.foldLeft((Board.initial, GameHistory.empty)):
|
||||
case ((board, history), move) =>
|
||||
val color = if history.moves.size % 2 == 0 then Color.White else Color.Black
|
||||
val newBoard = move.castleSide match
|
||||
case Some(side) => board.withCastle(color, side)
|
||||
case None =>
|
||||
val (b, _) = board.withMove(move.from, move.to)
|
||||
move.promotionPiece match
|
||||
case Some(pp) =>
|
||||
val pt = pp match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
b.updated(move.to, Piece(color, pt))
|
||||
case None => b
|
||||
(newBoard, history.addMove(move))
|
||||
val finalTurn = if finalHistory.moves.size % 2 == 0 then Color.White else Color.Black
|
||||
engine.loadPosition(finalBoard, finalHistory, finalTurn)
|
||||
doImport(PgnParser, "PGN")
|
||||
|
||||
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
|
||||
val exported = exporter.exportGameContext(engine.context)
|
||||
showCopyDialog(s"$formatName Export", exported)
|
||||
}
|
||||
|
||||
private def doImport(importer: GameContextImport, formatName: String): Unit = {
|
||||
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
||||
importer.importGameContext(input) match
|
||||
case Right(gameContext) =>
|
||||
engine.loadPosition(gameContext)
|
||||
showMessage(s"✓ $formatName loaded successfully!")
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ $formatName Error: $err")
|
||||
}
|
||||
}
|
||||
|
||||
private def showCopyDialog(title: String, content: String): Unit =
|
||||
val area = new javafx.scene.control.TextArea(content)
|
||||
@@ -339,3 +335,4 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
dialog.initOwner(stage.delegate)
|
||||
val result = dialog.showAndWait()
|
||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
|
||||
@@ -17,38 +17,57 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
Platform.runLater {
|
||||
event match
|
||||
case e: MoveExecutedEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
e.capturedPiece.foreach { piece =>
|
||||
boardView.showMessage(s"Captured: $piece on ${e.toSquare}")
|
||||
}
|
||||
|
||||
case e: CheckDetectedEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.showMessage(s"${e.turn.label} is in check!")
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
boardView.showMessage(s"${e.context.turn.label} is in check!")
|
||||
|
||||
case e: CheckmateEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
|
||||
|
||||
case e: StalemateEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
|
||||
|
||||
case e: InvalidMoveEvent =>
|
||||
boardView.showMessage(s"⚠️ ${e.reason}")
|
||||
|
||||
case e: BoardResetEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
boardView.showMessage("Board has been reset to initial position.")
|
||||
|
||||
case e: PromotionRequiredEvent =>
|
||||
boardView.showPromotionDialog(e.from, e.to)
|
||||
|
||||
case e: DrawClaimedEvent =>
|
||||
boardView.updateBoard(e.board, e.turn)
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.")
|
||||
case e: FiftyMoveRuleAvailableEvent =>
|
||||
|
||||
case e: FiftyMoveRuleAvailableEvent =>
|
||||
boardView.showMessage("50-move rule available! The game is a draw.")
|
||||
|
||||
case e: MoveUndoneEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
|
||||
boardView.updateUndoRedoButtons()
|
||||
|
||||
case e: MoveRedoneEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
if e.capturedPiece.isDefined then
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
||||
else
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||
boardView.updateUndoRedoButtons()
|
||||
|
||||
case e: PgnLoadedEvent =>
|
||||
boardView.updateBoard(e.context.board, e.context.turn)
|
||||
boardView.showMessage("✓ PGN loaded successfully!")
|
||||
boardView.updateUndoRedoButtons()
|
||||
}
|
||||
|
||||
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user