feat: NCS-25 Add linters to keep quality up (#27)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #27 Reviewed-by: Leon Hermann <lq@blackhole.local> Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# Normalize text files in the repo
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Keep Windows command scripts in CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Keep Unix shell scripts in LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Binary assets (no EOL normalization / textual diff)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.bmp binary
|
||||||
|
*.ico binary
|
||||||
|
|
||||||
|
# ML / model / numeric artifacts
|
||||||
|
*.bin binary
|
||||||
|
*.pt binary
|
||||||
|
*.pth binary
|
||||||
|
*.onnx binary
|
||||||
|
*.h5 binary
|
||||||
|
*.hdf5 binary
|
||||||
|
*.pb binary
|
||||||
|
*.tflite binary
|
||||||
|
*.npy binary
|
||||||
|
*.npz binary
|
||||||
|
*.safetensors binary
|
||||||
|
|
||||||
|
# Firmware / hex-like artifacts
|
||||||
|
*.hex binary
|
||||||
|
|
||||||
|
# Packaged binaries
|
||||||
|
*.jar binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.gz binary
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
rules = [
|
||||||
|
DisableSyntax,
|
||||||
|
LeakingImplicitClassVal,
|
||||||
|
NoValInForComprehension,
|
||||||
|
ProcedureSyntax,
|
||||||
|
]
|
||||||
|
|
||||||
|
DisableSyntax.noVars = true
|
||||||
|
DisableSyntax.noThrows = true
|
||||||
|
DisableSyntax.noNulls = true
|
||||||
|
DisableSyntax.noReturns = true
|
||||||
|
DisableSyntax.noAsInstanceOf = true
|
||||||
|
DisableSyntax.noIsInstanceOf = true
|
||||||
|
DisableSyntax.noXml = true
|
||||||
|
DisableSyntax.noFinalize = true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
version = 3.8.1
|
||||||
|
runner.dialect = scala3
|
||||||
|
maxColumn = 120
|
||||||
|
indent.main = 2
|
||||||
|
align.preset = more
|
||||||
|
trailingCommas = always
|
||||||
|
rewrite.rules = [SortImports, RedundantBraces]
|
||||||
|
rewrite.scala3.convertToNewSyntax = true
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
YOU CAN:
|
YOU CAN:
|
||||||
- Edit and use the asset in any commercial or non commercial project
|
- Edit and use the asset in any commercial or non commercial project
|
||||||
- Use the asset in any commercial or non commercial project
|
- Use the asset in any commercial or non commercial project
|
||||||
|
|
||||||
YOU CAN'T:
|
YOU CAN'T:
|
||||||
- Resell or distribute the asset to others
|
- Resell or distribute the asset to others
|
||||||
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
|
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
|
||||||
@@ -37,6 +37,11 @@ Try to stick to these commands for consistency.
|
|||||||
|
|
||||||
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
||||||
|
|
||||||
|
### Linters
|
||||||
|
|
||||||
|
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
|
||||||
|
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.sonarqube") version "7.2.3.7755"
|
id("org.sonarqube") version "7.2.3.7755"
|
||||||
id("org.scoverage") version "8.1" apply false
|
id("org.scoverage") version "8.1" apply false
|
||||||
|
id("com.diffplug.spotless") version "8.4.0" apply false
|
||||||
|
id("io.github.cosmicsilence.scalafix") version "0.2.6" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -40,3 +42,27 @@ val versions = mapOf(
|
|||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply(plugin = "com.diffplug.spotless")
|
||||||
|
|
||||||
|
pluginManager.withPlugin("scala") {
|
||||||
|
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||||
|
scala {
|
||||||
|
scalafmt().configFile(rootProject.file(".scalafmt.conf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(plugin = "io.github.cosmicsilence.scalafix")
|
||||||
|
configure<io.github.cosmicsilence.scalafix.ScalafixExtension> {
|
||||||
|
configFile.set(rootProject.file(".scalafix.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable SemanticDB config for the scoverage source set — it sets -sourceroot to
|
||||||
|
// the root project dir, which conflicts with scoverage's own -sourceroot and causes
|
||||||
|
// reportTestScoverage to fail with "No source root found".
|
||||||
|
tasks.matching { it.name in setOf("configSemanticDBScoverage", "checkScalafixScoverage", "checkScalafixTest") }.configureEach {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ object Board:
|
|||||||
def apply(pieces: Map[Square, Piece]): Board = pieces
|
def apply(pieces: Map[Square, Piece]): Board = pieces
|
||||||
|
|
||||||
extension (b: Board)
|
extension (b: Board)
|
||||||
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
||||||
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
|
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
|
||||||
def removed(sq: Square): Board = b.removed(sq)
|
def removed(sq: Square): Board = b.removed(sq)
|
||||||
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
|
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
|
||||||
val captured = b.get(to)
|
val captured = b.get(to)
|
||||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||||
(updatedBoard, captured)
|
(updatedBoard, captured)
|
||||||
def applyMove(move: de.nowchess.api.move.Move): Board =
|
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||||
@@ -21,8 +21,14 @@ object Board:
|
|||||||
|
|
||||||
val initial: Board =
|
val initial: Board =
|
||||||
val backRank: Vector[PieceType] = Vector(
|
val backRank: Vector[PieceType] = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
val entries = for
|
val entries = for
|
||||||
fileIdx <- 0 until 8
|
fileIdx <- 0 until 8
|
||||||
@@ -30,7 +36,7 @@ object Board:
|
|||||||
(Color.White, Rank.R1, backRank(fileIdx)),
|
(Color.White, Rank.R1, backRank(fileIdx)),
|
||||||
(Color.White, Rank.R2, PieceType.Pawn),
|
(Color.White, Rank.R2, PieceType.Pawn),
|
||||||
(Color.Black, Rank.R8, backRank(fileIdx)),
|
(Color.Black, Rank.R8, backRank(fileIdx)),
|
||||||
(Color.Black, Rank.R7, PieceType.Pawn)
|
(Color.Black, Rank.R7, PieceType.Pawn),
|
||||||
)
|
)
|
||||||
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
||||||
Board(entries.toMap)
|
Board(entries.toMap)
|
||||||
|
|||||||
@@ -1,50 +1,48 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
/**
|
/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and
|
||||||
* Unified castling rights tracker for all four sides.
|
* direction.
|
||||||
* Tracks whether castling is still available for each side and direction.
|
*
|
||||||
*
|
* @param whiteKingSide
|
||||||
* @param whiteKingSide White's king-side castling (0-0) still legally available
|
* White's king-side castling (0-0) still legally available
|
||||||
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
|
* @param whiteQueenSide
|
||||||
* @param blackKingSide Black's king-side castling (0-0) still legally available
|
* White's queen-side castling (0-0-0) still legally available
|
||||||
* @param blackQueenSide Black'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(
|
final case class CastlingRights(
|
||||||
whiteKingSide: Boolean,
|
whiteKingSide: Boolean,
|
||||||
whiteQueenSide: Boolean,
|
whiteQueenSide: Boolean,
|
||||||
blackKingSide: Boolean,
|
blackKingSide: Boolean,
|
||||||
blackQueenSide: Boolean
|
blackQueenSide: Boolean,
|
||||||
):
|
):
|
||||||
/**
|
/** Check if either side has any castling rights remaining.
|
||||||
* Check if either side has any castling rights remaining.
|
*/
|
||||||
*/
|
|
||||||
def hasAnyRights: Boolean =
|
def hasAnyRights: Boolean =
|
||||||
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||||
|
|
||||||
/**
|
/** Check if a specific color has any castling rights remaining.
|
||||||
* Check if a specific color has any castling rights remaining.
|
*/
|
||||||
*/
|
|
||||||
def hasRights(color: Color): Boolean = color match
|
def hasRights(color: Color): Boolean = color match
|
||||||
case Color.White => whiteKingSide || whiteQueenSide
|
case Color.White => whiteKingSide || whiteQueenSide
|
||||||
case Color.Black => blackKingSide || blackQueenSide
|
case Color.Black => blackKingSide || blackQueenSide
|
||||||
|
|
||||||
/**
|
/** Revoke all castling rights for a specific color.
|
||||||
* Revoke all castling rights for a specific color.
|
*/
|
||||||
*/
|
|
||||||
def revokeColor(color: Color): CastlingRights = color match
|
def revokeColor(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||||
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||||
|
|
||||||
/**
|
/** Revoke a specific castling right.
|
||||||
* Revoke a specific castling right.
|
*/
|
||||||
*/
|
|
||||||
def revokeKingSide(color: Color): CastlingRights = color match
|
def revokeKingSide(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteKingSide = false)
|
case Color.White => copy(whiteKingSide = false)
|
||||||
case Color.Black => copy(blackKingSide = false)
|
case Color.Black => copy(blackKingSide = false)
|
||||||
|
|
||||||
/**
|
/** Revoke a specific castling right.
|
||||||
* Revoke a specific castling right.
|
*/
|
||||||
*/
|
|
||||||
def revokeQueenSide(color: Color): CastlingRights = color match
|
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteQueenSide = false)
|
case Color.White => copy(whiteQueenSide = false)
|
||||||
case Color.Black => copy(blackQueenSide = false)
|
case Color.Black => copy(blackQueenSide = false)
|
||||||
@@ -55,7 +53,7 @@ object CastlingRights:
|
|||||||
whiteKingSide = false,
|
whiteKingSide = false,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = false
|
blackQueenSide = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** All castling rights available. */
|
/** All castling rights available. */
|
||||||
@@ -63,7 +61,7 @@ object CastlingRights:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = true,
|
whiteQueenSide = true,
|
||||||
blackKingSide = true,
|
blackKingSide = true,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Standard starting position castling rights (both sides can castle both ways). */
|
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ final case class Piece(color: Color, pieceType: PieceType)
|
|||||||
|
|
||||||
object Piece:
|
object Piece:
|
||||||
// Convenience constructors
|
// Convenience constructors
|
||||||
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
|
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
|
||||||
val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight)
|
val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight)
|
||||||
val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
|
val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
|
||||||
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||||
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||||
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
||||||
|
|
||||||
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
|
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
|
||||||
val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight)
|
val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight)
|
||||||
val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
|
val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
|
||||||
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||||
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||||
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
/**
|
/** A file (column) on the chess board, a–h. Ordinal values 0–7 correspond to a–h.
|
||||||
* A file (column) on the chess board, a–h.
|
*/
|
||||||
* Ordinal values 0–7 correspond to a–h.
|
|
||||||
*/
|
|
||||||
enum File:
|
enum File:
|
||||||
case A, B, C, D, E, F, G, H
|
case A, B, C, D, E, F, G, H
|
||||||
|
|
||||||
/**
|
/** A rank (row) on the chess board, 1–8. Ordinal values 0–7 correspond to ranks 1–8.
|
||||||
* A rank (row) on the chess board, 1–8.
|
*/
|
||||||
* Ordinal values 0–7 correspond to ranks 1–8.
|
|
||||||
*/
|
|
||||||
enum Rank:
|
enum Rank:
|
||||||
case R1, R2, R3, R4, R5, R6, R7, R8
|
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||||
|
|
||||||
/**
|
/** A unique square on the board, identified by its file and rank.
|
||||||
* A unique square on the board, identified by its file and rank.
|
*
|
||||||
*
|
* @param file
|
||||||
* @param file the column, a–h
|
* the column, a–h
|
||||||
* @param rank the row, 1–8
|
* @param rank
|
||||||
*/
|
* the row, 1–8
|
||||||
|
*/
|
||||||
final case class Square(file: File, rank: Rank):
|
final case class Square(file: File, rank: Rank):
|
||||||
/** Algebraic notation string, e.g. "e4". */
|
/** Algebraic notation string, e.g. "e4". */
|
||||||
override def toString: String =
|
override def toString: String =
|
||||||
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||||
|
|
||||||
object Square:
|
object Square:
|
||||||
/** Parse a square from algebraic notation (e.g. "e4").
|
/** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
|
||||||
* Returns None if the input is not a valid square name. */
|
*/
|
||||||
def fromAlgebraic(s: String): Option[Square] =
|
def fromAlgebraic(s: String): Option[Square] =
|
||||||
if s.length != 2 then None
|
if s.length != 2 then None
|
||||||
else
|
else
|
||||||
val fileChar = s.charAt(0)
|
val fileChar = s.charAt(0)
|
||||||
val rankChar = s.charAt(1)
|
val rankChar = s.charAt(1)
|
||||||
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
||||||
val rankOpt =
|
val rankOpt =
|
||||||
rankChar.toString.toIntOption.flatMap(n =>
|
rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
|
||||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
|
||||||
)
|
|
||||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||||
|
|
||||||
val all: IndexedSeq[Square] =
|
val all: IndexedSeq[Square] =
|
||||||
@@ -46,12 +41,13 @@ object Square:
|
|||||||
f <- File.values.toIndexedSeq
|
f <- File.values.toIndexedSeq
|
||||||
yield Square(f, r)
|
yield Square(f, r)
|
||||||
|
|
||||||
/** Compute a target square by offsetting file and rank.
|
/** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board
|
||||||
* Returns None if the resulting square is outside the board (0-7 range). */
|
* (0-7 range).
|
||||||
|
*/
|
||||||
extension (sq: Square)
|
extension (sq: Square)
|
||||||
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||||
val newFileOrd = sq.file.ordinal + fileDelta
|
val newFileOrd = sq.file.ordinal + fileDelta
|
||||||
val newRankOrd = sq.rank.ordinal + rankDelta
|
val newRankOrd = sq.rank.ordinal + rankDelta
|
||||||
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
|
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
|
||||||
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
|
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
|
||||||
else None
|
else None
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
package de.nowchess.api.game
|
package de.nowchess.api.game
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, Square}
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
/** Immutable bundle of complete game state.
|
/** Immutable bundle of complete game state. All state changes produce new GameContext instances.
|
||||||
* All state changes produce new GameContext instances.
|
*/
|
||||||
*/
|
|
||||||
case class GameContext(
|
case class GameContext(
|
||||||
board: Board,
|
board: Board,
|
||||||
turn: Color,
|
turn: Color,
|
||||||
castlingRights: CastlingRights,
|
castlingRights: CastlingRights,
|
||||||
enPassantSquare: Option[Square],
|
enPassantSquare: Option[Square],
|
||||||
halfMoveClock: Int,
|
halfMoveClock: Int,
|
||||||
moves: List[Move]
|
moves: List[Move],
|
||||||
):
|
):
|
||||||
/** Create new context with updated board. */
|
/** Create new context with updated board. */
|
||||||
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||||
@@ -40,5 +39,5 @@ object GameContext:
|
|||||||
castlingRights = CastlingRights.Initial,
|
castlingRights = CastlingRights.Initial,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,24 +10,30 @@ enum PromotionPiece:
|
|||||||
enum MoveType:
|
enum MoveType:
|
||||||
/** A normal move or capture with no special rule. */
|
/** A normal move or capture with no special rule. */
|
||||||
case Normal(isCapture: Boolean = false)
|
case Normal(isCapture: Boolean = false)
|
||||||
|
|
||||||
/** Kingside castling (O-O). */
|
/** Kingside castling (O-O). */
|
||||||
case CastleKingside
|
case CastleKingside
|
||||||
|
|
||||||
/** Queenside castling (O-O-O). */
|
/** Queenside castling (O-O-O). */
|
||||||
case CastleQueenside
|
case CastleQueenside
|
||||||
|
|
||||||
/** En-passant pawn capture. */
|
/** En-passant pawn capture. */
|
||||||
case EnPassant
|
case EnPassant
|
||||||
|
|
||||||
/** Pawn promotion; carries the chosen promotion piece. */
|
/** Pawn promotion; carries the chosen promotion piece. */
|
||||||
case Promotion(piece: PromotionPiece)
|
case Promotion(piece: PromotionPiece)
|
||||||
|
|
||||||
/**
|
/** A half-move (ply) in a chess game.
|
||||||
* A half-move (ply) in a chess game.
|
*
|
||||||
*
|
* @param from
|
||||||
* @param from origin square
|
* origin square
|
||||||
* @param to destination square
|
* @param to
|
||||||
* @param moveType special semantics; defaults to Normal
|
* destination square
|
||||||
*/
|
* @param moveType
|
||||||
|
* special semantics; defaults to Normal
|
||||||
|
*/
|
||||||
final case class Move(
|
final case class Move(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveType: MoveType = MoveType.Normal()
|
moveType: MoveType = MoveType.Normal(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
package de.nowchess.api.player
|
package de.nowchess.api.player
|
||||||
|
|
||||||
/**
|
/** An opaque player identifier.
|
||||||
* An opaque player identifier.
|
*
|
||||||
*
|
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
|
||||||
* Wraps a plain String so that IDs are not accidentally interchanged with
|
*/
|
||||||
* other String values at compile time.
|
|
||||||
*/
|
|
||||||
opaque type PlayerId = String
|
opaque type PlayerId = String
|
||||||
|
|
||||||
object PlayerId:
|
object PlayerId:
|
||||||
def apply(value: String): PlayerId = value
|
def apply(value: String): PlayerId = value
|
||||||
extension (id: PlayerId) def value: String = id
|
extension (id: PlayerId) def value: String = id
|
||||||
|
|
||||||
/**
|
/** The minimal cross-service identity stub for a player.
|
||||||
* The minimal cross-service identity stub for a player.
|
*
|
||||||
*
|
* Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs
|
||||||
* Full profile data (email, rating history, etc.) lives in the user-management
|
* is held here.
|
||||||
* service. Only what every service needs is held here.
|
*
|
||||||
*
|
* @param id
|
||||||
* @param id unique identifier
|
* unique identifier
|
||||||
* @param displayName human-readable name shown in the UI
|
* @param displayName
|
||||||
*/
|
* human-readable name shown in the UI
|
||||||
|
*/
|
||||||
final case class PlayerInfo(
|
final case class PlayerInfo(
|
||||||
id: PlayerId,
|
id: PlayerId,
|
||||||
displayName: String
|
displayName: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package de.nowchess.api.response
|
package de.nowchess.api.response
|
||||||
|
|
||||||
/**
|
/** A standardised envelope for every API response.
|
||||||
* A standardised envelope for every API response.
|
*
|
||||||
*
|
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
|
||||||
* Success and failure are modelled as subtypes so that callers
|
*
|
||||||
* can pattern-match exhaustively.
|
* @tparam A
|
||||||
*
|
* the payload type for a successful response
|
||||||
* @tparam A the payload type for a successful response
|
*/
|
||||||
*/
|
|
||||||
sealed trait ApiResponse[+A]
|
sealed trait ApiResponse[+A]
|
||||||
|
|
||||||
object ApiResponse:
|
object ApiResponse:
|
||||||
@@ -20,43 +19,49 @@ object ApiResponse:
|
|||||||
/** Convenience constructor for a single-error failure. */
|
/** Convenience constructor for a single-error failure. */
|
||||||
def error(err: ApiError): Failure = Failure(List(err))
|
def error(err: ApiError): Failure = Failure(List(err))
|
||||||
|
|
||||||
/**
|
/** A structured error descriptor.
|
||||||
* A structured error descriptor.
|
*
|
||||||
*
|
* @param code
|
||||||
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||||
* @param message human-readable explanation
|
* @param message
|
||||||
* @param field optional field name when the error relates to a specific input
|
* human-readable explanation
|
||||||
*/
|
* @param field
|
||||||
|
* optional field name when the error relates to a specific input
|
||||||
|
*/
|
||||||
final case class ApiError(
|
final case class ApiError(
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
field: Option[String] = None
|
field: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Pagination metadata for list responses.
|
||||||
* Pagination metadata for list responses.
|
*
|
||||||
*
|
* @param page
|
||||||
* @param page current 0-based page index
|
* current 0-based page index
|
||||||
* @param pageSize number of items per page
|
* @param pageSize
|
||||||
* @param totalItems total number of items across all pages
|
* number of items per page
|
||||||
*/
|
* @param totalItems
|
||||||
|
* total number of items across all pages
|
||||||
|
*/
|
||||||
final case class Pagination(
|
final case class Pagination(
|
||||||
page: Int,
|
page: Int,
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
totalItems: Long
|
totalItems: Long,
|
||||||
):
|
):
|
||||||
def totalPages: Int =
|
def totalPages: Int =
|
||||||
if pageSize <= 0 then 0
|
if pageSize <= 0 then 0
|
||||||
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||||
|
|
||||||
/**
|
/** A paginated list response envelope.
|
||||||
* A paginated list response envelope.
|
*
|
||||||
*
|
* @param items
|
||||||
* @param items the items on the current page
|
* the items on the current page
|
||||||
* @param pagination pagination metadata
|
* @param pagination
|
||||||
* @tparam A the item type
|
* pagination metadata
|
||||||
*/
|
* @tparam A
|
||||||
|
* the item type
|
||||||
|
*/
|
||||||
final case class PagedResponse[A](
|
final case class PagedResponse[A](
|
||||||
items: List[A],
|
items: List[A],
|
||||||
pagination: Pagination
|
pagination: Pagination,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("withMove returns captured piece when destination is occupied") {
|
test("withMove returns captured piece when destination is occupied") {
|
||||||
val from = Square(File.A, Rank.R1)
|
val from = Square(File.A, Rank.R1)
|
||||||
val to = Square(File.A, Rank.R8)
|
val to = Square(File.A, Rank.R8)
|
||||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||||
val (board, captured) = b.withMove(from, to)
|
val (board, captured) = b.withMove(from, to)
|
||||||
captured shouldBe Some(Piece.BlackRook)
|
captured shouldBe Some(Piece.BlackRook)
|
||||||
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
|
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
|
||||||
@@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("initial board white back rank") {
|
test("initial board white back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||||
@@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("initial board black back rank") {
|
test("initial board black back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||||
@@ -76,12 +88,11 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
for
|
for
|
||||||
rank <- emptyRanks
|
rank <- emptyRanks
|
||||||
file <- File.values
|
file <- File.values
|
||||||
do
|
do Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("updated adds and replaces piece at squares") {
|
test("updated adds and replaces piece at squares") {
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
val added = b.updated(e4, Piece.WhiteKnight)
|
val added = b.updated(e4, Piece.WhiteKnight)
|
||||||
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||||
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
@@ -91,7 +102,7 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("removed deletes piece from board") {
|
test("removed deletes piece from board") {
|
||||||
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
|
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
|
||||||
val removed = b.removed(e2)
|
val removed = b.removed(e2)
|
||||||
removed.pieceAt(e2) shouldBe None
|
removed.pieceAt(e2) shouldBe None
|
||||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
@@ -105,4 +116,3 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||||
moved.pieceAt(e2) shouldBe None
|
moved.pieceAt(e2) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
rights.hasAnyRights shouldBe true
|
rights.hasAnyRights shouldBe true
|
||||||
@@ -54,4 +54,3 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
|||||||
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||||
blackQueenSideRevoked.blackKingSide shouldBe true
|
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||||
blackQueenSideRevoked.blackQueenSide shouldBe false
|
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
|
|||||||
test("Color values expose opposite and label consistently"):
|
test("Color values expose opposite and label consistently"):
|
||||||
val cases = List(
|
val cases = List(
|
||||||
(Color.White, Color.Black, "White"),
|
(Color.White, Color.Black, "White"),
|
||||||
(Color.Black, Color.White, "Black")
|
(Color.Black, Color.White, "Black"),
|
||||||
)
|
)
|
||||||
|
|
||||||
cases.foreach { (color, opposite, label) =>
|
cases.foreach { (color, opposite, label) =>
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ class PieceTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("Piece holds color and pieceType") {
|
test("Piece holds color and pieceType") {
|
||||||
val p = Piece(Color.White, PieceType.Queen)
|
val p = Piece(Color.White, PieceType.Queen)
|
||||||
p.color shouldBe Color.White
|
p.color shouldBe Color.White
|
||||||
p.pieceType shouldBe PieceType.Queen
|
p.pieceType shouldBe PieceType.Queen
|
||||||
}
|
}
|
||||||
|
|
||||||
test("all convenience constants map to expected color and piece type") {
|
test("all convenience constants map to expected color and piece type") {
|
||||||
val expected = List(
|
val expected = List(
|
||||||
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
|
||||||
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
|
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
|
||||||
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
||||||
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||||
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||||
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||||
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||||
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
||||||
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
|
Piece.BlackKing -> Piece(Color.Black, PieceType.King),
|
||||||
)
|
)
|
||||||
|
|
||||||
expected.foreach { case (actual, wanted) =>
|
expected.foreach { case (actual, wanted) =>
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("PieceType values expose the expected labels"):
|
test("PieceType values expose the expected labels"):
|
||||||
val expectedLabels = List(
|
val expectedLabels = List(
|
||||||
PieceType.Pawn -> "Pawn",
|
PieceType.Pawn -> "Pawn",
|
||||||
PieceType.Knight -> "Knight",
|
PieceType.Knight -> "Knight",
|
||||||
PieceType.Bishop -> "Bishop",
|
PieceType.Bishop -> "Bishop",
|
||||||
PieceType.Rook -> "Rook",
|
PieceType.Rook -> "Rook",
|
||||||
PieceType.Queen -> "Queen",
|
PieceType.Queen -> "Queen",
|
||||||
PieceType.King -> "King"
|
PieceType.King -> "King",
|
||||||
)
|
)
|
||||||
|
|
||||||
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
|
|||||||
"a1" -> Square(File.A, Rank.R1),
|
"a1" -> Square(File.A, Rank.R1),
|
||||||
"e4" -> Square(File.E, Rank.R4),
|
"e4" -> Square(File.E, Rank.R4),
|
||||||
"h8" -> Square(File.H, Rank.R8),
|
"h8" -> Square(File.H, Rank.R8),
|
||||||
"E4" -> Square(File.E, Rank.R4)
|
"E4" -> Square(File.E, Rank.R4),
|
||||||
)
|
)
|
||||||
expected.foreach { case (raw, sq) =>
|
expected.foreach { case (raw, sq) =>
|
||||||
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||||
@@ -34,4 +34,3 @@ class SquareTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||||
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
|||||||
initial.moves shouldBe List.empty
|
initial.moves shouldBe List.empty
|
||||||
|
|
||||||
test("withBoard updates only board"):
|
test("withBoard updates only board"):
|
||||||
val square = Square(File.E, Rank.R4)
|
val square = Square(File.E, Rank.R4)
|
||||||
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
|
||||||
val updated = GameContext.initial.withBoard(updatedBoard)
|
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||||
updated.board shouldBe updatedBoard
|
updated.board shouldBe updatedBoard
|
||||||
updated.turn shouldBe GameContext.initial.turn
|
updated.turn shouldBe GameContext.initial.turn
|
||||||
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||||
@@ -34,13 +34,13 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
val square = Some(Square(File.E, Rank.R3))
|
val square = Some(Square(File.E, Rank.R3))
|
||||||
val updatedTurn = initial.withTurn(Color.Black)
|
val updatedTurn = initial.withTurn(Color.Black)
|
||||||
val updatedRights = initial.withCastlingRights(rights)
|
val updatedRights = initial.withCastlingRights(rights)
|
||||||
val updatedEp = initial.withEnPassantSquare(square)
|
val updatedEp = initial.withEnPassantSquare(square)
|
||||||
val updatedClock = initial.withHalfMoveClock(17)
|
val updatedClock = initial.withHalfMoveClock(17)
|
||||||
|
|
||||||
updatedTurn.turn shouldBe Color.Black
|
updatedTurn.turn shouldBe Color.Black
|
||||||
updatedTurn.board shouldBe initial.board
|
updatedTurn.board shouldBe initial.board
|
||||||
@@ -57,4 +57,3 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
|||||||
test("withMove appends move to history"):
|
test("withMove appends move to history"):
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
GameContext.initial.withMove(move).moves shouldBe List(move)
|
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class MoveTest extends AnyFunSuite with Matchers:
|
|||||||
MoveType.Promotion(PromotionPiece.Queen),
|
MoveType.Promotion(PromotionPiece.Queen),
|
||||||
MoveType.Promotion(PromotionPiece.Rook),
|
MoveType.Promotion(PromotionPiece.Rook),
|
||||||
MoveType.Promotion(PromotionPiece.Bishop),
|
MoveType.Promotion(PromotionPiece.Bishop),
|
||||||
MoveType.Promotion(PromotionPiece.Knight)
|
MoveType.Promotion(PromotionPiece.Knight),
|
||||||
)
|
)
|
||||||
|
|
||||||
moveTypes.foreach { moveType =>
|
moveTypes.foreach { moveType =>
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ class PlayerInfoTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("PlayerId and PlayerInfo preserve constructor values") {
|
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||||
val raw = "player-123"
|
val raw = "player-123"
|
||||||
val id = PlayerId(raw)
|
val id = PlayerId(raw)
|
||||||
|
|
||||||
id.value shouldBe raw
|
id.value shouldBe raw
|
||||||
|
|
||||||
val playerId = PlayerId("p1")
|
val playerId = PlayerId("p1")
|
||||||
val info = PlayerInfo(playerId, "Magnus")
|
val info = PlayerInfo(playerId, "Magnus")
|
||||||
info.id.value shouldBe "p1"
|
info.id.value shouldBe "p1"
|
||||||
info.displayName shouldBe "Magnus"
|
info.displayName shouldBe "Magnus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
|
|||||||
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||||
|
|
||||||
val e = ApiError("CODE", "message")
|
val e = ApiError("CODE", "message")
|
||||||
e.code shouldBe "CODE"
|
e.code shouldBe "CODE"
|
||||||
e.message shouldBe "message"
|
e.message shouldBe "message"
|
||||||
e.field shouldBe None
|
e.field shouldBe None
|
||||||
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,6 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
|
|||||||
test("PagedResponse holds items and pagination") {
|
test("PagedResponse holds items and pagination") {
|
||||||
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
|
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
|
||||||
val pr = PagedResponse(List("a", "b"), pagination)
|
val pr = PagedResponse(List("a", "b"), pagination)
|
||||||
pr.items shouldBe List("a", "b")
|
pr.items shouldBe List("a", "b")
|
||||||
pr.pagination shouldBe pagination
|
pr.pagination shouldBe pagination
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, Piece}
|
import de.nowchess.api.board.{Piece, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Marker trait for all commands that can be executed and undone.
|
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* transitions.
|
||||||
*/
|
*/
|
||||||
trait Command:
|
trait Command:
|
||||||
/** Execute the command and return true if successful, false otherwise. */
|
/** Execute the command and return true if successful, false otherwise. */
|
||||||
def execute(): Boolean
|
def execute(): Boolean
|
||||||
@@ -16,15 +16,14 @@ trait Command:
|
|||||||
/** A human-readable description of this command. */
|
/** A human-readable description of this command. */
|
||||||
def description: String
|
def description: String
|
||||||
|
|
||||||
/** Command to move a piece from one square to another.
|
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||||
* Stores the move result so undo can restore previous state.
|
*/
|
||||||
*/
|
|
||||||
case class MoveCommand(
|
case class MoveCommand(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
moveResult: Option[MoveResult] = None,
|
||||||
previousContext: Option[GameContext] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
notation: String = ""
|
notation: String = "",
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
@@ -39,18 +38,18 @@ case class MoveCommand(
|
|||||||
sealed trait MoveResult
|
sealed trait MoveResult
|
||||||
object MoveResult:
|
object MoveResult:
|
||||||
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||||
case object InvalidFormat extends MoveResult
|
case object InvalidFormat extends MoveResult
|
||||||
case object InvalidMove extends MoveResult
|
case object InvalidMove extends MoveResult
|
||||||
|
|
||||||
/** Command to quit the game. */
|
/** Command to quit the game. */
|
||||||
case class QuitCommand() extends Command:
|
case class QuitCommand() extends Command:
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Quit game"
|
override def description: String = "Quit game"
|
||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousContext: Option[GameContext] = None
|
previousContext: Option[GameContext] = None,
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
|
|||||||
/** Manages command execution and history for undo/redo support. */
|
/** Manages command execution and history for undo/redo support. */
|
||||||
class CommandInvoker:
|
class CommandInvoker:
|
||||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentIndex = -1
|
private var currentIndex = -1
|
||||||
|
|
||||||
/** Execute a command and add it to history.
|
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||||
* Discards any redo history if not at the end of the stack.
|
*/
|
||||||
*/
|
|
||||||
def execute(command: Command): Boolean = synchronized {
|
def execute(command: Command): Boolean = synchronized {
|
||||||
if command.execute() then
|
if command.execute() then
|
||||||
// Remove any commands after current index (redo stack is discarded)
|
// Remove any commands after current index (redo stack is discarded)
|
||||||
while currentIndex < executedCommands.size - 1 do
|
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||||
executedCommands.remove(executedCommands.size - 1)
|
|
||||||
executedCommands += command
|
executedCommands += command
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last executed command if possible. */
|
/** Undo the last executed command if possible. */
|
||||||
@@ -27,10 +25,8 @@ class CommandInvoker:
|
|||||||
if command.undo() then
|
if command.undo() then
|
||||||
currentIndex -= 1
|
currentIndex -= 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redo the next command in history if available. */
|
/** Redo the next command in history if available. */
|
||||||
@@ -40,10 +36,8 @@ class CommandInvoker:
|
|||||||
if command.execute() then
|
if command.execute() then
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the history of all executed commands. */
|
/** Get the history of all executed commands. */
|
||||||
|
|||||||
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
|
|||||||
|
|
||||||
object Parser:
|
object Parser:
|
||||||
|
|
||||||
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||||
* Returns None for any input that does not match the expected format.
|
* format.
|
||||||
*/
|
*/
|
||||||
def parseMove(input: String): Option[(Square, Square)] =
|
def parseMove(input: String): Option[(Square, Square)] =
|
||||||
val trimmed = input.trim.toLowerCase
|
val trimmed = input.trim.toLowerCase
|
||||||
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
Option
|
||||||
for
|
.when(trimmed.length == 4)(trimmed)
|
||||||
from <- parseSquare(s.substring(0, 2))
|
.flatMap: s =>
|
||||||
to <- parseSquare(s.substring(2, 4))
|
for
|
||||||
yield (from, to)
|
from <- parseSquare(s.substring(0, 2))
|
||||||
|
to <- parseSquare(s.substring(2, 4))
|
||||||
|
yield (from, to)
|
||||||
|
|
||||||
private def parseSquare(s: String): Option[Square] =
|
private def parseSquare(s: String): Option[Square] =
|
||||||
Option.when(s.length == 2)(s).flatMap: sq =>
|
Option
|
||||||
val fileIdx = sq(0) - 'a'
|
.when(s.length == 2)(s)
|
||||||
val rankIdx = sq(1) - '1'
|
.flatMap: sq =>
|
||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
val fileIdx = sq(0) - 'a'
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
val rankIdx = sq(1) - '1'
|
||||||
)
|
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||||
|
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,45 +6,46 @@ import de.nowchess.api.game.GameContext
|
|||||||
import de.nowchess.chess.controller.Parser
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
import de.nowchess.io.{GameContextImport, GameContextExport}
|
import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||||
import de.nowchess.rules.RuleSet
|
import de.nowchess.rules.RuleSet
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||||
* All rule queries delegate to the injected RuleSet.
|
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||||
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
*/
|
||||||
*/
|
|
||||||
class GameEngine(
|
class GameEngine(
|
||||||
val initialContext: GameContext = GameContext.initial,
|
val initialContext: GameContext = GameContext.initial,
|
||||||
val ruleSet: RuleSet = DefaultRules
|
val ruleSet: RuleSet = DefaultRules,
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentContext: GameContext = initialContext
|
private var currentContext: GameContext = initialContext
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
/** 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 case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var pendingPromotion: Option[PendingPromotion] = None
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentContext.board }
|
def board: Board = synchronized(currentContext.board)
|
||||||
def turn: Color = synchronized { currentContext.turn }
|
def turn: Color = synchronized(currentContext.turn)
|
||||||
def context: GameContext = synchronized { currentContext }
|
def context: GameContext = synchronized(currentContext)
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||||
|
|
||||||
/** Check if redo is available. */
|
/** Check if redo is available. */
|
||||||
def canRedo: Boolean = synchronized { invoker.canRedo }
|
def canRedo: Boolean = synchronized(invoker.canRedo)
|
||||||
|
|
||||||
/** Get the command history for inspection (testing/debugging). */
|
/** Get the command history for inspection (testing/debugging). */
|
||||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
|
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
|
||||||
|
|
||||||
/** Process a raw move input string and update game state if valid.
|
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||||
* Notifies all observers of the outcome via GameEvent.
|
* GameEvent.
|
||||||
*/
|
*/
|
||||||
def processUserInput(rawInput: String): Unit = synchronized {
|
def processUserInput(rawInput: String): Unit = synchronized {
|
||||||
val trimmed = rawInput.trim.toLowerCase
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
trimmed match
|
trimmed match
|
||||||
@@ -62,10 +63,12 @@ class GameEngine(
|
|||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawClaimedEvent(currentContext))
|
notifyObservers(DrawClaimedEvent(currentContext))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
currentContext,
|
InvalidMoveEvent(
|
||||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
currentContext,
|
||||||
))
|
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
case "" =>
|
case "" =>
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||||
@@ -73,10 +76,12 @@ class GameEngine(
|
|||||||
case moveInput =>
|
case moveInput =>
|
||||||
Parser.parseMove(moveInput) match
|
Parser.parseMove(moveInput) match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
currentContext,
|
InvalidMoveEvent(
|
||||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
currentContext,
|
||||||
))
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||||
|
),
|
||||||
|
)
|
||||||
case Some((from, to)) =>
|
case Some((from, to)) =>
|
||||||
handleParsedMove(from, to)
|
handleParsedMove(from, to)
|
||||||
}
|
}
|
||||||
@@ -108,9 +113,8 @@ class GameEngine(
|
|||||||
to.rank.ordinal == promoRank
|
to.rank.ordinal == promoRank
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a player's promotion piece choice.
|
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||||
* Must only be called when isPendingPromotion is true.
|
*/
|
||||||
*/
|
|
||||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||||
pendingPromotion match
|
pendingPromotion match
|
||||||
case None =>
|
case None =>
|
||||||
@@ -120,23 +124,19 @@ class GameEngine(
|
|||||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||||
// Verify it's actually legal
|
// Verify it's actually legal
|
||||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||||
if legal.contains(move) then
|
if legal.contains(move) then executeMove(move)
|
||||||
executeMove(move)
|
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||||
else
|
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last move. */
|
/** Undo the last move. */
|
||||||
def undo(): Unit = synchronized { performUndo() }
|
def undo(): Unit = synchronized(performUndo())
|
||||||
|
|
||||||
/** Redo the last undone move. */
|
/** Redo the last undone move. */
|
||||||
def redo(): Unit = synchronized { performRedo() }
|
def redo(): Unit = synchronized(performRedo())
|
||||||
|
|
||||||
/** Load a game using the provided importer.
|
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||||
* If the imported context has moves, they are replayed through the command system.
|
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
|
||||||
* Otherwise, the position is set directly.
|
*/
|
||||||
* Notifies observers with PgnLoadedEvent on success.
|
|
||||||
*/
|
|
||||||
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||||
importer.importGameContext(input) match
|
importer.importGameContext(input) match
|
||||||
case Left(err) => Left(err)
|
case Left(err) => Left(err)
|
||||||
@@ -155,29 +155,24 @@ class GameEngine(
|
|||||||
if ctx.moves.isEmpty then
|
if ctx.moves.isEmpty then
|
||||||
currentContext = ctx
|
currentContext = ctx
|
||||||
Right(())
|
Right(())
|
||||||
else
|
else replayMoves(ctx.moves, savedContext)
|
||||||
replayMoves(ctx.moves, savedContext)
|
|
||||||
|
|
||||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||||
var error: Option[String] = None
|
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||||
moves.foreach: move =>
|
acc.flatMap(_ => applyReplayMove(move))
|
||||||
if error.isEmpty then
|
}
|
||||||
handleParsedMove(move.from, move.to)
|
result.left.foreach(_ => currentContext = savedContext)
|
||||||
|
result
|
||||||
|
|
||||||
move.moveType match {
|
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||||
case MoveType.Promotion(pp) =>
|
handleParsedMove(move.from, move.to)
|
||||||
if pendingPromotion.isDefined then
|
move.moveType match
|
||||||
completePromotion(pp)
|
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
|
||||||
else
|
completePromotion(pp)
|
||||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
|
||||||
case _ => ()
|
|
||||||
}
|
|
||||||
error match
|
|
||||||
case Some(err) =>
|
|
||||||
currentContext = savedContext
|
|
||||||
Left(err)
|
|
||||||
case None =>
|
|
||||||
Right(())
|
Right(())
|
||||||
|
case MoveType.Promotion(_) =>
|
||||||
|
Left(s"Promotion required for move ${move.from}${move.to}")
|
||||||
|
case _ => Right(())
|
||||||
|
|
||||||
/** Export the current game context using the provided exporter. */
|
/** Export the current game context using the provided exporter. */
|
||||||
def exportGame(exporter: GameContextExport): String = synchronized {
|
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||||
@@ -203,25 +198,27 @@ class GameEngine(
|
|||||||
|
|
||||||
private def executeMove(move: Move): Unit =
|
private def executeMove(move: Move): Unit =
|
||||||
val contextBefore = currentContext
|
val contextBefore = currentContext
|
||||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||||
val captured = computeCaptured(currentContext, move)
|
val captured = computeCaptured(currentContext, move)
|
||||||
|
|
||||||
val cmd = MoveCommand(
|
val cmd = MoveCommand(
|
||||||
from = move.from,
|
from = move.from,
|
||||||
to = move.to,
|
to = move.to,
|
||||||
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||||
previousContext = Some(contextBefore),
|
previousContext = Some(contextBefore),
|
||||||
notation = translateMoveToNotation(move, contextBefore.board)
|
notation = translateMoveToNotation(move, contextBefore.board),
|
||||||
)
|
)
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
currentContext = nextContext
|
currentContext = nextContext
|
||||||
|
|
||||||
notifyObservers(MoveExecutedEvent(
|
notifyObservers(
|
||||||
currentContext,
|
MoveExecutedEvent(
|
||||||
move.from.toString,
|
currentContext,
|
||||||
move.to.toString,
|
move.from.toString,
|
||||||
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
move.to.toString,
|
||||||
))
|
captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if ruleSet.isCheckmate(currentContext) then
|
if ruleSet.isCheckmate(currentContext) then
|
||||||
val winner = currentContext.turn.opposite
|
val winner = currentContext.turn.opposite
|
||||||
@@ -232,18 +229,16 @@ class GameEngine(
|
|||||||
notifyObservers(StalemateEvent(currentContext))
|
notifyObservers(StalemateEvent(currentContext))
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
currentContext = GameContext.initial
|
currentContext = GameContext.initial
|
||||||
else if ruleSet.isCheck(currentContext) then
|
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
notifyObservers(CheckDetectedEvent(currentContext))
|
|
||||||
|
|
||||||
if currentContext.halfMoveClock >= 100 then
|
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
|
||||||
|
|
||||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||||
move.moveType match
|
move.moveType match
|
||||||
case MoveType.CastleKingside => "O-O"
|
case MoveType.CastleKingside => "O-O"
|
||||||
case MoveType.CastleQueenside => "O-O-O"
|
case MoveType.CastleQueenside => "O-O-O"
|
||||||
case MoveType.EnPassant => enPassantNotation(move)
|
case MoveType.EnPassant => enPassantNotation(move)
|
||||||
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||||
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
||||||
|
|
||||||
private def enPassantNotation(move: Move): String =
|
private def enPassantNotation(move: Move): String =
|
||||||
@@ -295,8 +290,7 @@ class GameEngine(
|
|||||||
moveCmd.previousContext.foreach(currentContext = _)
|
moveCmd.previousContext.foreach(currentContext = _)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||||
else
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then
|
||||||
@@ -307,12 +301,13 @@ class GameEngine(
|
|||||||
currentContext = nextCtx
|
currentContext = nextCtx
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
notifyObservers(MoveRedoneEvent(
|
notifyObservers(
|
||||||
currentContext,
|
MoveRedoneEvent(
|
||||||
moveCmd.notation,
|
currentContext,
|
||||||
moveCmd.from.toString,
|
moveCmd.notation,
|
||||||
moveCmd.to.toString,
|
moveCmd.from.toString,
|
||||||
capturedDesc
|
moveCmd.to.toString,
|
||||||
))
|
capturedDesc,
|
||||||
else
|
),
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
)
|
||||||
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||||
|
|||||||
@@ -3,82 +3,81 @@ package de.nowchess.chess.observer
|
|||||||
import de.nowchess.api.board.{Color, Square}
|
import de.nowchess.api.board.{Color, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||||
* Events are immutable snapshots of game state changes.
|
*/
|
||||||
*/
|
|
||||||
sealed trait GameEvent:
|
sealed trait GameEvent:
|
||||||
def context: GameContext
|
def context: GameContext
|
||||||
|
|
||||||
/** Fired when a move is successfully executed. */
|
/** Fired when a move is successfully executed. */
|
||||||
case class MoveExecutedEvent(
|
case class MoveExecutedEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the current player is in check. */
|
/** Fired when the current player is in check. */
|
||||||
case class CheckDetectedEvent(
|
case class CheckDetectedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches checkmate. */
|
/** Fired when the game reaches checkmate. */
|
||||||
case class CheckmateEvent(
|
case class CheckmateEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
winner: Color
|
winner: Color,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches stalemate. */
|
/** Fired when the game reaches stalemate. */
|
||||||
case class StalemateEvent(
|
case class StalemateEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is invalid. */
|
/** Fired when a move is invalid. */
|
||||||
case class InvalidMoveEvent(
|
case class InvalidMoveEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
reason: String
|
reason: String,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||||
case class PromotionRequiredEvent(
|
case class PromotionRequiredEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square
|
to: Square,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the board is reset. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||||
case class FiftyMoveRuleAvailableEvent(
|
case class FiftyMoveRuleAvailableEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||||
case class DrawClaimedEvent(
|
case class DrawClaimedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||||
case class MoveUndoneEvent(
|
case class MoveUndoneEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
pgnNotation: String
|
pgnNotation: String,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||||
case class MoveRedoneEvent(
|
case class MoveRedoneEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
pgnNotation: String,
|
pgnNotation: String,
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||||
case class PgnLoadedEvent(
|
case class PgnLoadedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Observer trait: implement to receive game state updates. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
|
|||||||
+26
-23
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -10,13 +10,16 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||||
|
|
||||||
private case class FailingCommand() extends Command:
|
private case class FailingCommand() extends Command:
|
||||||
override def execute(): Boolean = false
|
override def execute(): Boolean = false
|
||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Failing command"
|
override def description: String = "Failing command"
|
||||||
|
|
||||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
private case class ConditionalFailCommand(
|
||||||
override def execute(): Boolean = !shouldFailOnExecute
|
var shouldFailOnUndo: Boolean = false,
|
||||||
override def undo(): Boolean = !shouldFailOnUndo
|
var shouldFailOnExecute: Boolean = false,
|
||||||
|
) extends Command:
|
||||||
|
override def execute(): Boolean = !shouldFailOnExecute
|
||||||
|
override def undo(): Boolean = !shouldFailOnUndo
|
||||||
override def description: String = "Conditional fail"
|
override def description: String = "Conditional fail"
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||||
@@ -24,12 +27,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("execute rejects failing commands and keeps history unchanged"):
|
test("execute rejects failing commands and keeps history unchanged"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = FailingCommand()
|
val cmd = FailingCommand()
|
||||||
invoker.execute(cmd) shouldBe false
|
invoker.execute(cmd) shouldBe false
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
invoker.getCurrentIndex shouldBe -1
|
invoker.getCurrentIndex shouldBe -1
|
||||||
@@ -52,8 +55,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
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 cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
@@ -62,7 +65,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||||
invoker.execute(failingUndoCmd) shouldBe true
|
invoker.execute(failingUndoCmd) shouldBe true
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
@@ -71,7 +74,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val successUndoCmd = ConditionalFailCommand()
|
val successUndoCmd = ConditionalFailCommand()
|
||||||
invoker.execute(successUndoCmd) shouldBe true
|
invoker.execute(successUndoCmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
@@ -85,15 +88,15 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
invoker.canRedo shouldBe false
|
invoker.canRedo shouldBe false
|
||||||
invoker.redo() shouldBe false
|
invoker.redo() shouldBe false
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
val redoFailCmd = ConditionalFailCommand()
|
val redoFailCmd = ConditionalFailCommand()
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(redoFailCmd)
|
invoker.execute(redoFailCmd)
|
||||||
@@ -106,7 +109,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.undo() shouldBe true
|
invoker.undo() shouldBe true
|
||||||
invoker.redo() shouldBe true
|
invoker.redo() shouldBe true
|
||||||
@@ -115,9 +118,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
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 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))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
@@ -130,10 +133,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
{
|
{
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
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 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 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))
|
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.execute(cmd3)
|
invoker.execute(cmd3)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("execute appends commands and updates index"):
|
test("execute appends commands and updates index"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd) shouldBe true
|
invoker.execute(cmd) shouldBe true
|
||||||
invoker.history.size shouldBe 1
|
invoker.history.size shouldBe 1
|
||||||
invoker.getCurrentIndex shouldBe 0
|
invoker.getCurrentIndex shouldBe 0
|
||||||
@@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("undo and redo update index and availability flags"):
|
test("undo and redo update index and availability flags"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.canUndo shouldBe false
|
invoker.canUndo shouldBe false
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
invoker.canUndo shouldBe true
|
invoker.canUndo shouldBe true
|
||||||
@@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("clear removes full history and resets index"):
|
test("clear removes full history and resets index"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
invoker.history.size shouldBe 0
|
invoker.history.size shouldBe 0
|
||||||
@@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("execute after undo discards redo history"):
|
test("execute after undo discards redo history"):
|
||||||
val invoker = new CommandInvoker()
|
val invoker = new CommandInvoker()
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
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 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))
|
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||||
invoker.execute(cmd1)
|
invoker.execute(cmd1)
|
||||||
invoker.execute(cmd2)
|
invoker.execute(cmd2)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
val executable = MoveCommand(
|
val executable = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
)
|
)
|
||||||
executable.execute() shouldBe true
|
executable.execute() shouldBe true
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
undoable.undo() shouldBe true
|
undoable.undo() shouldBe true
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
val result = MoveResult.Successful(GameContext.initial, None)
|
val result = MoveResult.Successful(GameContext.initial, None)
|
||||||
val cmd2 = cmd1.copy(
|
val cmd2 = cmd1.copy(
|
||||||
moveResult = Some(result),
|
moveResult = Some(result),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd1.moveResult shouldBe None
|
cmd1.moveResult shouldBe None
|
||||||
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousContext = None
|
previousContext = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
val eq2 = MoveCommand(
|
val eq2 = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousContext = None
|
previousContext = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
eq1 shouldBe eq2
|
eq1 shouldBe eq2
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ object EngineTestHelpers:
|
|||||||
private val _events = mutable.ListBuffer[GameEvent]()
|
private val _events = mutable.ListBuffer[GameEvent]()
|
||||||
|
|
||||||
def events: mutable.ListBuffer[GameEvent] = _events
|
def events: mutable.ListBuffer[GameEvent] = _events
|
||||||
def eventCount: Int = _events.length
|
def eventCount: Int = _events.length
|
||||||
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
|
||||||
_events.exists(ct.runtimeClass.isInstance(_))
|
_events.exists(ct.runtimeClass.isInstance(_))
|
||||||
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||||
|
|||||||
+38
-29
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
|
|||||||
|
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color}
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -10,82 +10,91 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("GameEngine handles Checkmate (Fool's Mate)"):
|
test("GameEngine handles Checkmate (Fool's Mate)"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val observer = new EndingMockObserver()
|
val observer = new EndingMockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
// Play Fool's mate
|
// Play Fool's mate
|
||||||
engine.processUserInput("f2f3")
|
engine.processUserInput("f2f3")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.processUserInput("g2g4")
|
engine.processUserInput("g2g4")
|
||||||
|
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
engine.processUserInput("d8h4")
|
engine.processUserInput("d8h4")
|
||||||
|
|
||||||
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
|
||||||
observer.events.last shouldBe a[CheckmateEvent]
|
observer.events.last shouldBe a[CheckmateEvent]
|
||||||
|
|
||||||
val event = observer.events.last.asInstanceOf[CheckmateEvent]
|
val event = observer.events.last.asInstanceOf[CheckmateEvent]
|
||||||
event.winner shouldBe Color.Black
|
event.winner shouldBe Color.Black
|
||||||
|
|
||||||
// Board should be reset after checkmate
|
// Board should be reset after checkmate
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
test("GameEngine handles check detection"):
|
test("GameEngine handles check detection"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val observer = new EndingMockObserver()
|
val observer = new EndingMockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
// Play a simple check
|
// Play a simple check
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e7e5")
|
engine.processUserInput("e7e5")
|
||||||
engine.processUserInput("f1c4")
|
engine.processUserInput("f1c4")
|
||||||
engine.processUserInput("g8f6")
|
engine.processUserInput("g8f6")
|
||||||
|
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
engine.processUserInput("c4f7") // Check!
|
engine.processUserInput("c4f7") // Check!
|
||||||
|
|
||||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
||||||
checkEvents.size shouldBe 1
|
checkEvents.size shouldBe 1
|
||||||
checkEvents.head.context.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:
|
// 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
|
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
||||||
// Wait, let's just use Sam Loyd's 10-move stalemate:
|
// Wait, let's just use Sam Loyd's 10-move stalemate:
|
||||||
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
|
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
|
||||||
test("GameEngine handles Stalemate via 10-move known sequence"):
|
test("GameEngine handles Stalemate via 10-move known sequence"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val observer = new EndingMockObserver()
|
val observer = new EndingMockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6",
|
"a5c7",
|
||||||
"c8e6"
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
|
"c8e6",
|
||||||
)
|
)
|
||||||
|
|
||||||
moves.dropRight(1).foreach(engine.processUserInput)
|
moves.dropRight(1).foreach(engine.processUserInput)
|
||||||
|
|
||||||
observer.events.clear()
|
observer.events.clear()
|
||||||
engine.processUserInput(moves.last)
|
engine.processUserInput(moves.last)
|
||||||
|
|
||||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
||||||
stalemateEvents.size shouldBe 1
|
stalemateEvents.size shouldBe 1
|
||||||
|
|
||||||
// Board should be reset after stalemate
|
// Board should be reset after stalemate
|
||||||
engine.board shouldBe Board.initial
|
engine.board shouldBe Board.initial
|
||||||
engine.turn shouldBe Color.White
|
engine.turn shouldBe Color.White
|
||||||
|
|
||||||
private class EndingMockObserver extends Observer:
|
private class EndingMockObserver extends Observer:
|
||||||
val events = mutable.ListBuffer[GameEvent]()
|
val events = mutable.ListBuffer[GameEvent]()
|
||||||
|
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
events += event
|
events += event
|
||||||
|
|||||||
+18
-21
@@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
|
||||||
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||||
if square == sq("e2") then List(promotionMove) else List.empty
|
if square == sq("e2") then List(promotionMove) else List.empty
|
||||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||||
def isCheck(context: GameContext): Boolean = false
|
def isCheck(context: GameContext): Boolean = false
|
||||||
def isCheckmate(context: GameContext): Boolean = false
|
def isCheckmate(context: GameContext): Boolean = false
|
||||||
def isStalemate(context: GameContext): Boolean = false
|
def isStalemate(context: GameContext): Boolean = false
|
||||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||||
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
|
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
val engine = new GameEngine(ruleSet = permissiveRules)
|
val engine = new GameEngine(ruleSet = permissiveRules)
|
||||||
@@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
val noLegalMoves = new RuleSet:
|
val noLegalMoves = new RuleSet:
|
||||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||||
def legalMoves(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 allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||||
def isCheck(context: GameContext): Boolean = false
|
def isCheck(context: GameContext): Boolean = false
|
||||||
def isCheckmate(context: GameContext): Boolean = false
|
def isCheckmate(context: GameContext): Boolean = false
|
||||||
def isStalemate(context: GameContext): Boolean = false
|
def isStalemate(context: GameContext): Boolean = false
|
||||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||||
|
|
||||||
val engine = new GameEngine(ruleSet = noLegalMoves)
|
val engine = new GameEngine(ruleSet = noLegalMoves)
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
@@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||||
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
|
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
|
|
||||||
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
|
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
|
||||||
engine.context.moves.lastOption shouldBe Some(normalMove)
|
engine.context.moves.lastOption shouldBe Some(normalMove)
|
||||||
|
|
||||||
test("replayMoves skips later moves after the first move triggers an error"):
|
test("replayMoves skips later moves after the first move triggers an error"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val saved = engine.context
|
val saved = engine.context
|
||||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||||
|
|
||||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||||
engine.context shouldBe saved
|
engine.context shouldBe saved
|
||||||
|
|
||||||
|
|
||||||
test("normalMoveNotation handles missing source piece"):
|
test("normalMoveNotation handles missing source piece"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
||||||
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.observerCount shouldBe 1
|
engine.observerCount shouldBe 1
|
||||||
engine.unsubscribe(observer)
|
engine.unsubscribe(observer)
|
||||||
engine.observerCount shouldBe 0
|
engine.observerCount shouldBe 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
|||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
|
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
|
||||||
import de.nowchess.io.pgn.PgnParser
|
import de.nowchess.io.pgn.PgnParser
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
import de.nowchess.io.pgn.PgnExporter
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
@@ -15,7 +15,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
|
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
|
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
|
||||||
val result = engine.loadGame(PgnParser, pgn)
|
val result = engine.loadGame(PgnParser, pgn)
|
||||||
result shouldBe Right(())
|
result shouldBe Right(())
|
||||||
engine.context.moves.size shouldBe 2
|
engine.context.moves.size shouldBe 2
|
||||||
@@ -23,7 +23,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("loadGame with FenParser: loads position without replaying moves"):
|
test("loadGame with FenParser: loads position without replaying moves"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
||||||
val result = engine.loadGame(FenParser, fen)
|
val result = engine.loadGame(FenParser, fen)
|
||||||
result shouldBe Right(())
|
result shouldBe Right(())
|
||||||
engine.context.moves.isEmpty shouldBe true
|
engine.context.moves.isEmpty shouldBe true
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite
|
|||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
/** Tests that exercise moveToPgn branches not covered by other test files:
|
/** Tests that exercise moveToPgn branches not covered by other test files:
|
||||||
* - CastleQueenside (line 223)
|
* - CastleQueenside (line 223)
|
||||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||||
* - Promotion(Bishop) notation (line 230)
|
* - Promotion(Bishop) notation (line 230)
|
||||||
* - King normal move notation (line 246)
|
* - King normal move notation (line 246)
|
||||||
*/
|
*/
|
||||||
class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||||
@@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
|
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
|
||||||
// Castling rights: white queen-side only (no king-side rook present)
|
// Castling rights: white queen-side only (no king-side rook present)
|
||||||
val castlingRights = de.nowchess.api.board.CastlingRights(
|
val castlingRights = de.nowchess.api.board.CastlingRights(
|
||||||
whiteKingSide = false,
|
whiteKingSide = false,
|
||||||
whiteQueenSide = true,
|
whiteQueenSide = true,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = false
|
blackQueenSide = false,
|
||||||
)
|
)
|
||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
.withBoard(board)
|
.withBoard(board)
|
||||||
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// White castles queenside: e1c1
|
// White castles queenside: e1c1
|
||||||
engine.processUserInput("e1c1")
|
engine.processUserInput("e1c1")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
|
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
|
// 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 board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||||
val epSquare = Square.fromAlgebraic("d6")
|
val epSquare = Square.fromAlgebraic("d6")
|
||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
.withBoard(board)
|
.withBoard(board)
|
||||||
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// White pawn on e5 captures en passant to d6
|
// White pawn on e5 captures en passant to d6
|
||||||
engine.processUserInput("e5d6")
|
engine.processUserInput("e5d6")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||||
moveEvt.capturedPiece shouldBe defined
|
moveEvt.capturedPiece shouldBe defined
|
||||||
moveEvt.capturedPiece.get should include ("Black")
|
moveEvt.capturedPiece.get should include("Black")
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// King moves e1 -> f1
|
// King moves e1 -> f1
|
||||||
engine.processUserInput("e1f1")
|
engine.processUserInput("e1f1")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
|
|
||||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||||
evt.pgnNotation should startWith ("K")
|
evt.pgnNotation should startWith("K")
|
||||||
evt.pgnNotation should include ("f1")
|
evt.pgnNotation should include("f1")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Checkmate ───────────────────────────────────────────────────
|
// ── Checkmate ───────────────────────────────────────────────────
|
||||||
|
|
||||||
test("checkmate ends game with CheckmateEvent"):
|
test("checkmate ends game with CheckmateEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||||
|
|
||||||
test("checkmate with white winner"):
|
test("checkmate with white winner"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -45,20 +45,29 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Stalemate ───────────────────────────────────────────────────
|
// ── Stalemate ───────────────────────────────────────────────────
|
||||||
|
|
||||||
test("stalemate ends game with StalemateEvent"):
|
test("stalemate ends game with StalemateEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6"
|
"a5c7",
|
||||||
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
)
|
)
|
||||||
moves.foreach(engine.processUserInput)
|
moves.foreach(engine.processUserInput)
|
||||||
observer.clear()
|
observer.clear()
|
||||||
@@ -68,21 +77,30 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[StalemateEvent] shouldBe true
|
observer.hasEvent[StalemateEvent] shouldBe true
|
||||||
|
|
||||||
test("stalemate when king has no moves and no pieces"):
|
test("stalemate when king has no moves and no pieces"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6",
|
"a5c7",
|
||||||
"c8e6"
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
|
"c8e6",
|
||||||
)
|
)
|
||||||
|
|
||||||
moves.foreach(engine.processUserInput)
|
moves.foreach(engine.processUserInput)
|
||||||
@@ -93,7 +111,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Check detection ────────────────────────────────────────────
|
// ── Check detection ────────────────────────────────────────────
|
||||||
|
|
||||||
test("check detected after move puts king in check"):
|
test("check detected after move puts king in check"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -108,7 +126,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||||
|
|
||||||
test("check by knight"):
|
test("check by knight"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -122,7 +140,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Fifty-move rule ────────────────────────────────────────────
|
// ── Fifty-move rule ────────────────────────────────────────────
|
||||||
|
|
||||||
test("fifty-move rule triggers when half-move clock reaches 100"):
|
test("fifty-move rule triggers when half-move clock reaches 100"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -155,7 +173,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Draw claim ────────────────────────────────────────────────
|
// ── Draw claim ────────────────────────────────────────────────
|
||||||
|
|
||||||
test("draw can be claimed when fifty-move rule is available"):
|
test("draw can be claimed when fifty-move rule is available"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -167,7 +185,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||||
|
|
||||||
test("draw cannot be claimed when not available"):
|
test("draw cannot be claimed when not available"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
|
|||||||
+41
-41
@@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true)
|
||||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("isPendingPromotion is true after PromotionRequired input") {
|
test("isPendingPromotion is true after PromotionRequired input") {
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
captureEvents(engine)
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
engine.isPendingPromotion should be (true)
|
engine.isPendingPromotion should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("isPendingPromotion is false before any promotion input") {
|
test("isPendingPromotion is false before any promotion input") {
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion") {
|
test("completePromotion with rook underpromotion") {
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
captureEvents(engine)
|
captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Rook)
|
engine.completePromotion(PromotionPiece.Rook)
|
||||||
|
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||||
@@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||||
val engine = engineWith(promotionBoard)
|
val engine = engineWith(promotionBoard)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
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.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("h7h8")
|
engine.processUserInput("h7h8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
|
events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||||
val engine = engineWith(board)
|
val engine = engineWith(board)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("b7b8")
|
engine.processUserInput("b7b8")
|
||||||
engine.completePromotion(PromotionPiece.Knight)
|
engine.completePromotion(PromotionPiece.Knight)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
|
events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with black pawn promotion results in Moved") {
|
test("completePromotion with black pawn promotion results in Moved") {
|
||||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||||
val engine = engineWith(board, Color.Black)
|
val engine = engineWith(board, Color.Black)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
engine.processUserInput("e2e1")
|
engine.processUserInput("e2e1")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
|
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||||
@@ -177,21 +177,21 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
DefaultRules.applyMove(context)(move)
|
DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
|
|
||||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.isPendingPromotion should be (true)
|
engine.isPendingPromotion should be(true)
|
||||||
|
|
||||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||||
// but only Normal moves exist → fires InvalidMoveEvent
|
// but only Normal moves exist → fires InvalidMoveEvent
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||||
invalidEvt.reason should include ("Error completing promotion")
|
invalidEvt.reason should include("Error completing promotion")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
|
import de.nowchess.api.board.{Color, File, Piece, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
@@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Observer wiring ────────────────────────────────────────────
|
// ── Observer wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
test("observer subscribe and unsubscribe behavior"):
|
test("observer subscribe and unsubscribe behavior"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
@@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Invalid moves (minimal) ────────────────────────────────────
|
// ── Invalid moves (minimal) ────────────────────────────────────
|
||||||
|
|
||||||
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
|
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
engine.processUserInput("h3h4")
|
engine.processUserInput("h3h4")
|
||||||
|
|
||||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||||
engine.turn shouldBe Color.White // turn unchanged
|
engine.turn shouldBe Color.White // turn unchanged
|
||||||
|
|
||||||
engine.processUserInput("e7e5") // try to move black pawn on white's turn
|
engine.processUserInput("e7e5") // try to move black pawn on white's turn
|
||||||
|
|
||||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("e5e4") // pawn backward
|
engine.processUserInput("e5e4") // pawn backward
|
||||||
|
|
||||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||||
|
|
||||||
// ── Undo/Redo ────────────────────────────────────────────────
|
// ── Undo/Redo ────────────────────────────────────────────────
|
||||||
|
|
||||||
test("undo redo success and empty-history failures"):
|
test("undo redo success and empty-history failures"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Fifty-move rule ────────────────────────────────────────────
|
// ── Fifty-move rule ────────────────────────────────────────────
|
||||||
|
|
||||||
test("fifty-move event and draw claim success/failure"):
|
test("fifty-move event and draw claim success/failure"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Castling ────────────────────────────────────────────────────
|
// ── Castling ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("kingside castling executes successfully"):
|
test("kingside castling executes successfully"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("queenside castling executes successfully"):
|
test("queenside castling executes successfully"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("undo castling emits PGN notation"):
|
test("undo castling emits PGN notation"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── En passant ──────────────────────────────────────────────────
|
// ── En passant ──────────────────────────────────────────────────
|
||||||
|
|
||||||
test("en passant capture executes successfully"):
|
test("en passant capture executes successfully"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||||
val moveEvt = observer.getEvent[MoveExecutedEvent]
|
val moveEvt = observer.getEvent[MoveExecutedEvent]
|
||||||
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
|
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
|
||||||
|
|
||||||
test("undo en passant emits file-x-destination notation"):
|
test("undo en passant emits file-x-destination notation"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Pawn promotion ─────────────────────────────────────────────
|
// ── Pawn promotion ─────────────────────────────────────────────
|
||||||
|
|
||||||
test("pawn reaching back rank requires promotion"):
|
test("pawn reaching back rank requires promotion"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
engine.turn shouldBe Color.Black
|
engine.turn shouldBe Color.Black
|
||||||
|
|
||||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||||
|
|
||||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
|||||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||||
|
|
||||||
test("undo promotion emits notation with piece suffix"):
|
test("undo promotion emits notation with piece suffix"):
|
||||||
val engine = EngineTestHelpers.makeEngine()
|
val engine = EngineTestHelpers.makeEngine()
|
||||||
val observer = new EngineTestHelpers.MockObserver()
|
val observer = new EngineTestHelpers.MockObserver()
|
||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
|
|||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
|
|
||||||
/** Service for persisting and loading game states to/from disk.
|
/** Service for persisting and loading game states to/from disk.
|
||||||
*
|
*
|
||||||
* Abstracts file I/O operations away from the UI layer.
|
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||||
* Handles both reading and writing game files.
|
*/
|
||||||
*/
|
|
||||||
trait GameFileService:
|
trait GameFileService:
|
||||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||||
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
|
|||||||
()
|
()
|
||||||
}.fold(
|
}.fold(
|
||||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||||
_ => Right(())
|
_ => Right(()),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Load a game context from a file using the specified importer. */
|
/** Load a game context from a file using the specified importer. */
|
||||||
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
|
|||||||
importer.importGameContext(json)
|
importer.importGameContext(json)
|
||||||
}.fold(
|
}.fold(
|
||||||
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||||
result => result
|
result => result,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,28 +15,22 @@ object FenExporter extends GameContextExport:
|
|||||||
/** Build the FEN representation for a single rank. */
|
/** Build the FEN representation for a single rank. */
|
||||||
private def buildRankString(board: Board, rank: Rank): String =
|
private def buildRankString(board: Board, rank: Rank): String =
|
||||||
val rankSquares = File.values.map(file => Square(file, rank))
|
val rankSquares = File.values.map(file => Square(file, rank))
|
||||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
|
||||||
var emptyCount = 0
|
case ((acc, empty), square) =>
|
||||||
|
board.pieceAt(square) match
|
||||||
for square <- rankSquares do
|
case Some(piece) =>
|
||||||
board.pieceAt(square) match
|
val flushed = if empty > 0 then acc + empty.toString else acc
|
||||||
case Some(piece) =>
|
(flushed + pieceToFenChar(piece), 0)
|
||||||
if emptyCount > 0 then
|
case None =>
|
||||||
rankChars += emptyCount.toString.charAt(0)
|
(acc, empty + 1)
|
||||||
emptyCount = 0
|
if emptyCount > 0 then result + emptyCount.toString else result
|
||||||
rankChars += pieceToFenChar(piece)
|
|
||||||
case None =>
|
|
||||||
emptyCount += 1
|
|
||||||
|
|
||||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
|
||||||
rankChars.mkString
|
|
||||||
|
|
||||||
/** Convert a GameContext to a complete FEN string. */
|
/** Convert a GameContext to a complete FEN string. */
|
||||||
def gameContextToFen(context: GameContext): String =
|
def gameContextToFen(context: GameContext): String =
|
||||||
val piecePlacement = boardToFen(context.board)
|
val piecePlacement = boardToFen(context.board)
|
||||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||||
val castling = castlingString(context.castlingRights)
|
val castling = castlingString(context.castlingRights)
|
||||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||||
val fullMoveNumber = 1 + (context.moves.length / 2)
|
val fullMoveNumber = 1 + (context.moves.length / 2)
|
||||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||||
|
|
||||||
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
|
|||||||
|
|
||||||
/** Convert castling rights to FEN notation. */
|
/** Convert castling rights to FEN notation. */
|
||||||
private def castlingString(rights: CastlingRights): String =
|
private def castlingString(rights: CastlingRights): String =
|
||||||
val wk = if rights.whiteKingSide then "K" else ""
|
val wk = if rights.whiteKingSide then "K" else ""
|
||||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||||
val bk = if rights.blackKingSide then "k" else ""
|
val bk = if rights.blackKingSide then "k" else ""
|
||||||
val bq = if rights.blackQueenSide then "q" else ""
|
val bq = if rights.blackQueenSide then "q" else ""
|
||||||
val result = s"$wk$wq$bk$bq"
|
val result = s"$wk$wq$bk$bq"
|
||||||
if result.isEmpty then "-" else result
|
if result.isEmpty then "-" else result
|
||||||
|
|
||||||
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
|
|||||||
case PieceType.Queen => 'q'
|
case PieceType.Queen => 'q'
|
||||||
case PieceType.King => 'k'
|
case PieceType.King => 'k'
|
||||||
if piece.color == Color.White then base.toUpper else base
|
if piece.color == Color.White then base.toUpper else base
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
|
|||||||
|
|
||||||
object FenParser extends GameContextImport:
|
object FenParser extends GameContextImport:
|
||||||
|
|
||||||
/** Parse a complete FEN string into a GameContext.
|
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
|
||||||
* Returns Left with error message if the format is invalid. */
|
*/
|
||||||
def parseFen(fen: String): Either[String, GameContext] =
|
def parseFen(fen: String): Either[String, GameContext] =
|
||||||
val parts = fen.trim.split("\\s+")
|
val parts = fen.trim.split("\\s+")
|
||||||
if parts.length != 6 then
|
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
|
||||||
else
|
else
|
||||||
for
|
for
|
||||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||||
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
||||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
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)")
|
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||||
yield GameContext(
|
yield GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
turn = activeColor,
|
turn = activeColor,
|
||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassant,
|
enPassantSquare = enPassant,
|
||||||
halfMoveClock = halfMoveClock,
|
halfMoveClock = halfMoveClock,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
|
|||||||
|
|
||||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
||||||
private def parseCastling(s: String): Option[CastlingRights] =
|
private def parseCastling(s: String): Option[CastlingRights] =
|
||||||
if s == "-" then
|
if s == "-" then Some(CastlingRights.None)
|
||||||
Some(CastlingRights.None)
|
|
||||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||||
Some(CastlingRights(
|
Some(
|
||||||
whiteKingSide = s.contains('K'),
|
CastlingRights(
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteKingSide = s.contains('K'),
|
||||||
blackKingSide = s.contains('k'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackQueenSide = s.contains('q')
|
blackKingSide = s.contains('k'),
|
||||||
))
|
blackQueenSide = s.contains('q'),
|
||||||
else
|
),
|
||||||
None
|
)
|
||||||
|
else None
|
||||||
|
|
||||||
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
||||||
private def parseEnPassant(s: String): Option[Option[Square]] =
|
private def parseEnPassant(s: String): Option[Option[Square]] =
|
||||||
if s == "-" then Some(None)
|
if s == "-" then Some(None)
|
||||||
else Square.fromAlgebraic(s).map(Some(_))
|
else Square.fromAlgebraic(s).map(Some(_))
|
||||||
|
|
||||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
|
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format
|
||||||
* Returns None if the format is invalid. */
|
* is invalid.
|
||||||
|
*/
|
||||||
def parseBoard(fen: String): Option[Board] =
|
def parseBoard(fen: String): Option[Board] =
|
||||||
val rankStrings = fen.split("/", -1)
|
val rankStrings = fen.split("/", -1)
|
||||||
if rankStrings.length != 8 then None
|
if rankStrings.length != 8 then None
|
||||||
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
|
|||||||
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
||||||
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
||||||
|
|
||||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
|
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the
|
||||||
* Returns None if the rank string contains invalid characters or the wrong number of files. */
|
* rank string contains invalid characters or the wrong number of files.
|
||||||
|
*/
|
||||||
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
||||||
var fileIdx = 0
|
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
|
||||||
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
|
case ((idx, true, acc), _) => (idx, true, acc)
|
||||||
var failed = false
|
case ((idx, false, acc), c) =>
|
||||||
|
if idx > 7 then (idx, true, acc)
|
||||||
for c <- rankStr if !failed do
|
else if c.isDigit then (idx + c.asDigit, false, acc)
|
||||||
if fileIdx > 7 then
|
else
|
||||||
failed = true
|
charToPiece(c) match
|
||||||
else if c.isDigit then
|
case None => (idx, true, acc)
|
||||||
fileIdx += c.asDigit
|
case Some(piece) =>
|
||||||
else
|
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
|
||||||
charToPiece(c) match
|
|
||||||
case None => failed = true
|
|
||||||
case Some(piece) =>
|
|
||||||
val file = File.values(fileIdx)
|
|
||||||
squares += (Square(file, rank) -> piece)
|
|
||||||
fileIdx += 1
|
|
||||||
|
|
||||||
if failed || fileIdx != 8 then None
|
if failed || fileIdx != 8 then None
|
||||||
else Some(squares.toList)
|
else Some(squares)
|
||||||
|
|
||||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
||||||
private def charToPiece(c: Char): Option[Piece] =
|
private def charToPiece(c: Char): Option[Piece] =
|
||||||
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
|
|||||||
case 'k' => Some(PieceType.King)
|
case 'k' => Some(PieceType.King)
|
||||||
case _ => None
|
case _ => None
|
||||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
private def pieceChar: Parser[Piece] =
|
private def pieceChar: Parser[Piece] =
|
||||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||||
val c = s.head
|
val c = s.head
|
||||||
val color = if c.isUpper then Color.White else Color.Black
|
val color = if c.isUpper then Color.White else Color.Black
|
||||||
Piece(color, charToPieceType(c.toLower))
|
Piece(color, charToPieceType(c.toLower))
|
||||||
}
|
}
|
||||||
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||||
|
|
||||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
|
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
|
||||||
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
* placement exceeds board bounds.
|
||||||
|
*/
|
||||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||||
rankTokens >> { tokens =>
|
rankTokens >> { tokens =>
|
||||||
buildSquares(rank, tokens) match
|
buildSquares(rank, tokens) match
|
||||||
@@ -45,16 +46,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
|
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
|
||||||
private def boardParser: Parser[Board] =
|
private def boardParser: Parser[Board] =
|
||||||
rankParser(Rank.R8) ~
|
rankParser(Rank.R8) ~
|
||||||
(rankSep ~> rankParser(Rank.R7)) ~
|
(rankSep ~> rankParser(Rank.R7)) ~
|
||||||
(rankSep ~> rankParser(Rank.R6)) ~
|
(rankSep ~> rankParser(Rank.R6)) ~
|
||||||
(rankSep ~> rankParser(Rank.R5)) ~
|
(rankSep ~> rankParser(Rank.R5)) ~
|
||||||
(rankSep ~> rankParser(Rank.R4)) ~
|
(rankSep ~> rankParser(Rank.R4)) ~
|
||||||
(rankSep ~> rankParser(Rank.R3)) ~
|
(rankSep ~> rankParser(Rank.R3)) ~
|
||||||
(rankSep ~> rankParser(Rank.R2)) ~
|
(rankSep ~> rankParser(Rank.R2)) ~
|
||||||
(rankSep ~> rankParser(Rank.R1)) ^^ {
|
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||||
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
|
||||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Color parser ─────────────────────────────────────────────────────────
|
// ── Color parser ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
private def castlingParser: Parser[CastlingRights] =
|
private def castlingParser: Parser[CastlingRights] =
|
||||||
"-" ^^^ CastlingRights.None |
|
"-" ^^^ CastlingRights.None |
|
||||||
"[KQkq]{1,4}".r ^^ { s =>
|
"[KQkq]{1,4}".r ^^ { s =>
|
||||||
CastlingRights(
|
CastlingRights(
|
||||||
whiteKingSide = s.contains('K'),
|
whiteKingSide = s.contains('K'),
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackKingSide = s.contains('k'),
|
blackKingSide = s.contains('k'),
|
||||||
blackQueenSide = s.contains('q')
|
blackQueenSide = s.contains('q'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── En passant parser ────────────────────────────────────────────────────
|
// ── En passant parser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def enPassantParser: Parser[Option[Square]] =
|
private def enPassantParser: Parser[Option[Square]] =
|
||||||
"-" ^^^ Option.empty[Square] |
|
"-" ^^^ Option.empty[Square] |
|
||||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||||
|
|
||||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
private def fenParser: Parser[GameContext] =
|
private def fenParser: Parser[GameContext] =
|
||||||
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
||||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||||
GameContext(
|
GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
turn = color,
|
turn = color,
|
||||||
castlingRights = castling,
|
castlingRights = castling,
|
||||||
enPassantSquare = ep,
|
enPassantSquare = ep,
|
||||||
halfMoveClock = halfMove,
|
halfMoveClock = halfMove,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
|
|
||||||
private def pieceChar(using P[Any]): P[Piece] =
|
private def pieceChar(using P[Any]): P[Piece] =
|
||||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||||
val c = s.head
|
val c = s.head
|
||||||
val color = if c.isUpper then Color.White else Color.Black
|
val color = if c.isUpper then Color.White else Color.Black
|
||||||
Piece(color, charToPieceType(c.toLower))
|
Piece(color, charToPieceType(c.toLower))
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
|
|
||||||
private def boardParser(using P[Any]): P[Board] =
|
private def boardParser(using P[Any]): P[Board] =
|
||||||
(rankParser(Rank.R8) ~ sep ~
|
(rankParser(Rank.R8) ~ sep ~
|
||||||
rankParser(Rank.R7) ~ sep ~
|
rankParser(Rank.R7) ~ sep ~
|
||||||
rankParser(Rank.R6) ~ sep ~
|
rankParser(Rank.R6) ~ sep ~
|
||||||
rankParser(Rank.R5) ~ sep ~
|
rankParser(Rank.R5) ~ sep ~
|
||||||
rankParser(Rank.R4) ~ sep ~
|
rankParser(Rank.R4) ~ sep ~
|
||||||
rankParser(Rank.R3) ~ sep ~
|
rankParser(Rank.R3) ~ sep ~
|
||||||
rankParser(Rank.R2) ~ sep ~
|
rankParser(Rank.R2) ~ sep ~
|
||||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,20 +61,20 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
|
|
||||||
private def castlingParser(using P[Any]): P[CastlingRights] =
|
private def castlingParser(using P[Any]): P[CastlingRights] =
|
||||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||||
CharsWhileIn("KQkq").!.map { s =>
|
CharsWhileIn("KQkq").!.map { s =>
|
||||||
CastlingRights(
|
CastlingRights(
|
||||||
whiteKingSide = s.contains('K'),
|
whiteKingSide = s.contains('K'),
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackKingSide = s.contains('k'),
|
blackKingSide = s.contains('k'),
|
||||||
blackQueenSide = s.contains('q')
|
blackQueenSide = s.contains('q'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── En passant parser ────────────────────────────────────────────────────
|
// ── En passant parser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
||||||
LiteralStr("-").map(_ => Option.empty[Square]) |
|
LiteralStr("-").map(_ => Option.empty[Square]) |
|
||||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||||
|
|
||||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
|
|
||||||
private def fenParser(using P[Any]): P[GameContext] =
|
private def fenParser(using P[Any]): P[GameContext] =
|
||||||
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
||||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||||
case (board, color, castling, ep, halfMove, _) =>
|
case (board, color, castling, ep, halfMove, _) =>
|
||||||
GameContext(
|
GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
turn = color,
|
turn = color,
|
||||||
castlingRights = castling,
|
castlingRights = castling,
|
||||||
enPassantSquare = ep,
|
enPassantSquare = ep,
|
||||||
halfMoveClock = halfMove,
|
halfMoveClock = halfMove,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,20 @@ private[fen] object FenParserSupport:
|
|||||||
'n' -> PieceType.Knight,
|
'n' -> PieceType.Knight,
|
||||||
'b' -> PieceType.Bishop,
|
'b' -> PieceType.Bishop,
|
||||||
'q' -> PieceType.Queen,
|
'q' -> PieceType.Queen,
|
||||||
'k' -> PieceType.King
|
'k' -> PieceType.King,
|
||||||
)
|
)
|
||||||
|
|
||||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||||
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
tokens
|
||||||
case (None, _) => None
|
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
case (None, _) => None
|
||||||
if fileIdx > 7 then None
|
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||||
else
|
if fileIdx > 7 then None
|
||||||
val sq = Square(File.values(fileIdx), rank)
|
else
|
||||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
val sq = Square(File.values(fileIdx), rank)
|
||||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||||
val next = fileIdx + n
|
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||||
if next > 8 then None
|
val next = fileIdx + n
|
||||||
else Some((acc, next))
|
if next > 8 then None
|
||||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
else Some((acc, next))
|
||||||
|
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||||
|
|||||||
@@ -8,31 +8,31 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
|||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.io.GameContextExport
|
import de.nowchess.io.GameContextExport
|
||||||
import de.nowchess.io.pgn.PgnExporter
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
import java.time.{LocalDate, ZonedDateTime, ZoneId}
|
import java.time.{LocalDate, ZoneId, ZonedDateTime}
|
||||||
|
|
||||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||||
*
|
*
|
||||||
* The JSON includes:
|
* The JSON includes:
|
||||||
* - Game metadata (players, event, date, result)
|
* - Game metadata (players, event, date, result)
|
||||||
* - Board state (all pieces and their positions)
|
* - Board state (all pieces and their positions)
|
||||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||||
* - Captured pieces tracking (which pieces have been removed)
|
* - Captured pieces tracking (which pieces have been removed)
|
||||||
* - Timestamp for record-keeping
|
* - Timestamp for record-keeping
|
||||||
*/
|
*/
|
||||||
object JsonExporter extends GameContextExport:
|
object JsonExporter extends GameContextExport:
|
||||||
private val mapper = createMapper()
|
private val mapper = createMapper()
|
||||||
|
|
||||||
private def createMapper(): ObjectMapper =
|
private def createMapper(): ObjectMapper =
|
||||||
val mapper = new ObjectMapper()
|
val mapper = new ObjectMapper()
|
||||||
.registerModule(DefaultScalaModule)
|
.registerModule(DefaultScalaModule)
|
||||||
|
|
||||||
// Configure pretty printer with custom spacing to match test expectations
|
// Configure pretty printer with custom spacing to match test expectations
|
||||||
val indenter = new DefaultIndenter(" ", "\n")
|
val indenter = new DefaultIndenter(" ", "\n")
|
||||||
val printer = new DefaultPrettyPrinter()
|
val printer = new DefaultPrettyPrinter()
|
||||||
printer.indentArraysWith(indenter)
|
printer.indentArraysWith(indenter)
|
||||||
printer.indentObjectsWith(indenter)
|
printer.indentObjectsWith(indenter)
|
||||||
|
|
||||||
mapper.setDefaultPrettyPrinter(printer)
|
mapper.setDefaultPrettyPrinter(printer)
|
||||||
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
||||||
mapper
|
mapper
|
||||||
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
|
|||||||
formatJson(mapper.writeValueAsString(record))
|
formatJson(mapper.writeValueAsString(record))
|
||||||
|
|
||||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||||
val pgn = try {
|
val pgn =
|
||||||
Some(PgnExporter.exportGameContext(context))
|
try
|
||||||
} catch {
|
Some(PgnExporter.exportGameContext(context))
|
||||||
case _: Exception => None
|
catch {
|
||||||
}
|
case _: Exception => None
|
||||||
|
}
|
||||||
JsonGameRecord(
|
JsonGameRecord(
|
||||||
metadata = Some(buildMetadata()),
|
metadata = Some(buildMetadata()),
|
||||||
gameState = Some(buildGameState(context)),
|
gameState = Some(buildGameState(context)),
|
||||||
moveHistory = pgn,
|
moveHistory = pgn,
|
||||||
moves = Some(buildMoves(context.moves)),
|
moves = Some(buildMoves(context.moves)),
|
||||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildMetadata(): JsonMetadata =
|
private def buildMetadata(): JsonMetadata =
|
||||||
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
event = Some("Game"),
|
event = Some("Game"),
|
||||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||||
date = Some(LocalDate.now().toString),
|
date = Some(LocalDate.now().toString),
|
||||||
result = Some("*")
|
result = Some("*"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildGameState(context: GameContext): JsonGameState =
|
private def buildGameState(context: GameContext): JsonGameState =
|
||||||
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
turn = Some(context.turn.label),
|
turn = Some(context.turn.label),
|
||||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||||
halfMoveClock = Some(context.halfMoveClock)
|
halfMoveClock = Some(context.halfMoveClock),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||||
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
Some(rights.whiteKingSide),
|
Some(rights.whiteKingSide),
|
||||||
Some(rights.whiteQueenSide),
|
Some(rights.whiteQueenSide),
|
||||||
Some(rights.blackKingSide),
|
Some(rights.blackKingSide),
|
||||||
Some(rights.blackQueenSide)
|
Some(rights.blackQueenSide),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||||
@@ -128,12 +129,11 @@ object JsonExporter extends GameContextExport:
|
|||||||
val captured = Square.all.flatMap { square =>
|
val captured = Square.all.flatMap { square =>
|
||||||
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||||
board.pieceAt(square) match
|
board.pieceAt(square) match
|
||||||
case None => Some(initialPiece)
|
case None => Some(initialPiece)
|
||||||
case Some(_) => None
|
case Some(_) => None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||||
(blackCaptured, whiteCaptured)
|
(blackCaptured, whiteCaptured)
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
case class JsonMetadata(
|
case class JsonMetadata(
|
||||||
event: Option[String] = None,
|
event: Option[String] = None,
|
||||||
players: Option[Map[String, String]] = None,
|
players: Option[Map[String, String]] = None,
|
||||||
date: Option[String] = None,
|
date: Option[String] = None,
|
||||||
result: Option[String] = None
|
result: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonPiece(
|
case class JsonPiece(
|
||||||
square: Option[String] = None,
|
square: Option[String] = None,
|
||||||
color: Option[String] = None,
|
color: Option[String] = None,
|
||||||
piece: Option[String] = None
|
piece: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonCastlingRights(
|
case class JsonCastlingRights(
|
||||||
whiteKingSide: Option[Boolean] = None,
|
whiteKingSide: Option[Boolean] = None,
|
||||||
whiteQueenSide: Option[Boolean] = None,
|
whiteQueenSide: Option[Boolean] = None,
|
||||||
blackKingSide: Option[Boolean] = None,
|
blackKingSide: Option[Boolean] = None,
|
||||||
blackQueenSide: Option[Boolean] = None
|
blackQueenSide: Option[Boolean] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonGameState(
|
case class JsonGameState(
|
||||||
board: Option[List[JsonPiece]] = None,
|
board: Option[List[JsonPiece]] = None,
|
||||||
turn: Option[String] = None,
|
turn: Option[String] = None,
|
||||||
castlingRights: Option[JsonCastlingRights] = None,
|
castlingRights: Option[JsonCastlingRights] = None,
|
||||||
enPassantSquare: Option[String] = None,
|
enPassantSquare: Option[String] = None,
|
||||||
halfMoveClock: Option[Int] = None
|
halfMoveClock: Option[Int] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonCapturedPieces(
|
case class JsonCapturedPieces(
|
||||||
byWhite: Option[List[String]] = None,
|
byWhite: Option[List[String]] = None,
|
||||||
byBlack: Option[List[String]] = None
|
byBlack: Option[List[String]] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonMoveType(
|
case class JsonMoveType(
|
||||||
`type`: Option[String] = None,
|
`type`: Option[String] = None,
|
||||||
isCapture: Option[Boolean] = None,
|
isCapture: Option[Boolean] = None,
|
||||||
promotionPiece: Option[String] = None
|
promotionPiece: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonMove(
|
case class JsonMove(
|
||||||
from: Option[String] = None,
|
from: Option[String] = None,
|
||||||
to: Option[String] = None,
|
to: Option[String] = None,
|
||||||
`type`: Option[JsonMoveType] = None
|
`type`: Option[JsonMoveType] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonGameRecord(
|
case class JsonGameRecord(
|
||||||
metadata: Option[JsonMetadata] = None,
|
metadata: Option[JsonMetadata] = None,
|
||||||
gameState: Option[JsonGameState] = None,
|
gameState: Option[JsonGameState] = None,
|
||||||
moveHistory: Option[String] = None,
|
moveHistory: Option[String] = None,
|
||||||
moves: Option[List[JsonMove]] = None,
|
moves: Option[List[JsonMove]] = None,
|
||||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||||
timestamp: Option[String] = None
|
timestamp: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
|
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
|
||||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
|
|||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
|
|
||||||
/** Imports a GameContext from JSON format using Jackson.
|
/** Imports a GameContext from JSON format using Jackson.
|
||||||
*
|
*
|
||||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||||
* - Board state
|
* - Board state
|
||||||
* - Current turn
|
* - Current turn
|
||||||
* - Castling rights
|
* - Castling rights
|
||||||
* - En passant square
|
* - En passant square
|
||||||
* - Half-move clock
|
* - Half-move clock
|
||||||
* - Move history
|
* - Move history
|
||||||
*
|
*
|
||||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||||
*/
|
*/
|
||||||
object JsonParser extends GameContextImport:
|
object JsonParser extends GameContextImport:
|
||||||
|
|
||||||
private val mapper = new ObjectMapper()
|
private val mapper = new ObjectMapper()
|
||||||
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
|
|||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||||
.left.map(e => "JSON parsing error: " + e.getMessage)
|
.map(e => "JSON parsing error: " + e.getMessage)
|
||||||
.flatMap { data =>
|
.flatMap { data =>
|
||||||
val gs = data.gameState.getOrElse(JsonGameState())
|
val gs = data.gameState.getOrElse(JsonGameState())
|
||||||
val rawBoard = gs.board.getOrElse(Nil)
|
val rawBoard = gs.board.getOrElse(Nil)
|
||||||
val rawTurn = gs.turn.getOrElse("White")
|
val rawTurn = gs.turn.getOrElse("White")
|
||||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||||
val rawMoves = data.moves.getOrElse(Nil)
|
val rawMoves = data.moves.getOrElse(Nil)
|
||||||
|
|
||||||
for
|
for
|
||||||
board <- parseBoard(rawBoard)
|
board <- parseBoard(rawBoard)
|
||||||
turn <- parseTurn(rawTurn)
|
turn <- parseTurn(rawTurn)
|
||||||
castlingRights = parseCastlingRights(rawCr)
|
castlingRights = parseCastlingRights(rawCr)
|
||||||
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||||
moves <- parseMoves(rawMoves)
|
moves <- parseMoves(rawMoves)
|
||||||
yield GameContext(
|
yield GameContext(
|
||||||
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
|
|||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassantSquare,
|
enPassantSquare = enPassantSquare,
|
||||||
halfMoveClock = rawHmc,
|
halfMoveClock = rawHmc,
|
||||||
moves = moves
|
moves = moves,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||||
val parsedPieces = pieces.flatMap { p =>
|
val parsedPieces = pieces.flatMap { p =>
|
||||||
for
|
for
|
||||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||||
color <- p.color.flatMap(parseColor)
|
color <- p.color.flatMap(parseColor)
|
||||||
pt <- p.piece.flatMap(parsePieceType)
|
pt <- p.piece.flatMap(parsePieceType)
|
||||||
yield (sq, Piece(color, pt))
|
yield (sq, Piece(color, pt))
|
||||||
}
|
}
|
||||||
Right(Board(parsedPieces.toMap))
|
Right(Board(parsedPieces.toMap))
|
||||||
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
|
|||||||
|
|
||||||
private def parsePieceType(pt: String): Option[PieceType] =
|
private def parsePieceType(pt: String): Option[PieceType] =
|
||||||
pt match
|
pt match
|
||||||
case "Pawn" => Some(PieceType.Pawn)
|
case "Pawn" => Some(PieceType.Pawn)
|
||||||
case "Knight" => Some(PieceType.Knight)
|
case "Knight" => Some(PieceType.Knight)
|
||||||
case "Bishop" => Some(PieceType.Bishop)
|
case "Bishop" => Some(PieceType.Bishop)
|
||||||
case "Rook" => Some(PieceType.Rook)
|
case "Rook" => Some(PieceType.Rook)
|
||||||
case "Queen" => Some(PieceType.Queen)
|
case "Queen" => Some(PieceType.Queen)
|
||||||
case "King" => Some(PieceType.King)
|
case "King" => Some(PieceType.King)
|
||||||
case _ => None
|
case _ => None
|
||||||
|
|
||||||
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||||
CastlingRights(
|
CastlingRights(
|
||||||
cr.whiteKingSide.getOrElse(false),
|
cr.whiteKingSide.getOrElse(false),
|
||||||
cr.whiteQueenSide.getOrElse(false),
|
cr.whiteQueenSide.getOrElse(false),
|
||||||
cr.blackKingSide.getOrElse(false),
|
cr.blackKingSide.getOrElse(false),
|
||||||
cr.blackQueenSide.getOrElse(false)
|
cr.blackQueenSide.getOrElse(false),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||||
Right(moves.flatMap { m =>
|
Right(moves.flatMap { m =>
|
||||||
for
|
for
|
||||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||||
moveType <- m.`type`.flatMap(parseMoveType)
|
moveType <- m.`type`.flatMap(parseMoveType)
|
||||||
yield Move(from, to, moveType)
|
yield Move(from, to, moveType)
|
||||||
})
|
})
|
||||||
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
|
|||||||
Some(MoveType.EnPassant)
|
Some(MoveType.EnPassant)
|
||||||
case Some("promotion") =>
|
case Some("promotion") =>
|
||||||
val piece = mt.promotionPiece match
|
val piece = mt.promotionPiece match
|
||||||
case Some("queen") => PromotionPiece.Queen
|
case Some("queen") => PromotionPiece.Queen
|
||||||
case Some("rook") => PromotionPiece.Rook
|
case Some("rook") => PromotionPiece.Rook
|
||||||
case Some("bishop") => PromotionPiece.Bishop
|
case Some("bishop") => PromotionPiece.Bishop
|
||||||
case Some("knight") => PromotionPiece.Knight
|
case Some("knight") => PromotionPiece.Knight
|
||||||
case _ => PromotionPiece.Queen // default
|
case _ => PromotionPiece.Queen // default
|
||||||
Some(MoveType.Promotion(piece))
|
Some(MoveType.Promotion(piece))
|
||||||
case _ => None
|
case _ => None
|
||||||
|
|||||||
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
|
|||||||
/** Export a GameContext to PGN format. */
|
/** Export a GameContext to PGN format. */
|
||||||
def exportGameContext(context: GameContext): String =
|
def exportGameContext(context: GameContext): String =
|
||||||
val headers = Map(
|
val headers = Map(
|
||||||
"Event" -> "?",
|
"Event" -> "?",
|
||||||
"White" -> "?",
|
"White" -> "?",
|
||||||
"Black" -> "?",
|
"Black" -> "?",
|
||||||
"Result" -> "*"
|
"Result" -> "*",
|
||||||
)
|
)
|
||||||
|
|
||||||
exportGame(headers, context.moves)
|
exportGame(headers, context.moves)
|
||||||
|
|
||||||
/** Export a game with headers and moves to PGN format. */
|
/** Export a game with headers and moves to PGN format. */
|
||||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||||
val headerLines = headers.map { case (key, value) =>
|
val headerLines = headers
|
||||||
s"""[$key "$value"]"""
|
.map { case (key, value) =>
|
||||||
}.mkString("\n")
|
s"""[$key "$value"]"""
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
.mkString("\n")
|
||||||
|
|
||||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
val moveText =
|
||||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
if moves.isEmpty then ""
|
||||||
val moveNum = moveNumber + 1
|
else
|
||||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
|
||||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
|
||||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
|
||||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
|
||||||
|
|
||||||
val termination = headers.getOrElse("Result", "*")
|
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||||
moveLines.mkString(" ") + s" $termination"
|
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
|
if headerLines.isEmpty then moveText
|
||||||
else if moveText.isEmpty then headerLines
|
else if moveText.isEmpty then headerLines
|
||||||
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
|
|||||||
case MoveType.CastleKingside => "O-O"
|
case MoveType.CastleKingside => "O-O"
|
||||||
case MoveType.CastleQueenside => "O-O-O"
|
case MoveType.CastleQueenside => "O-O-O"
|
||||||
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
case MoveType.Promotion(pp) =>
|
case MoveType.Promotion(pp) =>
|
||||||
val promSuffix = pp match
|
val promSuffix = pp match
|
||||||
case PromotionPiece.Queen => "=Q"
|
case PromotionPiece.Queen => "=Q"
|
||||||
case PromotionPiece.Rook => "=R"
|
case PromotionPiece.Rook => "=R"
|
||||||
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
|
|||||||
case PieceType.Rook => s"R$capStr$dest"
|
case PieceType.Rook => s"R$capStr$dest"
|
||||||
case PieceType.Queen => s"Q$capStr$dest"
|
case PieceType.Queen => s"Q$capStr$dest"
|
||||||
case PieceType.King => s"K$capStr$dest"
|
case PieceType.King => s"K$capStr$dest"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules
|
|||||||
|
|
||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
/** A parsed PGN game containing headers and the resolved move list. */
|
||||||
case class PgnGame(
|
case class PgnGame(
|
||||||
headers: Map[String, String],
|
headers: Map[String, String],
|
||||||
moves: List[Move]
|
moves: List[Move],
|
||||||
)
|
)
|
||||||
|
|
||||||
object PgnParser extends GameContextImport:
|
object PgnParser extends GameContextImport:
|
||||||
|
|
||||||
/** Strictly validate a PGN text.
|
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||||
* 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.
|
||||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
*/
|
||||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
val lines = pgn.split("\n").map(_.trim)
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||||
val headers = parseHeaders(headerLines)
|
val headers = parseHeaders(headerLines)
|
||||||
val moveText = rest.mkString(" ")
|
val moveText = rest.mkString(" ")
|
||||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||||
|
|
||||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
|
||||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
|
||||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
* issue.
|
||||||
|
*/
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
validatePgn(input).flatMap { game =>
|
validatePgn(input).flatMap { game =>
|
||||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
/** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
|
||||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
* are silently skipped.
|
||||||
|
*/
|
||||||
def parsePgn(pgn: String): Option[PgnGame] =
|
def parsePgn(pgn: String): Option[PgnGame] =
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
val lines = pgn.split("\n").map(_.trim)
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||||
val headers = parseHeaders(headerLines)
|
val headers = parseHeaders(headerLines)
|
||||||
val moveText = rest.mkString(" ")
|
val moveText = rest.mkString(" ")
|
||||||
val moves = parseMovesText(moveText)
|
val moves = parseMovesText(moveText)
|
||||||
Some(PgnGame(headers, moves))
|
Some(PgnGame(headers, moves))
|
||||||
|
|
||||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||||
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
|
|||||||
private def parseMovesText(moveText: String): List[Move] =
|
private def parseMovesText(moveText: String): List[Move] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
val (_, _, moves) = tokens.foldLeft(
|
val (_, _, moves) = tokens.foldLeft(
|
||||||
(GameContext.initial, Color.White, List.empty[Move])
|
(GameContext.initial, Color.White, List.empty[Move]),
|
||||||
):
|
):
|
||||||
case (state @ (ctx, color, acc), token) =>
|
case (state @ (ctx, color, acc), token) =>
|
||||||
if isMoveNumberOrResult(token) then state
|
if isMoveNumberOrResult(token) then state
|
||||||
else
|
else
|
||||||
parseAlgebraicMove(token, ctx, color) match
|
parseAlgebraicMove(token, ctx, color) match
|
||||||
case None => state
|
case None => state
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||||
(nextCtx, color.opposite, acc :+ move)
|
(nextCtx, color.opposite, acc :+ move)
|
||||||
moves
|
moves
|
||||||
|
|
||||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||||
private def isMoveNumberOrResult(token: String): Boolean =
|
private def isMoveNumberOrResult(token: String): Boolean =
|
||||||
token.matches("""\d+\.""") ||
|
token.matches("""\d+\.""") ||
|
||||||
token == "*" ||
|
token == "*" ||
|
||||||
token == "1-0" ||
|
token == "1-0" ||
|
||||||
token == "0-1" ||
|
token == "0-1" ||
|
||||||
token == "1/2-1/2"
|
token == "1/2-1/2"
|
||||||
|
|
||||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||||
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
|
|||||||
if clean.length < 2 then None
|
if clean.length < 2 then None
|
||||||
else
|
else
|
||||||
val destStr = clean.takeRight(2)
|
val destStr = clean.takeRight(2)
|
||||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
Square
|
||||||
val disambig = clean.dropRight(2)
|
.fromAlgebraic(destStr)
|
||||||
|
.flatMap: toSquare =>
|
||||||
|
val disambig = clean.dropRight(2)
|
||||||
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
val requiredPieceType: Option[PieceType] =
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||||
else Some(PieceType.Pawn)
|
else Some(PieceType.Pawn)
|
||||||
|
|
||||||
val hint =
|
val hint =
|
||||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||||
else disambig
|
else disambig
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
val promotion = extractPromotion(notation)
|
||||||
|
|
||||||
// Get all legal moves for this color that reach toSquare
|
// Get all legal moves for this color that reach toSquare
|
||||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||||
val candidates = allLegal.filter { move =>
|
val candidates = allLegal.filter { move =>
|
||||||
move.to == toSquare &&
|
move.to == toSquare &&
|
||||||
ctx.board.pieceAt(move.from).exists(p =>
|
ctx.board
|
||||||
p.color == color &&
|
.pieceAt(move.from)
|
||||||
requiredPieceType.forall(_ == p.pieceType)
|
.exists(p =>
|
||||||
) &&
|
p.color == color &&
|
||||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
requiredPieceType.forall(_ == p.pieceType),
|
||||||
promotionMatches(move, promotion)
|
) &&
|
||||||
}
|
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||||
|
promotionMatches(move, promotion)
|
||||||
|
}
|
||||||
|
|
||||||
candidates.headOption
|
candidates.headOption
|
||||||
|
|
||||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||||
hint.forall(c =>
|
hint.forall(c =>
|
||||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||||
else true
|
else true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||||
promotion match
|
promotion match
|
||||||
case None => move.moveType match
|
case None =>
|
||||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
move.moveType match
|
||||||
case _ => false
|
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||||
|
case _ => false
|
||||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||||
|
|
||||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||||
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
|
|||||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
tokens
|
||||||
case (acc, token) =>
|
.foldLeft(
|
||||||
|
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
|
||||||
|
) { case (acc, token) =>
|
||||||
acc.flatMap { case (ctx, color, moves) =>
|
acc.flatMap { case (ctx, color, moves) =>
|
||||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||||
else
|
else
|
||||||
parseAlgebraicMove(token, ctx, color) match
|
parseAlgebraicMove(token, ctx, color) match
|
||||||
case None => Left(s"Illegal or impossible move: '$token'")
|
case None => Left(s"Illegal or impossible move: '$token'")
|
||||||
case Some(move) =>
|
case Some(move) =>
|
||||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||||
Right((nextCtx, color.opposite, moves :+ move))
|
Right((nextCtx, color.opposite, moves :+ move))
|
||||||
}
|
}
|
||||||
}.map(_._3)
|
}
|
||||||
|
.map(_._3)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io
|
package de.nowchess.io
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
import java.nio.file.{Files, Paths}
|
import java.nio.file.{Files, Paths}
|
||||||
@@ -15,37 +15,35 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||||
try
|
try
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
assert(Files.exists(tmpFile))
|
assert(Files.exists(tmpFile))
|
||||||
assert(Files.size(tmpFile) > 0)
|
assert(Files.size(tmpFile) > 0)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: reads JSON file successfully") {
|
test("loadGameFromFile: reads JSON file successfully") {
|
||||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||||
try
|
try
|
||||||
val originalContext = GameContext.initial
|
val originalContext = GameContext.initial
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
|
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
|
||||||
|
|
||||||
// Load
|
// Load
|
||||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
val loaded = result.getOrElse(GameContext.initial)
|
val loaded = result.getOrElse(GameContext.initial)
|
||||||
assert(loaded == originalContext)
|
assert(loaded == originalContext)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: returns error on missing file") {
|
test("loadGameFromFile: returns error on missing file") {
|
||||||
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
|
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
|
||||||
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
||||||
|
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +55,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
.withMove(move1)
|
.withMove(move1)
|
||||||
.withMove(move2)
|
.withMove(move2)
|
||||||
|
|
||||||
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||||
assert(saveResult.isRight)
|
assert(saveResult.isRight)
|
||||||
|
|
||||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
assert(loadResult.isRight)
|
assert(loadResult.isRight)
|
||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 2)
|
assert(loaded.moves.length == 2)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("saveGameToFile: overwrites existing file") {
|
test("saveGameToFile: overwrites existing file") {
|
||||||
@@ -76,18 +73,17 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val context1 = GameContext.initial
|
val context1 = GameContext.initial
|
||||||
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
|
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
|
||||||
val size1 = Files.size(tmpFile)
|
val size1 = Files.size(tmpFile)
|
||||||
|
|
||||||
// Write second file (should overwrite)
|
// Write second file (should overwrite)
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
val context2 = GameContext.initial.withMove(move)
|
val context2 = GameContext.initial.withMove(move)
|
||||||
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
||||||
|
|
||||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
assert(loadResult.isRight)
|
assert(loadResult.isRight)
|
||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 1)
|
assert(loaded.moves.length == 1)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: handles invalid JSON in file") {
|
test("loadGameFromFile: handles invalid JSON in file") {
|
||||||
@@ -95,10 +91,9 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
try
|
try
|
||||||
Files.write(tmpFile, "{ invalid json}".getBytes())
|
Files.write(tmpFile, "{ invalid json}".getBytes())
|
||||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("round-trip: save and load preserves game state") {
|
test("round-trip: save and load preserves game state") {
|
||||||
@@ -110,16 +105,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
.withMove(move1)
|
.withMove(move1)
|
||||||
.withMove(move2)
|
.withMove(move2)
|
||||||
.withHalfMoveClock(3)
|
.withHalfMoveClock(3)
|
||||||
|
|
||||||
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
|
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
|
||||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
assert(loadResult.isRight)
|
assert(loadResult.isRight)
|
||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 2)
|
assert(loaded.moves.length == 2)
|
||||||
assert(loaded.halfMoveClock == 3)
|
assert(loaded.halfMoveClock == 3)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("saveGameToFile: handles exporter that throws exception") {
|
test("saveGameToFile: handles exporter that throws exception") {
|
||||||
@@ -127,13 +121,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
try
|
try
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val faultyExporter = new GameContextExport {
|
val faultyExporter = new GameContextExport {
|
||||||
def exportGameContext(c: GameContext): String =
|
def exportGameContext(c: GameContext): String =
|
||||||
throw new RuntimeException("Export failed")
|
throw new RuntimeException("Export failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
assert(result.left.toOption.get.contains("Failed to save file"))
|
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class FenExporterTest extends AnyFunSuite with Matchers:
|
class FenExporterTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
private def context(
|
private def context(
|
||||||
piecePlacement: String,
|
piecePlacement: String,
|
||||||
turn: Color,
|
turn: Color,
|
||||||
castlingRights: CastlingRights,
|
castlingRights: CastlingRights,
|
||||||
enPassantSquare: Option[Square],
|
enPassantSquare: Option[Square],
|
||||||
halfMoveClock: Int,
|
halfMoveClock: Int,
|
||||||
moveCount: Int
|
moveCount: Int,
|
||||||
): GameContext =
|
): GameContext =
|
||||||
val board = FenParser.parseBoard(piecePlacement).getOrElse(
|
val board = FenParser
|
||||||
fail(s"Invalid test board FEN: $piecePlacement")
|
.parseBoard(piecePlacement)
|
||||||
)
|
.getOrElse(
|
||||||
|
fail(s"Invalid test board FEN: $piecePlacement"),
|
||||||
|
)
|
||||||
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
||||||
GameContext(
|
GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassantSquare,
|
enPassantSquare = enPassantSquare,
|
||||||
halfMoveClock = halfMoveClock,
|
halfMoveClock = halfMoveClock,
|
||||||
moves = List.fill(moveCount)(dummyMove)
|
moves = List.fill(moveCount)(dummyMove),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("exportGameContextToFen handles initial and typical developed position"):
|
test("exportGameContextToFen handles initial and typical developed position"):
|
||||||
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moveCount = 0
|
moveCount = 0,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(gameContext) shouldBe
|
FenExporter.gameContextToFen(gameContext) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||||
@@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.None,
|
castlingRights = CastlingRights.None,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moveCount = 0
|
moveCount = 0,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(noCastling) shouldBe
|
FenExporter.gameContextToFen(noCastling) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||||
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
),
|
),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 5,
|
halfMoveClock = 5,
|
||||||
moveCount = 4
|
moveCount = 4,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(partialCastling) shouldBe
|
FenExporter.gameContextToFen(partialCastling) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
||||||
@@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
||||||
halfMoveClock = 2,
|
halfMoveClock = 2,
|
||||||
moveCount = 4
|
moveCount = 4,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
||||||
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||||
@@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 42,
|
halfMoveClock = 42,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
val fen = FenExporter.gameContextToFen(gameContext)
|
val fen = FenExporter.gameContextToFen(gameContext)
|
||||||
FenParser.parseFen(fen) match
|
FenParser.parseFen(fen) match
|
||||||
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
|
|
||||||
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -8,34 +8,52 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseBoard parses canonical positions and supports round-trip"):
|
test("parseBoard parses canonical positions and supports round-trip"):
|
||||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||||
val empty = "8/8/8/8/8/8/8/8"
|
val empty = "8/8/8/8/8/8/8/8"
|
||||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||||
|
|
||||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
|
||||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
Some(Piece.WhitePawn),
|
||||||
|
)
|
||||||
|
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(
|
||||||
|
Some(Piece.BlackKing),
|
||||||
|
)
|
||||||
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||||
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(
|
||||||
|
Some(Piece.BlackKing),
|
||||||
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||||
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
test("parseFen parses full state for common valid inputs"):
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
ctx.turn shouldBe Color.White
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
.fold(
|
||||||
ctx.enPassantSquare shouldBe None
|
_ => fail(),
|
||||||
ctx.halfMoveClock shouldBe 0
|
ctx =>
|
||||||
)
|
ctx.turn shouldBe Color.White
|
||||||
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
|
ctx.enPassantSquare shouldBe None
|
||||||
|
ctx.halfMoveClock shouldBe 0,
|
||||||
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
ctx.turn shouldBe Color.Black
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
.fold(
|
||||||
)
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
|
ctx.turn shouldBe Color.Black
|
||||||
|
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||||
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
.fold(
|
||||||
)
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
test("parseFen rejects invalid color and castling tokens"):
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseBoard parses canonical positions and supports round-trip"):
|
test("parseBoard parses canonical positions and supports round-trip"):
|
||||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||||
val empty = "8/8/8/8/8/8/8/8"
|
val empty = "8/8/8/8/8/8/8/8"
|
||||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||||
|
|
||||||
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||||
@@ -20,22 +20,34 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
|||||||
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
test("parseFen parses full state for common valid inputs"):
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.turn shouldBe Color.White
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
.fold(
|
||||||
ctx.enPassantSquare shouldBe None
|
_ => fail(),
|
||||||
ctx.halfMoveClock shouldBe 0
|
ctx =>
|
||||||
)
|
ctx.turn shouldBe Color.White
|
||||||
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
|
ctx.enPassantSquare shouldBe None
|
||||||
|
ctx.halfMoveClock shouldBe 0,
|
||||||
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.turn shouldBe Color.Black
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
.fold(
|
||||||
)
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
|
ctx.turn shouldBe Color.Black
|
||||||
|
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||||
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
.fold(
|
||||||
)
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
test("parseFen rejects invalid color and castling tokens"):
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseBoard parses canonical positions and supports round-trip"):
|
test("parseBoard parses canonical positions and supports round-trip"):
|
||||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||||
val empty = "8/8/8/8/8/8/8/8"
|
val empty = "8/8/8/8/8/8/8/8"
|
||||||
val partial = "8/8/4k3/8/4K3/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.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||||
@@ -20,22 +20,34 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
|||||||
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
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 =>
|
FenParser
|
||||||
ctx.turn shouldBe Color.White
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
.fold(
|
||||||
ctx.enPassantSquare shouldBe None
|
_ => fail(),
|
||||||
ctx.halfMoveClock shouldBe 0
|
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 =>
|
FenParser
|
||||||
ctx.turn shouldBe Color.Black
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
.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 =>
|
FenParser
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
.fold(
|
||||||
)
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
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 x KQkq - 0 1").isLeft shouldBe true
|
||||||
@@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
|||||||
FenParser.parseBoard("8p/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("7/8/8/8/8/8/8/8") shouldBe None
|
||||||
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
(PromotionPiece.Queen, "queen"),
|
(PromotionPiece.Queen, "queen"),
|
||||||
(PromotionPiece.Rook, "rook"),
|
(PromotionPiece.Rook, "rook"),
|
||||||
(PromotionPiece.Bishop, "bishop"),
|
(PromotionPiece.Bishop, "bishop"),
|
||||||
(PromotionPiece.Knight, "knight")
|
(PromotionPiece.Knight, "knight"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((piece, expectedName) <- promotions) do
|
for (piece, expectedName) <- promotions do
|
||||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||||
// Empty boards can cause issues in PgnExporter, using initial
|
// Empty boards can cause issues in PgnExporter, using initial
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include (s""""$expectedName"""")
|
json should include(s""""$expectedName"""")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export normal non-capture move") {
|
test("export normal non-capture move") {
|
||||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"normal\"")
|
json should include("\"normal\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export normal capture move manually") {
|
test("export normal capture move manually") {
|
||||||
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"normal\"")
|
json should include("\"normal\"")
|
||||||
json should include ("\"isCapture\": true")
|
json should include("\"isCapture\": true")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export all move type categories") {
|
test("export all move type categories") {
|
||||||
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
|
||||||
json should include ("\"moves\"")
|
json should include("\"moves\"")
|
||||||
json should include ("\"from\"")
|
json should include("\"from\"")
|
||||||
json should include ("\"to\"")
|
json should include("\"to\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export castle queenside move") {
|
test("export castle queenside move") {
|
||||||
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"castleQueenside\"")
|
json should include("\"castleQueenside\"")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export castle kingside move") {
|
test("export castle kingside move") {
|
||||||
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"castleKingside\"")
|
json should include("\"castleKingside\"")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export en passant move manually") {
|
test("export en passant move manually") {
|
||||||
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"enPassant\"")
|
json should include("\"enPassant\"")
|
||||||
json should include ("\"isCapture\": true")
|
json should include("\"isCapture\": true")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -10,8 +10,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGameContext: exports initial position") {
|
test("exportGameContext: exports initial position") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"metadata\"")
|
json should include("\"metadata\"")
|
||||||
json should include("\"gameState\"")
|
json should include("\"gameState\"")
|
||||||
json should include("\"moveHistory\"")
|
json should include("\"moveHistory\"")
|
||||||
@@ -21,8 +21,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGameContext: includes board pieces") {
|
test("exportGameContext: includes board pieces") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"a1\"")
|
json should include("\"a1\"")
|
||||||
json should include("\"Rook\"")
|
json should include("\"Rook\"")
|
||||||
json should include("\"White\"")
|
json should include("\"White\"")
|
||||||
@@ -30,24 +30,24 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGameContext: includes turn information") {
|
test("exportGameContext: includes turn information") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"turn\": \"White\"")
|
json should include("\"turn\": \"White\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: includes castling rights") {
|
test("exportGameContext: includes castling rights") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"whiteKingSide\": true")
|
json should include("\"whiteKingSide\": true")
|
||||||
json should include("\"whiteQueenSide\": true")
|
json should include("\"whiteQueenSide\": true")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports with moves") {
|
test("exportGameContext: exports with moves") {
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
val context = GameContext.initial.withMove(move)
|
val context = GameContext.initial.withMove(move)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"moves\"")
|
json should include("\"moves\"")
|
||||||
json should include("\"from\"")
|
json should include("\"from\"")
|
||||||
json should include("\"to\"")
|
json should include("\"to\"")
|
||||||
@@ -57,8 +57,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGameContext: valid JSON structure") {
|
test("exportGameContext: valid JSON structure") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should startWith("{")
|
json should startWith("{")
|
||||||
json should endWith("}")
|
json should endWith("}")
|
||||||
json should include("\"metadata\": {")
|
json should include("\"metadata\": {")
|
||||||
@@ -67,47 +67,47 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGameContext: empty move history for initial position") {
|
test("exportGameContext: empty move history for initial position") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"moves\": []")
|
json should include("\"moves\": []")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports en passant square") {
|
test("exportGameContext: exports en passant square") {
|
||||||
val epSquare = Some(Square(File.E, Rank.R3))
|
val epSquare = Some(Square(File.E, Rank.R3))
|
||||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"enPassantSquare\": \"e3\"")
|
json should include("\"enPassantSquare\": \"e3\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports null en passant square") {
|
test("exportGameContext: exports null en passant square") {
|
||||||
val context = GameContext.initial.copy(enPassantSquare = None)
|
val context = GameContext.initial.copy(enPassantSquare = None)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"enPassantSquare\": null")
|
json should include("\"enPassantSquare\": null")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports different move destinations") {
|
test("exportGameContext: exports different move destinations") {
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
val context = GameContext.initial.withMove(move)
|
val context = GameContext.initial.withMove(move)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"moves\"")
|
json should include("\"moves\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports empty board") {
|
test("exportGameContext: exports empty board") {
|
||||||
val emptyBoard = Board(Map.empty)
|
val emptyBoard = Board(Map.empty)
|
||||||
val context = GameContext.initial.copy(board = emptyBoard)
|
val context = GameContext.initial.copy(board = emptyBoard)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"board\": []")
|
json should include("\"board\": []")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exportGameContext: exports all castling rights disabled") {
|
test("exportGameContext: exports all castling rights disabled") {
|
||||||
val noCastling = CastlingRights(false, false, false, false)
|
val noCastling = CastlingRights(false, false, false, false)
|
||||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
|
|
||||||
json should include("\"whiteKingSide\": false")
|
json should include("\"whiteKingSide\": false")
|
||||||
json should include("\"whiteQueenSide\": false")
|
json should include("\"whiteQueenSide\": false")
|
||||||
json should include("\"blackKingSide\": false")
|
json should include("\"blackKingSide\": false")
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
|||||||
Some("White"),
|
Some("White"),
|
||||||
Some(JsonCastlingRights()),
|
Some(JsonCastlingRights()),
|
||||||
Some("e3"),
|
Some("e3"),
|
||||||
Some(5)
|
Some(5),
|
||||||
)
|
)
|
||||||
assert(gs.board.contains(Nil))
|
assert(gs.board.contains(Nil))
|
||||||
assert(gs.halfMoveClock.contains(5))
|
assert(gs.halfMoveClock.contains(5))
|
||||||
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
|||||||
Some(""),
|
Some(""),
|
||||||
Some(Nil),
|
Some(Nil),
|
||||||
Some(JsonCapturedPieces()),
|
Some(JsonCapturedPieces()),
|
||||||
Some("2026-04-08T00:00:00Z")
|
Some("2026-04-08T00:00:00Z"),
|
||||||
)
|
)
|
||||||
assert(record.metadata.nonEmpty)
|
assert(record.metadata.nonEmpty)
|
||||||
assert(record.timestamp.nonEmpty)
|
assert(record.timestamp.nonEmpty)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("parse invalid turn color returns error") {
|
test("parse invalid turn color returns error") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "Invalid", "board": []},
|
"gameState": {"turn": "Invalid", "board": []},
|
||||||
"moves": []
|
"moves": []
|
||||||
@@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse invalid piece type filters it out") {
|
test("parse invalid piece type filters it out") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
@@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse invalid color in board filters piece") {
|
test("parse invalid color in board filters piece") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
@@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse with missing turn uses default") {
|
test("parse with missing turn uses default") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"board": []},
|
"gameState": {"board": []},
|
||||||
"moves": []
|
"moves": []
|
||||||
@@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse with missing board uses empty") {
|
test("parse with missing board uses empty") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White"},
|
"gameState": {"turn": "White"},
|
||||||
"moves": []
|
"moves": []
|
||||||
@@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse with missing moves uses empty list") {
|
test("parse with missing moves uses empty list") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White", "board": []}
|
"gameState": {"turn": "White", "board": []}
|
||||||
}"""
|
}"""
|
||||||
@@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse invalid square in board filters it") {
|
test("parse invalid square in board filters it") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
@@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse all valid piece types") {
|
test("parse all valid piece types") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
@@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
val ctx = result.toOption.get
|
val ctx = result.toOption.get
|
||||||
assert(ctx.board.pieces.size == 6)
|
assert(ctx.board.pieces.size == 6)
|
||||||
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
|
assert(
|
||||||
|
ctx.board
|
||||||
|
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||||
|
.get
|
||||||
|
.pieceType == PieceType.Pawn,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse with all castling rights false") {
|
test("parse with all castling rights false") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parse completely invalid JSON returns error") {
|
test("parse completely invalid JSON returns error") {
|
||||||
val invalidJson = "{ this is not valid json at all }"
|
val invalidJson = "{ this is not valid json at all }"
|
||||||
val result = JsonParser.importGameContext(invalidJson)
|
val result = JsonParser.importGameContext(invalidJson)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||||
}
|
}
|
||||||
@@ -26,26 +26,26 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parse malformed JSON object returns error") {
|
test("parse malformed JSON object returns error") {
|
||||||
val malformed = """{"metadata": {"unclosed": """
|
val malformed = """{"metadata": {"unclosed": """
|
||||||
val result = JsonParser.importGameContext(malformed)
|
val result = JsonParser.importGameContext(malformed)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse invalid JSON array returns error") {
|
test("parse invalid JSON array returns error") {
|
||||||
val invalidArray = "[1, 2, 3"
|
val invalidArray = "[1, 2, 3"
|
||||||
val result = JsonParser.importGameContext(invalidArray)
|
val result = JsonParser.importGameContext(invalidArray)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse JSON with missing required fields") {
|
test("parse JSON with missing required fields") {
|
||||||
val json = """{"metadata": {}}"""
|
val json = """{"metadata": {}}"""
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
// Should still succeed because all fields have defaults
|
// Should still succeed because all fields have defaults
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse valid JSON with invalid turn falls back to default") {
|
test("parse valid JSON with invalid turn falls back to default") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": []
|
"moves": []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
|
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("parse all move type variations") {
|
test("parse all move type variations") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {"event": "Game", "result": "*"},
|
"metadata": {"event": "Game", "result": "*"},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": [
|
"moves": [
|
||||||
@@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse invalid move type defaults to None") {
|
test("parse invalid move type defaults to None") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {"event": "Game"},
|
"metadata": {"event": "Game"},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||||
@@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse promotion with default piece") {
|
test("parse promotion with default piece") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||||
@@ -56,7 +56,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse move with missing from/to skips it") {
|
test("parse move with missing from/to skips it") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||||
@@ -69,26 +69,26 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("parse with invalid JSON returns error") {
|
test("parse with invalid JSON returns error") {
|
||||||
val json = """{"invalid json"""
|
val json = """{"invalid json"""
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse normal move with isCapture true") {
|
test("parse normal move with isCapture true") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {"turn": "White", "board": []},
|
"gameState": {"turn": "White", "board": []},
|
||||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||||
}"""
|
}"""
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
val ctx = result.toOption.get
|
val ctx = result.toOption.get
|
||||||
val move = ctx.moves.head
|
val move = ctx.moves.head
|
||||||
assert(move.moveType == MoveType.Normal(true))
|
assert(move.moveType == MoveType.Normal(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse board with invalid pieces filters them") {
|
test("parse board with invalid pieces filters them") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"turn": "White",
|
"turn": "White",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
|
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -9,39 +9,39 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class JsonParserSuite extends AnyFunSuite with Matchers:
|
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("importGameContext: parses valid JSON") {
|
test("importGameContext: parses valid JSON") {
|
||||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: restores board state") {
|
test("importGameContext: restores board state") {
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result == Right(context))
|
assert(result == Right(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: restores turn") {
|
test("importGameContext: restores turn") {
|
||||||
val context = GameContext.initial.withTurn(Color.Black)
|
val context = GameContext.initial.withTurn(Color.Black)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.turn) == Right(Color.Black))
|
assert(result.map(_.turn) == Right(Color.Black))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: restores moves") {
|
test("importGameContext: restores moves") {
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
val context = GameContext.initial.withMove(move)
|
val context = GameContext.initial.withMove(move)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.moves.length) == Right(1))
|
assert(result.map(_.moves.length) == Right(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles empty board") {
|
test("importGameContext: handles empty board") {
|
||||||
val json = """{
|
val json = """{
|
||||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||||
"gameState": {
|
"gameState": {
|
||||||
"board": [],
|
"board": [],
|
||||||
@@ -56,30 +56,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
|||||||
"timestamp": "2026-04-06T00:00:00Z"
|
"timestamp": "2026-04-06T00:00:00Z"
|
||||||
}"""
|
}"""
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: returns error on invalid JSON") {
|
test("importGameContext: returns error on invalid JSON") {
|
||||||
val result = JsonParser.importGameContext("not valid json {{{")
|
val result = JsonParser.importGameContext("not valid json {{{")
|
||||||
|
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles missing fields with defaults") {
|
test("importGameContext: handles missing fields with defaults") {
|
||||||
val json = "{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
val json =
|
||||||
|
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles castling rights") {
|
test("importGameContext: handles castling rights") {
|
||||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
|||||||
.withMove(move2)
|
.withMove(move2)
|
||||||
.withTurn(Color.White)
|
.withTurn(Color.White)
|
||||||
|
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val restored = JsonParser.importGameContext(json)
|
val restored = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(restored.map(_.moves.length) == Right(2))
|
assert(restored.map(_.moves.length) == Right(2))
|
||||||
@@ -100,55 +101,55 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("importGameContext: handles half-move clock") {
|
test("importGameContext: handles half-move clock") {
|
||||||
val context = GameContext.initial.withHalfMoveClock(5)
|
val context = GameContext.initial.withHalfMoveClock(5)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.halfMoveClock) == Right(5))
|
assert(result.map(_.halfMoveClock) == Right(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: parses en passant square") {
|
test("importGameContext: parses en passant square") {
|
||||||
// Create a context with en passant square
|
// Create a context with en passant square
|
||||||
val epSquare = Some(Square(File.E, Rank.R3))
|
val epSquare = Some(Square(File.E, Rank.R3))
|
||||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles black turn") {
|
test("importGameContext: handles black turn") {
|
||||||
val context = GameContext.initial.withTurn(Color.Black)
|
val context = GameContext.initial.withTurn(Color.Black)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.turn) == Right(Color.Black))
|
assert(result.map(_.turn) == Right(Color.Black))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: preserves basic moves in JSON round-trip") {
|
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||||
// Use simple move without explicit moveType to let system handle it
|
// Use simple move without explicit moveType to let system handle it
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
val context = GameContext.initial.withMove(move)
|
val context = GameContext.initial.withMove(move)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
assert(result.map(_.moves.length) == Right(1))
|
assert(result.map(_.moves.length) == Right(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles all castling rights disabled") {
|
test("importGameContext: handles all castling rights disabled") {
|
||||||
val noCastling = CastlingRights(false, false, false, false)
|
val noCastling = CastlingRights(false, false, false, false)
|
||||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles mixed castling rights") {
|
test("importGameContext: handles mixed castling rights") {
|
||||||
val mixed = CastlingRights(true, false, false, true)
|
val mixed = CastlingRights(true, false, false, true)
|
||||||
val context = GameContext.initial.withCastlingRights(mixed)
|
val context = GameContext.initial.withCastlingRights(mixed)
|
||||||
val json = JsonExporter.exportGameContext(context)
|
val json = JsonExporter.exportGameContext(context)
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.map(_.castlingRights) == Right(mixed))
|
assert(result.map(_.castlingRights) == Right(mixed))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class PgnExporterTest extends AnyFunSuite with Matchers:
|
class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("exportGame renders headers and basic move text"):
|
test("exportGame renders headers and basic move text"):
|
||||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||||
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
|
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
|
||||||
emptyPgn.contains("[Event \"Test\"]") shouldBe true
|
emptyPgn.contains("[Event \"Test\"]") shouldBe true
|
||||||
emptyPgn.contains("[White \"A\"]") shouldBe true
|
emptyPgn.contains("[White \"A\"]") shouldBe true
|
||||||
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
||||||
|
|
||||||
test("exportGame renders castling grouping and result markers"):
|
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(
|
||||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
|
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(
|
val seq = List(
|
||||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
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.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
||||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
|
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
|
||||||
)
|
)
|
||||||
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
||||||
grouped should include("1. e4 c5")
|
grouped should include("1. e4 c5")
|
||||||
@@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("exportGame handles promotion suffixes and normal move formatting"):
|
test("exportGame handles promotion suffixes and normal move formatting"):
|
||||||
List(
|
List(
|
||||||
PromotionPiece.Queen -> "=Q",
|
PromotionPiece.Queen -> "=Q",
|
||||||
PromotionPiece.Rook -> "=R",
|
PromotionPiece.Rook -> "=R",
|
||||||
PromotionPiece.Bishop -> "=B",
|
PromotionPiece.Bishop -> "=B",
|
||||||
PromotionPiece.Knight -> "=N"
|
PromotionPiece.Knight -> "=N",
|
||||||
).foreach { (piece, suffix) =>
|
).foreach { (piece, suffix) =>
|
||||||
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
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")
|
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())))
|
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 include("e4")
|
||||||
normal should not include "="
|
normal should not include "="
|
||||||
|
|
||||||
test("exportGameContext preserves moves and default headers"):
|
test("exportGameContext preserves moves and default headers"):
|
||||||
val moves = List(
|
val moves = List(
|
||||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
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())
|
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
|
||||||
)
|
)
|
||||||
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
||||||
withMoves.contains("e4") shouldBe true
|
withMoves.contains("e4") shouldBe true
|
||||||
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
Move(sq("c7"), sq("c6")),
|
Move(sq("c7"), sq("c6")),
|
||||||
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||||
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||||
@@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
pgn should include("Kxe2")
|
pgn should include("Kxe2")
|
||||||
|
|
||||||
test("exportGame emits en-passant and promotion capture notation"):
|
test("exportGame emits en-passant and promotion capture notation"):
|
||||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||||
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
||||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
|
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||||
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
||||||
|
|
||||||
pgn should include("exd3")
|
pgn should include("exd3")
|
||||||
pgn should include("exf8=Q")
|
pgn should include("exf8=Q")
|
||||||
pawnCapturePgn should include("exd3")
|
pawnCapturePgn should include("exd3")
|
||||||
quietPromotionPgn should include("e8=Q")
|
quietPromotionPgn should include("e8=Q")
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
class PgnParserTest extends AnyFunSuite with Matchers:
|
class PgnParserTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
|
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
|
||||||
val headerOnly = """[Event "Test Game"]
|
val headerOnly = """[Event "Test Game"]
|
||||||
[White "Alice"]
|
[White "Alice"]
|
||||||
[Black "Bob"]
|
[Black "Bob"]
|
||||||
[Result "1-0"]"""
|
[Result "1-0"]"""
|
||||||
@@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
capture.map(_.moves.length) shouldBe Some(3)
|
capture.map(_.moves.length) shouldBe Some(3)
|
||||||
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||||
|
|
||||||
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
|
val whiteKs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
|
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
whiteKs.moveType shouldBe MoveType.CastleKingside
|
whiteKs.moveType shouldBe MoveType.CastleKingside
|
||||||
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
||||||
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
||||||
|
|
||||||
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
|
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
|
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.moveType shouldBe MoveType.CastleQueenside
|
||||||
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
||||||
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
||||||
|
|
||||||
val blackKs = PgnParser.parsePgn("""[Event "Test"]
|
val blackKs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
|
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
blackKs.moveType shouldBe MoveType.CastleKingside
|
blackKs.moveType shouldBe MoveType.CastleKingside
|
||||||
blackKs.from shouldBe Square(File.E, Rank.R8)
|
blackKs.from shouldBe Square(File.E, Rank.R8)
|
||||||
|
|
||||||
val blackQs = PgnParser.parsePgn("""[Event "Test"]
|
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
|
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.moveType shouldBe MoveType.CastleQueenside
|
||||||
blackQs.from shouldBe Square(File.E, Rank.R8)
|
blackQs.from shouldBe Square(File.E, Rank.R8)
|
||||||
blackQs.to shouldBe Square(File.C, Rank.R8)
|
blackQs.to shouldBe Square(File.C, Rank.R8)
|
||||||
|
|
||||||
PgnParser.parsePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
|
1. e4 e5 1-0""")
|
||||||
PgnParser.parsePgn("""[Event "Test"]
|
.map(_.moves.length) shouldBe Some(2)
|
||||||
|
PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
|
1. e4 INVALID e5""")
|
||||||
|
.map(_.moves.length) shouldBe Some(2)
|
||||||
|
|
||||||
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
|
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
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(
|
val rookPieces: Map[Square, Piece] = Map(
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.H, 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.R1) -> Piece(Color.White, PieceType.King),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||||
)
|
)
|
||||||
val rankPieces: Map[Square, Piece] = Map(
|
val rankPieces: Map[Square, Piece] = Map(
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.A, Rank.R4) -> 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.R1) -> Piece(Color.White, PieceType.King),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, 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
|
||||||
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
.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 kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||||
king.isDefined shouldBe true
|
king.isDefined shouldBe true
|
||||||
king.get.from shouldBe Square(File.E, Rank.R1)
|
king.get.from shouldBe Square(File.E, Rank.R1)
|
||||||
king.get.to shouldBe Square(File.E, Rank.R2)
|
king.get.to shouldBe Square(File.E, Rank.R2)
|
||||||
|
|
||||||
test("parseAlgebraicMove handles all promotion targets"):
|
test("parseAlgebraicMove handles all promotion targets"):
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
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
|
||||||
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||||
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
.get
|
||||||
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
.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"):
|
test("importGameContext accepts valid and empty PGN"):
|
||||||
val pgn = """[Event "Test"]
|
val pgn = """[Event "Test"]
|
||||||
@@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
||||||
|
|
||||||
test("parseAlgebraicMove rejects notation with invalid promotion piece"):
|
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 board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||||
val context = GameContext.initial.withBoard(board)
|
val context = GameContext.initial.withBoard(board)
|
||||||
|
|
||||||
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
||||||
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||||
|
|
||||||
parsed.map(_.moves.size) shouldBe Some(2)
|
parsed.map(_.moves.size) shouldBe Some(2)
|
||||||
|
|
||||||
|
|||||||
@@ -37,17 +37,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
||||||
|
|
||||||
test("validatePgn rejects impossible illegal and garbage tokens"):
|
test("validatePgn rejects impossible illegal and garbage tokens"):
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. Qd4
|
1. Qd4
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
|
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. O-O
|
1. O-O
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
|
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 GARBAGE e5
|
1. e4 GARBAGE e5
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
test("validatePgn accepts empty move text and minimal valid header"):
|
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 \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
||||||
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext
|
|||||||
import de.nowchess.api.board.Square
|
import de.nowchess.api.board.Square
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
/** Extension point for chess rule variants (standard, Chess960, etc.).
|
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
|
||||||
* All rule queries are stateless: given a GameContext, return the answer.
|
* GameContext, return the answer.
|
||||||
*/
|
*/
|
||||||
trait RuleSet:
|
trait RuleSet:
|
||||||
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
||||||
def candidateMoves(context: GameContext)(square: Square): List[Move]
|
def candidateMoves(context: GameContext)(square: Square): List[Move]
|
||||||
@@ -32,8 +32,7 @@ trait RuleSet:
|
|||||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean
|
def isFiftyMoveRule(context: GameContext): Boolean
|
||||||
|
|
||||||
/** Apply a legal move to produce the next game context.
|
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
|
||||||
* Handles all special move types: castling, en passant, promotion.
|
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||||
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
*/
|
||||||
*/
|
|
||||||
def applyMove(context: GameContext)(move: Move): GameContext
|
def applyMove(context: GameContext)(move: Move): GameContext
|
||||||
|
|||||||
@@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet
|
|||||||
|
|
||||||
import scala.annotation.tailrec
|
import scala.annotation.tailrec
|
||||||
|
|
||||||
/** Standard chess rules implementation.
|
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
|
||||||
* Handles move generation, validation, check/checkmate/stalemate detection.
|
*/
|
||||||
*/
|
|
||||||
object DefaultRules extends RuleSet:
|
object DefaultRules extends RuleSet:
|
||||||
|
|
||||||
// ── Direction vectors ──────────────────────────────────────────────
|
// ── Direction vectors ──────────────────────────────────────────────
|
||||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
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 BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
||||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||||
private val KnightJumps: List[(Int, Int)] =
|
private val KnightJumps: List[(Int, Int)] =
|
||||||
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
|
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
|
||||||
|
|
||||||
// ── Pawn configuration helpers ─────────────────────────────────────
|
// ── Pawn configuration helpers ─────────────────────────────────────
|
||||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
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 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
|
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
|
||||||
|
|
||||||
@@ -29,13 +28,14 @@ object DefaultRules extends RuleSet:
|
|||||||
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||||
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
||||||
if piece.color != context.turn then List.empty[Move]
|
if piece.color != context.turn then List.empty[Move]
|
||||||
else piece.pieceType match
|
else
|
||||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
piece.pieceType match
|
||||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
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] =
|
override def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||||
@@ -65,18 +65,18 @@ object DefaultRules extends RuleSet:
|
|||||||
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
|
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
|
||||||
|
|
||||||
private def slidingMoves(
|
private def slidingMoves(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color,
|
color: Color,
|
||||||
dirs: List[(Int, Int)]
|
dirs: List[(Int, Int)],
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
||||||
|
|
||||||
private def castRay(
|
private def castRay(
|
||||||
board: Board,
|
board: Board,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color,
|
color: Color,
|
||||||
dir: (Int, Int)
|
dir: (Int, Int),
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
@tailrec
|
@tailrec
|
||||||
def loop(sq: Square, acc: List[Move]): List[Move] =
|
def loop(sq: Square, acc: List[Move]): List[Move] =
|
||||||
@@ -84,40 +84,40 @@ object DefaultRules extends RuleSet:
|
|||||||
case None => acc
|
case None => acc
|
||||||
case Some(next) =>
|
case Some(next) =>
|
||||||
board.pieceAt(next) match
|
board.pieceAt(next) match
|
||||||
case None => loop(next, Move(from, next) :: acc)
|
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(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
|
||||||
case Some(_) => acc
|
case Some(_) => acc
|
||||||
loop(from, Nil).reverse
|
loop(from, Nil).reverse
|
||||||
|
|
||||||
// ── Knight ─────────────────────────────────────────────────────────
|
// ── Knight ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def knightCandidates(
|
private def knightCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
KnightJumps.flatMap { (df, dr) =>
|
KnightJumps.flatMap { (df, dr) =>
|
||||||
from.offset(df, dr).flatMap { to =>
|
from.offset(df, dr).flatMap { to =>
|
||||||
context.board.pieceAt(to) match
|
context.board.pieceAt(to) match
|
||||||
case Some(p) if p.color == color => None
|
case Some(p) if p.color == color => None
|
||||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||||
case None => Some(Move(from, to))
|
case None => Some(Move(from, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── King ───────────────────────────────────────────────────────────
|
// ── King ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def kingCandidates(
|
private def kingCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
val steps = QueenDirs.flatMap { (df, dr) =>
|
val steps = QueenDirs.flatMap { (df, dr) =>
|
||||||
from.offset(df, dr).flatMap { to =>
|
from.offset(df, dr).flatMap { to =>
|
||||||
context.board.pieceAt(to) match
|
context.board.pieceAt(to) match
|
||||||
case Some(p) if p.color == color => None
|
case Some(p) if p.color == color => None
|
||||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||||
case None => Some(Move(from, to))
|
case None => Some(Move(from, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps ++ castlingCandidates(context, from, color)
|
steps ++ castlingCandidates(context, from, color)
|
||||||
@@ -125,17 +125,17 @@ object DefaultRules extends RuleSet:
|
|||||||
// ── Castling ───────────────────────────────────────────────────────
|
// ── Castling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private case class CastlingMove(
|
private case class CastlingMove(
|
||||||
kingFromAlg: String,
|
kingFromAlg: String,
|
||||||
kingToAlg: String,
|
kingToAlg: String,
|
||||||
middleAlg: String,
|
middleAlg: String,
|
||||||
rookFromAlg: String,
|
rookFromAlg: String,
|
||||||
moveType: MoveType
|
moveType: MoveType,
|
||||||
)
|
)
|
||||||
|
|
||||||
private def castlingCandidates(
|
private def castlingCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
color match
|
color match
|
||||||
case Color.White => whiteCastles(context, from)
|
case Color.White => whiteCastles(context, from)
|
||||||
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
|
|||||||
if from != expected then List.empty
|
if from != expected then List.empty
|
||||||
else
|
else
|
||||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||||
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
|
addCastleMove(
|
||||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
|
context,
|
||||||
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
|
moves,
|
||||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
|
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
|
moves.toList
|
||||||
|
|
||||||
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
||||||
@@ -157,10 +165,18 @@ object DefaultRules extends RuleSet:
|
|||||||
if from != expected then List.empty
|
if from != expected then List.empty
|
||||||
else
|
else
|
||||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||||
addCastleMove(context, moves, context.castlingRights.blackKingSide,
|
addCastleMove(
|
||||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
|
context,
|
||||||
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
|
moves,
|
||||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
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
|
moves.toList
|
||||||
|
|
||||||
private def queensideBSquare(kingToAlg: String): List[String] =
|
private def queensideBSquare(kingToAlg: String): List[String] =
|
||||||
@@ -170,10 +186,10 @@ object DefaultRules extends RuleSet:
|
|||||||
case _ => List.empty
|
case _ => List.empty
|
||||||
|
|
||||||
private def addCastleMove(
|
private def addCastleMove(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
moves: scala.collection.mutable.ListBuffer[Move],
|
moves: scala.collection.mutable.ListBuffer[Move],
|
||||||
castlingRight: Boolean,
|
castlingRight: Boolean,
|
||||||
castlingMove: CastlingMove
|
castlingMove: CastlingMove,
|
||||||
): Unit =
|
): Unit =
|
||||||
if castlingRight then
|
if castlingRight then
|
||||||
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
||||||
@@ -185,16 +201,15 @@ object DefaultRules extends RuleSet:
|
|||||||
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
|
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
|
||||||
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
|
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
|
||||||
do
|
do
|
||||||
val color = context.turn
|
val color = context.turn
|
||||||
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
|
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 rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
|
||||||
val squaresSafe =
|
val squaresSafe =
|
||||||
!isAttackedBy(context.board, kf, color.opposite) &&
|
!isAttackedBy(context.board, kf, color.opposite) &&
|
||||||
!isAttackedBy(context.board, km, color.opposite) &&
|
!isAttackedBy(context.board, km, color.opposite) &&
|
||||||
!isAttackedBy(context.board, kt, color.opposite)
|
!isAttackedBy(context.board, kt, color.opposite)
|
||||||
|
|
||||||
if kingPresent && rookPresent && squaresSafe then
|
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
|
||||||
moves += Move(kf, kt, castlingMove.moveType)
|
|
||||||
|
|
||||||
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
||||||
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
||||||
@@ -202,22 +217,26 @@ object DefaultRules extends RuleSet:
|
|||||||
// ── Pawn ───────────────────────────────────────────────────────────
|
// ── Pawn ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def pawnCandidates(
|
private def pawnCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
val fwd = pawnForward(color)
|
val fwd = pawnForward(color)
|
||||||
val startRank = pawnStartRank(color)
|
val startRank = pawnStartRank(color)
|
||||||
val promoRank = pawnPromoRank(color)
|
val promoRank = pawnPromoRank(color)
|
||||||
|
|
||||||
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
||||||
val double = Option.when(from.rank.ordinal == startRank) {
|
val double = Option
|
||||||
from.offset(0, fwd).flatMap { mid =>
|
.when(from.rank.ordinal == startRank) {
|
||||||
Option.when(context.board.pieceAt(mid).isEmpty) {
|
from.offset(0, fwd).flatMap { mid =>
|
||||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
Option
|
||||||
}.flatten
|
.when(context.board.pieceAt(mid).isEmpty) {
|
||||||
|
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||||
|
}
|
||||||
|
.flatten
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.flatten
|
.flatten
|
||||||
|
|
||||||
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
||||||
from.offset(df, fwd).flatMap { to =>
|
from.offset(df, fwd).flatMap { to =>
|
||||||
@@ -236,22 +255,22 @@ object DefaultRules extends RuleSet:
|
|||||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||||
if dest.rank.ordinal == promoRank then
|
if dest.rank.ordinal == promoRank then
|
||||||
List(
|
List(
|
||||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
PromotionPiece.Queen,
|
||||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
PromotionPiece.Rook,
|
||||||
|
PromotionPiece.Bishop,
|
||||||
|
PromotionPiece.Knight,
|
||||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||||
|
|
||||||
val stepSquares = single.toList ++ double.toList
|
val stepSquares = single.toList ++ double.toList
|
||||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||||
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
||||||
stepMoves ++ captureMoves ++ epCaptures
|
stepMoves ++ captureMoves ++ epCaptures
|
||||||
|
|
||||||
// ── Check detection ────────────────────────────────────────────────
|
// ── Check detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
private def kingSquare(board: Board, color: Color): Option[Square] =
|
private def kingSquare(board: Board, color: Color): Option[Square] =
|
||||||
Square.all.find(sq =>
|
Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
|
||||||
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
|
|
||||||
)
|
|
||||||
|
|
||||||
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
||||||
Square.all.exists { sq =>
|
Square.all.exists { sq =>
|
||||||
@@ -266,26 +285,26 @@ object DefaultRules extends RuleSet:
|
|||||||
case PieceType.Pawn =>
|
case PieceType.Pawn =>
|
||||||
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
||||||
case PieceType.Knight =>
|
case PieceType.Knight =>
|
||||||
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||||
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
||||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||||
case PieceType.King =>
|
case PieceType.King =>
|
||||||
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||||
|
|
||||||
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
||||||
dirs.exists { dir =>
|
dirs.exists { dir =>
|
||||||
@tailrec
|
@tailrec
|
||||||
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
|
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
|
||||||
case None => false
|
case None => false
|
||||||
case Some(next) if next == target => true
|
case Some(next) if next == target => true
|
||||||
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
|
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
|
||||||
case Some(_) => false
|
case Some(_) => false
|
||||||
loop(from)
|
loop(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
|
private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
|
||||||
val nextBoard = context.board.applyMove(move)
|
val nextBoard = context.board.applyMove(move)
|
||||||
val nextContext = context.withBoard(nextBoard)
|
val nextContext = context.withBoard(nextBoard)
|
||||||
isCheck(nextContext)
|
isCheck(nextContext)
|
||||||
|
|
||||||
@@ -293,7 +312,7 @@ object DefaultRules extends RuleSet:
|
|||||||
|
|
||||||
override def applyMove(context: GameContext)(move: Move): GameContext =
|
override def applyMove(context: GameContext)(move: Move): GameContext =
|
||||||
val color = context.turn
|
val color = context.turn
|
||||||
val board = context.board
|
val board = context.board
|
||||||
|
|
||||||
val newBoard = move.moveType match
|
val newBoard = move.moveType match
|
||||||
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
|
case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
|
||||||
@@ -302,14 +321,14 @@ object DefaultRules extends RuleSet:
|
|||||||
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
||||||
case MoveType.Normal(_) => board.applyMove(move)
|
case MoveType.Normal(_) => board.applyMove(move)
|
||||||
|
|
||||||
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||||
val newEnPassantSquare = computeEnPassantSquare(board, move)
|
val newEnPassantSquare = computeEnPassantSquare(board, move)
|
||||||
val isCapture = move.moveType match
|
val isCapture = move.moveType match
|
||||||
case MoveType.Normal(capture) => capture
|
case MoveType.Normal(capture) => capture
|
||||||
case MoveType.EnPassant => true
|
case MoveType.EnPassant => true
|
||||||
case _ => board.pieceAt(move.to).isDefined
|
case _ => board.pieceAt(move.to).isDefined
|
||||||
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||||
|
|
||||||
context
|
context
|
||||||
.withBoard(newBoard)
|
.withBoard(newBoard)
|
||||||
@@ -322,19 +341,18 @@ object DefaultRules extends RuleSet:
|
|||||||
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||||
val (kingFrom, kingTo, rookFrom, rookTo) =
|
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||||
if kingside then
|
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||||
(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))
|
||||||
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 king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||||
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||||
board
|
board
|
||||||
.removed(kingFrom).removed(rookFrom)
|
.removed(kingFrom)
|
||||||
|
.removed(rookFrom)
|
||||||
.updated(kingTo, king)
|
.updated(kingTo, king)
|
||||||
.updated(rookTo, rook)
|
.updated(rookTo, rook)
|
||||||
|
|
||||||
private def applyEnPassant(board: Board, move: Move): Board =
|
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 capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||||
val capturedSquare = Square(move.to.file, capturedRank)
|
val capturedSquare = Square(move.to.file, capturedRank)
|
||||||
board.applyMove(move).removed(capturedSquare)
|
board.applyMove(move).removed(capturedSquare)
|
||||||
|
|
||||||
@@ -347,7 +365,7 @@ object DefaultRules extends RuleSet:
|
|||||||
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
||||||
|
|
||||||
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
||||||
val piece = board.pieceAt(move.from)
|
val piece = board.pieceAt(move.from)
|
||||||
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
||||||
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
||||||
|
|
||||||
@@ -360,14 +378,14 @@ object DefaultRules extends RuleSet:
|
|||||||
var r = rights
|
var r = rights
|
||||||
if isKingMove then r = r.revokeColor(color)
|
if isKingMove then r = r.revokeColor(color)
|
||||||
else if isRookMove then
|
else if isRookMove then
|
||||||
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||||
if move.from == whiteQueensideRook then r = r.revokeQueenSide(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 == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||||
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||||
// Also revoke if a rook is captured
|
// Also revoke if a rook is captured
|
||||||
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||||
if move.to == whiteQueensideRook then r = r.revokeQueenSide(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 == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||||
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||||
r
|
r
|
||||||
|
|
||||||
@@ -386,9 +404,10 @@ object DefaultRules extends RuleSet:
|
|||||||
private def insufficientMaterial(board: Board): Boolean =
|
private def insufficientMaterial(board: Board): Boolean =
|
||||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
||||||
pieces match
|
pieces match
|
||||||
case Nil => true
|
case Nil => true
|
||||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||||
case List(p1, p2)
|
case List(p1, p2)
|
||||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||||
&& p1.color != p2.color => true
|
&& p1.color != p2.color =>
|
||||||
|
true
|
||||||
case _ => false
|
case _ => false
|
||||||
|
|||||||
+26
-30
@@ -65,7 +65,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove clears en passant square for non double pawn push"):
|
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 context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
|
||||||
val move = Move(sq("e2"), sq("e3"))
|
val move = Move(sq("e2"), sq("e3"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove resets halfMoveClock on pawn move"):
|
test("applyMove resets halfMoveClock on pawn move"):
|
||||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
||||||
val move = Move(sq("e2"), sq("e4"))
|
val move = Move(sq("e2"), sq("e4"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove increments halfMoveClock on quiet non pawn move"):
|
test("applyMove increments halfMoveClock on quiet non pawn move"):
|
||||||
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
|
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
|
||||||
val move = Move(sq("g1"), sq("f3"))
|
val move = Move(sq("g1"), sq("f3"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove resets halfMoveClock on capture"):
|
test("applyMove resets halfMoveClock on capture"):
|
||||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
|
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 move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove updates castling rights after king move"):
|
test("applyMove updates castling rights after king move"):
|
||||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
val move = Move(sq("e1"), sq("e2"))
|
val move = Move(sq("e1"), sq("e2"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove updates castling rights after rook move from h1"):
|
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 context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
|
||||||
val move = Move(sq("h1"), sq("h2"))
|
val move = Move(sq("h1"), sq("h2"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove revokes opponent castling right when rook on starting square is captured"):
|
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 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 move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove executes kingside castling and repositions king and rook"):
|
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 context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
|
||||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove executes queenside castling and repositions king and rook"):
|
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 context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
|
||||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove executes en passant and removes captured pawn"):
|
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 context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||||
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove executes promotion with selected piece type"):
|
test("applyMove executes promotion with selected piece type"):
|
||||||
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
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 move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove preserves black castling rights after white kingside castling"):
|
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 context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||||
val context = GameContext(
|
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)),
|
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,
|
turn = Color.Black,
|
||||||
castlingRights = CastlingRights(true, true, false, false),
|
castlingRights = CastlingRights(true, true, false, false),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
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)))
|
val afterH1Capture =
|
||||||
|
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||||
|
|
||||||
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
||||||
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
||||||
@@ -233,7 +235,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
|
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 context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -244,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove revokes black castling rights when black rook moves from h8"):
|
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 context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
val move = Move(sq("h8"), sq("h7"))
|
val move = Move(sq("h8"), sq("h7"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -253,7 +255,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove revokes black queenside castling right when black rook moves from a8"):
|
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 context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
val move = Move(sq("a8"), sq("a7"))
|
val move = Move(sq("a8"), sq("a7"))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -262,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
|
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 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 move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
val next = DefaultRules.applyMove(context)(move)
|
val next = DefaultRules.applyMove(context)(move)
|
||||||
|
|
||||||
@@ -270,31 +272,25 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("candidateMoves creates all promotion move variants for black pawn"):
|
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 context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
|
||||||
val to = sq("a1")
|
val to = sq("a1")
|
||||||
|
|
||||||
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
|
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
|
||||||
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
|
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
|
||||||
|
|
||||||
promotions.toSet shouldBe Set(
|
promotions.toSet shouldBe Set(
|
||||||
PromotionPiece.Queen,
|
PromotionPiece.Queen,
|
||||||
PromotionPiece.Rook,
|
PromotionPiece.Rook,
|
||||||
PromotionPiece.Bishop,
|
PromotionPiece.Bishop,
|
||||||
PromotionPiece.Knight
|
PromotionPiece.Knight,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("applyMove promotion supports queen rook and bishop targets"):
|
test("applyMove promotion supports queen rook and bishop targets"):
|
||||||
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
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 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 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)))
|
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))
|
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||||
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.rule
|
package de.nowchess.rule
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType}
|
import de.nowchess.api.move.{Move, MoveType}
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
@@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Pawn moves ──────────────────────────────────────────────────
|
// ── Pawn moves ──────────────────────────────────────────────────
|
||||||
|
|
||||||
test("pawn can move forward one square"):
|
test("pawn can move forward one square"):
|
||||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
|
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
|
||||||
|
|
||||||
test("pawn can move forward two squares from starting position"):
|
test("pawn can move forward two squares from starting position"):
|
||||||
val context = GameContext.initial
|
val context = GameContext.initial
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||||
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
|
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
|
||||||
|
|
||||||
test("pawn can capture diagonally"):
|
test("pawn can capture diagonally"):
|
||||||
// FEN: white pawn e4, black pawn d5
|
// FEN: white pawn e4, black pawn d5
|
||||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
|
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
|
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
||||||
|
|
||||||
test("pawn cannot move backward"):
|
test("pawn cannot move backward"):
|
||||||
// FEN: white pawn on e4
|
// FEN: white pawn on e4
|
||||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
|
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
|
||||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
|
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
|
||||||
|
|
||||||
@@ -47,18 +47,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("moving king out of check removes it from legal moves if king stays in check"):
|
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
|
// 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 fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
|
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
|
||||||
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
|
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
|
||||||
|
|
||||||
test("king cannot move to square attacked by opponent"):
|
test("king cannot move to square attacked by opponent"):
|
||||||
// FEN: white king e1, black rook e2 defended by black king e3
|
// 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 fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
// King cannot move to e2 (occupied and attacked)
|
// 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))
|
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
|
||||||
@@ -67,64 +67,67 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
// ── Castling legality ────────────────────────────────────────────
|
// ── Castling legality ────────────────────────────────────────────
|
||||||
|
|
||||||
test("castling kingside is legal when king and rook unmoved and path clear"):
|
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 fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||||
castles.nonEmpty shouldBe true
|
castles.nonEmpty shouldBe true
|
||||||
|
|
||||||
test("castling queenside is legal when king and rook unmoved and path clear"):
|
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 fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||||
castles.nonEmpty shouldBe true
|
castles.nonEmpty shouldBe true
|
||||||
|
|
||||||
test("castling is illegal when castling rights are false"):
|
test("castling is illegal when castling rights are false"):
|
||||||
// FEN: king and rook in position, but castling rights disabled
|
// FEN: king and rook in position, but castling rights disabled
|
||||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||||
castles.isEmpty shouldBe true
|
castles.isEmpty shouldBe true
|
||||||
|
|
||||||
test("castling is illegal when king is in check"):
|
test("castling is illegal when king is in check"):
|
||||||
// FEN: white king e1 in check from black rook e8
|
// 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 fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
|
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
|
||||||
castles.isEmpty shouldBe true
|
castles.isEmpty shouldBe true
|
||||||
|
|
||||||
test("castling is illegal when path has piece in the way"):
|
test("castling is illegal when path has piece in the way"):
|
||||||
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
|
// 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 fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||||
castles.isEmpty shouldBe true
|
castles.isEmpty shouldBe true
|
||||||
|
|
||||||
test("castling queenside is illegal when knight blocks on b8"):
|
test("castling queenside is illegal when knight blocks on b8"):
|
||||||
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
||||||
val board = Board(Map(
|
val board = Board(
|
||||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
Map(
|
||||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
))
|
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||||
|
),
|
||||||
|
)
|
||||||
val context = GameContext(
|
val context = GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
turn = Color.Black,
|
turn = Color.Black,
|
||||||
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
castlingRights =
|
||||||
|
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
@@ -135,18 +138,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("en passant is legal when en passant square is set"):
|
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
|
// 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 fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||||
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
|
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
|
||||||
|
|
||||||
test("en passant is illegal when en passant square is none"):
|
test("en passant is illegal when en passant square is none"):
|
||||||
// FEN: white pawn e5, black pawn d5, but no en passant square
|
// 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 fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||||
epMoves.isEmpty shouldBe true
|
epMoves.isEmpty shouldBe true
|
||||||
@@ -155,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("pinned piece cannot move and expose king to check"):
|
test("pinned piece cannot move and expose king to check"):
|
||||||
// FEN: white king e1, white bishop d2 (pinned), black rook a2
|
// 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 fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
// Bishop on d2 is pinned by rook on a2; it cannot move
|
// Bishop on d2 is pinned by rook on a2; it cannot move
|
||||||
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
|
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
|
||||||
@@ -166,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
test("piece blocking a check is legal"):
|
test("piece blocking a check is legal"):
|
||||||
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
|
// 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
|
// 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 fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
// White is in check; only moves that block or move the king are legal
|
// White is in check; only moves that block or move the king are legal
|
||||||
moves.nonEmpty shouldBe true
|
moves.nonEmpty shouldBe true
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.ui.terminal.TerminalUI
|
import de.nowchess.ui.terminal.TerminalUI
|
||||||
import de.nowchess.ui.gui.ChessGUILauncher
|
import de.nowchess.ui.gui.ChessGUILauncher
|
||||||
|
|
||||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
|
||||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
* GameEngine via Observer pattern.
|
||||||
*/
|
*/
|
||||||
object Main:
|
object Main:
|
||||||
def main(args: Array[String]): Unit =
|
def main(args: Array[String]): Unit =
|
||||||
// Create the core game engine (single source of truth)
|
// Create the core game engine (single source of truth)
|
||||||
@@ -18,4 +18,3 @@ object Main:
|
|||||||
// Create and start the terminal UI (blocks on main thread)
|
// Create and start the terminal UI (blocks on main thread)
|
||||||
val tui = new TerminalUI(engine)
|
val tui = new TerminalUI(engine)
|
||||||
tui.start()
|
tui.start()
|
||||||
|
|
||||||
|
|||||||
@@ -17,37 +17,36 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService}
|
import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import scalafx.stage.FileChooser
|
import scalafx.stage.FileChooser
|
||||||
import scalafx.stage.FileChooser.ExtensionFilter
|
import scalafx.stage.FileChooser.ExtensionFilter
|
||||||
|
|
||||||
/** ScalaFX chess board view that displays the game state.
|
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
|
||||||
* Uses chess sprites and color palette.
|
* interactions (clicks) and sends moves to GameEngine.
|
||||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
*/
|
||||||
*/
|
|
||||||
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
||||||
|
|
||||||
private val squareSize = 70.0
|
private val squareSize = 70.0
|
||||||
private val comicSansFontFamily = "Comic Sans MS"
|
private val comicSansFontFamily = "Comic Sans MS"
|
||||||
private val boardGrid = new GridPane()
|
private val boardGrid = new GridPane()
|
||||||
private val messageLabel = new Label {
|
private val messageLabel = new Label {
|
||||||
text = "Welcome!"
|
text = "Welcome!"
|
||||||
font = Font.font(comicSansFontFamily, 16)
|
font = Font.font(comicSansFontFamily, 16)
|
||||||
padding = Insets(10)
|
padding = Insets(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentBoard: Board = engine.board
|
private var currentBoard: Board = engine.board
|
||||||
private var currentTurn: Color = engine.turn
|
private var currentTurn: Color = engine.turn
|
||||||
private var selectedSquare: Option[Square] = None
|
private var selectedSquare: Option[Square] = None
|
||||||
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
|
||||||
|
|
||||||
private var undoButton: Button = uninitialized
|
private var undoButton: Button = uninitialized
|
||||||
private var redoButton: Button = uninitialized
|
private var redoButton: Button = uninitialized
|
||||||
|
|
||||||
// Initialize UI
|
// Initialize UI
|
||||||
initializeBoard()
|
initializeBoard()
|
||||||
|
|
||||||
top = new VBox {
|
top = new VBox {
|
||||||
padding = Insets(10)
|
padding = Insets(10)
|
||||||
spacing = 5
|
spacing = 5
|
||||||
@@ -58,17 +57,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 24)
|
font = Font.font(comicSansFontFamily, 24)
|
||||||
style = "-fx-font-weight: bold;"
|
style = "-fx-font-weight: bold;"
|
||||||
},
|
},
|
||||||
messageLabel
|
messageLabel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
center = new VBox {
|
center = new VBox {
|
||||||
padding = Insets(20)
|
padding = Insets(20)
|
||||||
alignment = Pos.Center
|
alignment = Pos.Center
|
||||||
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
|
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
|
||||||
children = boardGrid
|
children = boardGrid
|
||||||
}
|
}
|
||||||
|
|
||||||
bottom = new VBox {
|
bottom = new VBox {
|
||||||
padding = Insets(10)
|
padding = Insets(10)
|
||||||
spacing = 8
|
spacing = 8
|
||||||
@@ -86,8 +85,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
disable = !engine.canUndo
|
disable = !engine.canUndo
|
||||||
}
|
}
|
||||||
undoButton
|
undoButton
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
redoButton = new Button("Redo") {
|
redoButton = new Button("Redo") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => if engine.canRedo then engine.redo()
|
onAction = _ => if engine.canRedo then engine.redo()
|
||||||
@@ -100,7 +98,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => engine.reset()
|
onAction = _ => engine.reset()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
new HBox {
|
new HBox {
|
||||||
@@ -126,7 +124,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => doPgnImport()
|
onAction = _ => doPgnImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
new HBox {
|
new HBox {
|
||||||
@@ -142,17 +140,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => doJsonImport()
|
onAction = _ => doJsonImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def initializeBoard(): Unit =
|
private def initializeBoard(): Unit =
|
||||||
boardGrid.padding = Insets(5)
|
boardGrid.padding = Insets(5)
|
||||||
boardGrid.hgap = 0
|
boardGrid.hgap = 0
|
||||||
boardGrid.vgap = 0
|
boardGrid.vgap = 0
|
||||||
|
|
||||||
// Create 8x8 board with rank/file labels
|
// Create 8x8 board with rank/file labels
|
||||||
for
|
for
|
||||||
rank <- 0 until 8
|
rank <- 0 until 8
|
||||||
@@ -161,13 +159,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
val square = createSquare(rank, file)
|
val square = createSquare(rank, file)
|
||||||
squareViews((rank, file)) = square
|
squareViews((rank, file)) = square
|
||||||
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
||||||
|
|
||||||
updateBoard(currentBoard, currentTurn)
|
updateBoard(currentBoard, currentTurn)
|
||||||
|
|
||||||
private def createSquare(rank: Int, file: Int): StackPane =
|
private def createSquare(rank: Int, file: Int): StackPane =
|
||||||
val isWhite = (rank + file) % 2 == 0
|
val isWhite = (rank + file) % 2 == 0
|
||||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||||
|
|
||||||
val bgRect = new Rectangle {
|
val bgRect = new Rectangle {
|
||||||
width = squareSize
|
width = squareSize
|
||||||
height = squareSize
|
height = squareSize
|
||||||
@@ -175,21 +173,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
arcWidth = 8
|
arcWidth = 8
|
||||||
arcHeight = 8
|
arcHeight = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
val square = new StackPane {
|
val square = new StackPane {
|
||||||
children = Seq(bgRect)
|
children = Seq(bgRect)
|
||||||
onMouseClicked = _ => handleSquareClick(rank, file)
|
onMouseClicked = _ => handleSquareClick(rank, file)
|
||||||
style = "-fx-cursor: hand;"
|
style = "-fx-cursor: hand;"
|
||||||
}
|
}
|
||||||
|
|
||||||
square
|
square
|
||||||
|
|
||||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||||
if engine.isPendingPromotion then
|
if engine.isPendingPromotion then return // Don't allow moves during promotion
|
||||||
return // Don't allow moves during promotion
|
|
||||||
|
|
||||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||||
|
|
||||||
selectedSquare match
|
selectedSquare match
|
||||||
case None =>
|
case None =>
|
||||||
// First click - select piece if it belongs to current player
|
// First click - select piece if it belongs to current player
|
||||||
@@ -198,13 +195,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
selectedSquare = Some(clickedSquare)
|
selectedSquare = Some(clickedSquare)
|
||||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||||
|
|
||||||
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
|
val legalDests = engine.ruleSet
|
||||||
.collect { case move if move.from == clickedSquare => move.to }
|
.legalMoves(engine.context)(clickedSquare)
|
||||||
|
.collect { case move if move.from == clickedSquare => move.to }
|
||||||
legalDests.foreach { sq =>
|
legalDests.foreach { sq =>
|
||||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case Some(fromSquare) =>
|
case Some(fromSquare) =>
|
||||||
// Second click - attempt move
|
// Second click - attempt move
|
||||||
if clickedSquare == fromSquare then
|
if clickedSquare == fromSquare then
|
||||||
@@ -216,21 +214,21 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
val moveStr = s"${fromSquare}$clickedSquare"
|
val moveStr = s"${fromSquare}$clickedSquare"
|
||||||
engine.processUserInput(moveStr)
|
engine.processUserInput(moveStr)
|
||||||
selectedSquare = None
|
selectedSquare = None
|
||||||
|
|
||||||
def updateBoard(board: Board, turn: Color): Unit =
|
def updateBoard(board: Board, turn: Color): Unit =
|
||||||
currentBoard = board
|
currentBoard = board
|
||||||
currentTurn = turn
|
currentTurn = turn
|
||||||
selectedSquare = None
|
selectedSquare = None
|
||||||
|
|
||||||
// Update all squares
|
// Update all squares
|
||||||
for
|
for
|
||||||
rank <- 0 until 8
|
rank <- 0 until 8
|
||||||
file <- 0 until 8
|
file <- 0 until 8
|
||||||
do
|
do
|
||||||
squareViews.get((rank, file)).foreach { stackPane =>
|
squareViews.get((rank, file)).foreach { stackPane =>
|
||||||
val isWhite = (rank + file) % 2 == 0
|
val isWhite = (rank + file) % 2 == 0
|
||||||
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
|
||||||
|
|
||||||
val bgRect = new Rectangle {
|
val bgRect = new Rectangle {
|
||||||
width = squareSize
|
width = squareSize
|
||||||
height = squareSize
|
height = squareSize
|
||||||
@@ -238,16 +236,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
arcWidth = 8
|
arcWidth = 8
|
||||||
arcHeight = 8
|
arcHeight = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
val square = Square(File.values(file), Rank.values(rank))
|
val square = Square(File.values(file), Rank.values(rank))
|
||||||
val pieceOption = board.pieceAt(square)
|
val pieceOption = board.pieceAt(square)
|
||||||
|
|
||||||
val children = pieceOption match
|
val children = pieceOption match
|
||||||
case Some(piece) =>
|
case Some(piece) =>
|
||||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||||
case None =>
|
case None =>
|
||||||
Seq(bgRect)
|
Seq(bgRect)
|
||||||
|
|
||||||
stackPane.children = children
|
stackPane.children = children
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,20 +264,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
arcWidth = 8
|
arcWidth = 8
|
||||||
arcHeight = 8
|
arcHeight = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
val square = Square(File.values(file), Rank.values(rank))
|
val square = Square(File.values(file), Rank.values(rank))
|
||||||
val pieceOption = currentBoard.pieceAt(square)
|
val pieceOption = currentBoard.pieceAt(square)
|
||||||
|
|
||||||
stackPane.children = pieceOption match
|
stackPane.children = pieceOption match
|
||||||
case Some(piece) =>
|
case Some(piece) =>
|
||||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||||
case None =>
|
case None =>
|
||||||
Seq(bgRect)
|
Seq(bgRect)
|
||||||
}
|
}
|
||||||
|
|
||||||
def showMessage(msg: String): Unit =
|
def showMessage(msg: String): Unit =
|
||||||
messageLabel.text = msg
|
messageLabel.text = msg
|
||||||
|
|
||||||
def showPromotionDialog(from: Square, to: Square): Unit =
|
def showPromotionDialog(from: Square, to: Square): Unit =
|
||||||
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
|
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
|
||||||
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
|
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
|
||||||
@@ -288,14 +286,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
headerText = "Choose promotion piece"
|
headerText = "Choose promotion piece"
|
||||||
contentText = "Promote to:"
|
contentText = "Promote to:"
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = dialog.showAndWait()
|
val result = dialog.showAndWait()
|
||||||
result match
|
result match
|
||||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
||||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
||||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||||
|
|
||||||
private def doFenExport(): Unit =
|
private def doFenExport(): Unit =
|
||||||
doExport(FenExporter, "FEN")
|
doExport(FenExporter, "FEN")
|
||||||
@@ -316,16 +314,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedFile = fileChooser.showSaveDialog(stage)
|
val selectedFile = fileChooser.showSaveDialog(stage)
|
||||||
if selectedFile != null then
|
if selectedFile != null then
|
||||||
val result = FileSystemGameService.saveGameToFile(
|
val result = FileSystemGameService.saveGameToFile(
|
||||||
engine.context,
|
engine.context,
|
||||||
selectedFile.toPath,
|
selectedFile.toPath,
|
||||||
JsonExporter
|
JsonExporter,
|
||||||
)
|
)
|
||||||
result match
|
result match
|
||||||
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
||||||
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
|
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
|
||||||
|
|
||||||
private def doJsonImport(): Unit =
|
private def doJsonImport(): Unit =
|
||||||
@@ -334,12 +332,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
|
||||||
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedFile = fileChooser.showOpenDialog(stage)
|
val selectedFile = fileChooser.showOpenDialog(stage)
|
||||||
if selectedFile != null then
|
if selectedFile != null then
|
||||||
val result = FileSystemGameService.loadGameFromFile(
|
val result = FileSystemGameService.loadGameFromFile(
|
||||||
selectedFile.toPath,
|
selectedFile.toPath,
|
||||||
JsonParser
|
JsonParser,
|
||||||
)
|
)
|
||||||
result match
|
result match
|
||||||
case Right(gameContext) =>
|
case Right(gameContext) =>
|
||||||
@@ -353,7 +351,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
showCopyDialog(s"$formatName Export", exported)
|
showCopyDialog(s"$formatName Export", exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def doImport(importer: GameContextImport, formatName: String): Unit = {
|
private def doImport(importer: GameContextImport, formatName: String): Unit =
|
||||||
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
||||||
importer.importGameContext(input) match
|
importer.importGameContext(input) match
|
||||||
case Right(gameContext) =>
|
case Right(gameContext) =>
|
||||||
@@ -362,7 +360,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
case Left(err) =>
|
case Left(err) =>
|
||||||
showMessage(s"⚠️ $formatName Error: $err")
|
showMessage(s"⚠️ $formatName Error: $err")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private def showCopyDialog(title: String, content: String): Unit =
|
private def showCopyDialog(title: String, content: String): Unit =
|
||||||
val area = new javafx.scene.control.TextArea(content)
|
val area = new javafx.scene.control.TextArea(content)
|
||||||
@@ -386,7 +383,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
dialog.getDialogPane.setContent(area)
|
dialog.getDialogPane.setContent(area)
|
||||||
dialog.getDialogPane.getButtonTypes.addAll(
|
dialog.getDialogPane.getButtonTypes.addAll(
|
||||||
javafx.scene.control.ButtonType.OK,
|
javafx.scene.control.ButtonType.OK,
|
||||||
javafx.scene.control.ButtonType.CANCEL
|
javafx.scene.control.ButtonType.CANCEL,
|
||||||
)
|
)
|
||||||
dialog.setResultConverter { bt =>
|
dialog.setResultConverter { bt =>
|
||||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
||||||
@@ -394,4 +391,3 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
dialog.initOwner(stage.delegate)
|
dialog.initOwner(stage.delegate)
|
||||||
val result = dialog.showAndWait()
|
val result = dialog.showAndWait()
|
||||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +1,58 @@
|
|||||||
package de.nowchess.ui.gui
|
package de.nowchess.ui.gui
|
||||||
|
|
||||||
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
|
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
|
||||||
import javafx.stage.Stage as JFXStage
|
import javafx.stage.Stage as JFXStage
|
||||||
import scalafx.application.Platform
|
import scalafx.application.Platform
|
||||||
import scalafx.scene.Scene
|
import scalafx.scene.Scene
|
||||||
import scalafx.stage.Stage
|
import scalafx.stage.Stage
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
|
||||||
/** ScalaFX GUI Application for Chess.
|
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
|
||||||
* This is launched from Main alongside the TUI.
|
* GameEngine via Observer pattern.
|
||||||
* Both subscribe to the same GameEngine via Observer pattern.
|
*/
|
||||||
*/
|
|
||||||
class ChessGUIApp extends JFXApplication:
|
class ChessGUIApp extends JFXApplication:
|
||||||
|
|
||||||
override def start(primaryStage: JFXStage): Unit =
|
override def start(primaryStage: JFXStage): Unit =
|
||||||
val engine = ChessGUILauncher.getEngine
|
val engine = ChessGUILauncher.getEngine
|
||||||
val stage = new Stage(primaryStage)
|
val stage = new Stage(primaryStage)
|
||||||
|
|
||||||
stage.title = "Chess"
|
stage.title = "Chess"
|
||||||
stage.width = 700
|
stage.width = 700
|
||||||
stage.height = 1000
|
stage.height = 1000
|
||||||
stage.resizable = false
|
stage.resizable = false
|
||||||
|
|
||||||
val boardView = new ChessBoardView(stage, engine)
|
val boardView = new ChessBoardView(stage, engine)
|
||||||
val guiObserver = new GUIObserver(boardView)
|
val guiObserver = new GUIObserver(boardView)
|
||||||
|
|
||||||
// Subscribe GUI observer to engine
|
// Subscribe GUI observer to engine
|
||||||
engine.subscribe(guiObserver)
|
engine.subscribe(guiObserver)
|
||||||
|
|
||||||
stage.scene = new Scene {
|
stage.scene = new Scene {
|
||||||
root = boardView
|
root = boardView
|
||||||
// Load CSS if available
|
// Load CSS if available
|
||||||
try {
|
try {
|
||||||
val cssUrl = getClass.getResource("/styles.css")
|
val cssUrl = getClass.getResource("/styles.css")
|
||||||
if cssUrl != null then
|
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
|
||||||
stylesheets.add(cssUrl.toExternalForm)
|
|
||||||
} catch {
|
} catch {
|
||||||
case _: Exception => // CSS is optional
|
case _: Exception => // CSS is optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage.onCloseRequest = _ => {
|
stage.onCloseRequest = _ =>
|
||||||
// Unsubscribe when window closes
|
// Unsubscribe when window closes
|
||||||
engine.unsubscribe(guiObserver)
|
engine.unsubscribe(guiObserver)
|
||||||
}
|
|
||||||
|
|
||||||
stage.show()
|
stage.show()
|
||||||
|
|
||||||
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
|
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
|
||||||
object ChessGUILauncher:
|
object ChessGUILauncher:
|
||||||
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
|
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
|
||||||
|
|
||||||
def getEngine: GameEngine = engine
|
def getEngine: GameEngine = engine
|
||||||
|
|
||||||
def launch(eng: GameEngine): Unit =
|
def launch(eng: GameEngine): Unit =
|
||||||
engine = eng
|
engine = eng
|
||||||
val guiThread = new Thread(() => {
|
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
|
||||||
JFXApplication.launch(classOf[ChessGUIApp])
|
|
||||||
})
|
|
||||||
guiThread.setDaemon(false)
|
guiThread.setDaemon(false)
|
||||||
guiThread.setName("ScalaFX-GUI-Thread")
|
guiThread.setName("ScalaFX-GUI-Thread")
|
||||||
guiThread.start()
|
guiThread.start()
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ package de.nowchess.ui.gui
|
|||||||
import scalafx.application.Platform
|
import scalafx.application.Platform
|
||||||
import scalafx.scene.control.Alert
|
import scalafx.scene.control.Alert
|
||||||
import scalafx.scene.control.Alert.AlertType
|
import scalafx.scene.control.Alert.AlertType
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
import de.nowchess.chess.observer.{GameEvent, Observer, *}
|
||||||
import de.nowchess.api.board.Board
|
import de.nowchess.api.board.Board
|
||||||
|
|
||||||
/** GUI Observer that implements the Observer pattern.
|
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
|
||||||
* Receives game events from GameEngine and updates the ScalaFX UI.
|
* All UI updates must be done on the JavaFX Application Thread.
|
||||||
* All UI updates must be done on the JavaFX Application Thread.
|
*/
|
||||||
*/
|
|
||||||
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||||
|
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
@@ -60,8 +59,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
|||||||
boardView.updateBoard(e.context.board, e.context.turn)
|
boardView.updateBoard(e.context.board, e.context.turn)
|
||||||
if e.capturedPiece.isDefined then
|
if e.capturedPiece.isDefined then
|
||||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
||||||
else
|
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
|
||||||
boardView.updateUndoRedoButtons()
|
boardView.updateUndoRedoButtons()
|
||||||
|
|
||||||
case e: PgnLoadedEvent =>
|
case e: PgnLoadedEvent =>
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
package de.nowchess.ui.gui
|
package de.nowchess.ui.gui
|
||||||
|
|
||||||
import scalafx.scene.image.{Image, ImageView}
|
import scalafx.scene.image.{Image, ImageView}
|
||||||
import de.nowchess.api.board.{Piece, PieceType, Color}
|
import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||||
|
|
||||||
/** Utility object for loading chess piece sprites. */
|
/** Utility object for loading chess piece sprites. */
|
||||||
object PieceSprites:
|
object PieceSprites:
|
||||||
|
|
||||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||||
|
|
||||||
/** Load a piece sprite image from resources.
|
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||||
* Sprites are cached for performance.
|
*/
|
||||||
*/
|
|
||||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
||||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||||
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
|
||||||
|
|
||||||
new ImageView(image) {
|
new ImageView(image) {
|
||||||
fitWidth = size
|
fitWidth = size
|
||||||
fitHeight = size
|
fitHeight = size
|
||||||
preserveRatio = true
|
preserveRatio = true
|
||||||
smooth = true
|
smooth = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private def loadImage(key: String): Image =
|
private def loadImage(key: String): Image =
|
||||||
val path = s"/sprites/pieces/$key.png"
|
val path = s"/sprites/pieces/$key.png"
|
||||||
val stream = getClass.getResourceAsStream(path)
|
val stream = getClass.getResourceAsStream(path)
|
||||||
if stream == null then
|
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
|
||||||
throw new RuntimeException(s"Could not load sprite: $path")
|
|
||||||
new Image(stream)
|
new Image(stream)
|
||||||
|
|
||||||
/** Get square colors for the board using theme. */
|
/** Get square colors for the board using theme. */
|
||||||
object SquareColors:
|
object SquareColors:
|
||||||
val White = "#F3C8A0" // Warm light beige
|
val White = "#F3C8A0" // Warm light beige
|
||||||
val Black = "#BA6D4B" // Warm terracotta
|
val Black = "#BA6D4B" // Warm terracotta
|
||||||
val Selected = "#C19EF5" // Purple highlight
|
val Selected = "#C19EF5" // Purple highlight
|
||||||
val ValidMove = "#E1EAA9" // Light yellow-green
|
val ValidMove = "#E1EAA9" // Light yellow-green
|
||||||
val Border = "#5A2C28" // Dark brown border
|
val Border = "#5A2C28" // Dark brown border
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.ui.utils.Renderer
|
import de.nowchess.ui.utils.Renderer
|
||||||
|
|
||||||
/** Terminal UI that implements Observer pattern.
|
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
|
||||||
* Subscribes to GameEngine and receives state change events.
|
* I/O and user interaction in the terminal.
|
||||||
* Handles all I/O and user interaction in the terminal.
|
*/
|
||||||
*/
|
|
||||||
class TerminalUI(engine: GameEngine) extends Observer:
|
class TerminalUI(engine: GameEngine) extends Observer:
|
||||||
private var running = true
|
private var running = true
|
||||||
private var awaitingPromotion = false
|
private var awaitingPromotion = false
|
||||||
|
|
||||||
/** Called by GameEngine whenever a game event occurs. */
|
/** Called by GameEngine whenever a game event occurs. */
|
||||||
|
|||||||
@@ -5,24 +5,26 @@ import de.nowchess.api.board.*
|
|||||||
object Renderer:
|
object Renderer:
|
||||||
|
|
||||||
private val AnsiReset = "\u001b[0m"
|
private val AnsiReset = "\u001b[0m"
|
||||||
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
|
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
|
||||||
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
|
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
|
||||||
private val AnsiWhitePiece = "\u001b[97m" // bright white text
|
private val AnsiWhitePiece = "\u001b[97m" // bright white text
|
||||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||||
|
|
||||||
def render(board: Board): String =
|
def render(board: Board): String =
|
||||||
val rows = (0 until 8).reverse.map { rank =>
|
val rows = (0 until 8).reverse
|
||||||
val cells = (0 until 8).map { file =>
|
.map { rank =>
|
||||||
val sq = Square(File.values(file), Rank.values(rank))
|
val cells = (0 until 8).map { file =>
|
||||||
val isLightSq = (file + rank) % 2 != 0
|
val sq = Square(File.values(file), Rank.values(rank))
|
||||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
val isLightSq = (file + rank) % 2 != 0
|
||||||
board.pieceAt(sq) match
|
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||||
case Some(piece) =>
|
board.pieceAt(sq) match
|
||||||
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
case Some(piece) =>
|
||||||
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||||
case None =>
|
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||||
s"$bgColor $AnsiReset"
|
case None =>
|
||||||
}.mkString
|
s"$bgColor $AnsiReset"
|
||||||
s"${rank + 1} $cells ${rank + 1}"
|
}.mkString
|
||||||
}.mkString("\n")
|
s"${rank + 1} $cells ${rank + 1}"
|
||||||
|
}
|
||||||
|
.mkString("\n")
|
||||||
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
|||||||
(Piece(Color.Black, PieceType.Rook), "\u265C"),
|
(Piece(Color.Black, PieceType.Rook), "\u265C"),
|
||||||
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
|
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
|
||||||
(Piece(Color.Black, PieceType.Knight), "\u265E"),
|
(Piece(Color.Black, PieceType.Knight), "\u265E"),
|
||||||
(Piece(Color.Black, PieceType.Pawn), "\u265F")
|
(Piece(Color.Black, PieceType.Pawn), "\u265F"),
|
||||||
)
|
)
|
||||||
pieces.foreach { (piece, expected) =>
|
pieces.foreach { (piece, expected) =>
|
||||||
piece.unicode shouldBe expected
|
piece.unicode shouldBe expected
|
||||||
}
|
}
|
||||||
|
|
||||||
test("render outputs coordinates ranks ansi escapes and piece glyphs"):
|
test("render outputs coordinates ranks ansi escapes and piece glyphs"):
|
||||||
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
|
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
|
||||||
val rendered = Renderer.render(Board(Map.empty))
|
val rendered = Renderer.render(Board(Map.empty))
|
||||||
val lines = rendered.trim.split("\\n").toList.map(_.trim)
|
val lines = rendered.trim.split("\\n").toList.map(_.trim)
|
||||||
|
|
||||||
lines.head shouldBe "a b c d e f g h"
|
lines.head shouldBe "a b c d e f g h"
|
||||||
lines.last shouldBe "a b c d e f g h"
|
lines.last shouldBe "a b c d e f g h"
|
||||||
@@ -38,9 +38,7 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
|||||||
Renderer.render(board) should include("\u001b[")
|
Renderer.render(board) should include("\u001b[")
|
||||||
|
|
||||||
test("render applies black piece color for black pieces"):
|
test("render applies black piece color for black pieces"):
|
||||||
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
|
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
|
||||||
val rendered = Renderer.render(board)
|
val rendered = Renderer.render(board)
|
||||||
rendered should include("\u265A") // Black king unicode
|
rendered should include("\u265A") // Black king unicode
|
||||||
rendered should include("\u001b[30m") // ANSI black text color
|
rendered should include("\u001b[30m") // ANSI black text color
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user