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
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
- **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 {
|
||||
id("org.sonarqube") version "7.2.3.7755"
|
||||
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"
|
||||
@@ -40,3 +42,27 @@ val versions = mapOf(
|
||||
)
|
||||
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
|
||||
|
||||
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 removed(sq: Square): Board = b.removed(sq)
|
||||
def removed(sq: Square): Board = b.removed(sq)
|
||||
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))
|
||||
(updatedBoard, captured)
|
||||
def applyMove(move: de.nowchess.api.move.Move): Board =
|
||||
@@ -21,8 +21,14 @@ object Board:
|
||||
|
||||
val initial: Board =
|
||||
val backRank: Vector[PieceType] = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
val entries = for
|
||||
fileIdx <- 0 until 8
|
||||
@@ -30,7 +36,7 @@ object Board:
|
||||
(Color.White, Rank.R1, backRank(fileIdx)),
|
||||
(Color.White, Rank.R2, PieceType.Pawn),
|
||||
(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)
|
||||
Board(entries.toMap)
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
/**
|
||||
* Unified castling rights tracker for all four sides.
|
||||
* Tracks whether castling is still available for each side and direction.
|
||||
*
|
||||
* @param whiteKingSide White's king-side castling (0-0) still legally available
|
||||
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
|
||||
* @param blackKingSide Black's king-side castling (0-0) still legally available
|
||||
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
|
||||
*/
|
||||
/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and
|
||||
* direction.
|
||||
*
|
||||
* @param whiteKingSide
|
||||
* White's king-side castling (0-0) still legally available
|
||||
* @param whiteQueenSide
|
||||
* White's queen-side castling (0-0-0) still legally available
|
||||
* @param blackKingSide
|
||||
* Black's king-side castling (0-0) still legally available
|
||||
* @param blackQueenSide
|
||||
* Black's queen-side castling (0-0-0) still legally available
|
||||
*/
|
||||
final case class CastlingRights(
|
||||
whiteKingSide: Boolean,
|
||||
whiteQueenSide: Boolean,
|
||||
blackKingSide: Boolean,
|
||||
blackQueenSide: Boolean
|
||||
whiteKingSide: Boolean,
|
||||
whiteQueenSide: Boolean,
|
||||
blackKingSide: Boolean,
|
||||
blackQueenSide: Boolean,
|
||||
):
|
||||
/**
|
||||
* Check if either side has any castling rights remaining.
|
||||
*/
|
||||
/** Check if either side has any castling rights remaining.
|
||||
*/
|
||||
def hasAnyRights: Boolean =
|
||||
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||
|
||||
/**
|
||||
* Check if a specific color has any castling rights remaining.
|
||||
*/
|
||||
/** Check if a specific color has any castling rights remaining.
|
||||
*/
|
||||
def hasRights(color: Color): Boolean = color match
|
||||
case Color.White => whiteKingSide || whiteQueenSide
|
||||
case Color.Black => blackKingSide || blackQueenSide
|
||||
|
||||
/**
|
||||
* Revoke all castling rights for a specific color.
|
||||
*/
|
||||
/** Revoke all castling rights for a specific color.
|
||||
*/
|
||||
def revokeColor(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||
|
||||
/**
|
||||
* Revoke a specific castling right.
|
||||
*/
|
||||
/** Revoke a specific castling right.
|
||||
*/
|
||||
def revokeKingSide(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteKingSide = false)
|
||||
case Color.Black => copy(blackKingSide = false)
|
||||
|
||||
/**
|
||||
* Revoke a specific castling right.
|
||||
*/
|
||||
/** Revoke a specific castling right.
|
||||
*/
|
||||
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||
case Color.White => copy(whiteQueenSide = false)
|
||||
case Color.Black => copy(blackQueenSide = false)
|
||||
@@ -55,7 +53,7 @@ object CastlingRights:
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
blackQueenSide = false,
|
||||
)
|
||||
|
||||
/** All castling rights available. */
|
||||
@@ -63,7 +61,7 @@ object CastlingRights:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = true,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
|
||||
/** 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:
|
||||
// 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 WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
|
||||
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||
val WhiteKing: Piece = Piece(Color.White, PieceType.King)
|
||||
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
|
||||
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
|
||||
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 BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
|
||||
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
|
||||
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
|
||||
val BlackKing: Piece = Piece(Color.Black, PieceType.King)
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
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:
|
||||
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:
|
||||
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||
|
||||
/**
|
||||
* A unique square on the board, identified by its file and rank.
|
||||
*
|
||||
* @param file the column, a–h
|
||||
* @param rank the row, 1–8
|
||||
*/
|
||||
/** A unique square on the board, identified by its file and rank.
|
||||
*
|
||||
* @param file
|
||||
* the column, a–h
|
||||
* @param rank
|
||||
* the row, 1–8
|
||||
*/
|
||||
final case class Square(file: File, rank: Rank):
|
||||
/** Algebraic notation string, e.g. "e4". */
|
||||
override def toString: String =
|
||||
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||
|
||||
object Square:
|
||||
/** Parse a square from algebraic notation (e.g. "e4").
|
||||
* Returns None if the input is not a valid square name. */
|
||||
/** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
|
||||
*/
|
||||
def fromAlgebraic(s: String): Option[Square] =
|
||||
if s.length != 2 then None
|
||||
else
|
||||
val fileChar = s.charAt(0)
|
||||
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 =
|
||||
rankChar.toString.toIntOption.flatMap(n =>
|
||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
||||
)
|
||||
rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
|
||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||
|
||||
val all: IndexedSeq[Square] =
|
||||
@@ -46,8 +41,9 @@ object Square:
|
||||
f <- File.values.toIndexedSeq
|
||||
yield Square(f, r)
|
||||
|
||||
/** Compute a target square by offsetting file and rank.
|
||||
* Returns None if the resulting square is outside the board (0-7 range). */
|
||||
/** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board
|
||||
* (0-7 range).
|
||||
*/
|
||||
extension (sq: Square)
|
||||
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||
val newFileOrd = sq.file.ordinal + fileDelta
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
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
|
||||
|
||||
/** Immutable bundle of complete game state.
|
||||
* All state changes produce new GameContext instances.
|
||||
*/
|
||||
/** Immutable bundle of complete game state. All state changes produce new GameContext instances.
|
||||
*/
|
||||
case class GameContext(
|
||||
board: Board,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moves: List[Move]
|
||||
board: Board,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moves: List[Move],
|
||||
):
|
||||
/** Create new context with updated board. */
|
||||
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||
@@ -40,5 +39,5 @@ object GameContext:
|
||||
castlingRights = CastlingRights.Initial,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
@@ -10,24 +10,30 @@ enum PromotionPiece:
|
||||
enum MoveType:
|
||||
/** A normal move or capture with no special rule. */
|
||||
case Normal(isCapture: Boolean = false)
|
||||
|
||||
/** Kingside castling (O-O). */
|
||||
case CastleKingside
|
||||
|
||||
/** Queenside castling (O-O-O). */
|
||||
case CastleQueenside
|
||||
|
||||
/** En-passant pawn capture. */
|
||||
case EnPassant
|
||||
|
||||
/** Pawn promotion; carries the chosen promotion piece. */
|
||||
case Promotion(piece: PromotionPiece)
|
||||
|
||||
/**
|
||||
* A half-move (ply) in a chess game.
|
||||
*
|
||||
* @param from origin square
|
||||
* @param to destination square
|
||||
* @param moveType special semantics; defaults to Normal
|
||||
*/
|
||||
/** A half-move (ply) in a chess game.
|
||||
*
|
||||
* @param from
|
||||
* origin square
|
||||
* @param to
|
||||
* destination square
|
||||
* @param moveType
|
||||
* special semantics; defaults to Normal
|
||||
*/
|
||||
final case class Move(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal()
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal(),
|
||||
)
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
package de.nowchess.api.player
|
||||
|
||||
/**
|
||||
* An opaque player identifier.
|
||||
*
|
||||
* Wraps a plain String so that IDs are not accidentally interchanged with
|
||||
* other String values at compile time.
|
||||
*/
|
||||
/** An opaque player identifier.
|
||||
*
|
||||
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
|
||||
*/
|
||||
opaque type PlayerId = String
|
||||
|
||||
object PlayerId:
|
||||
def apply(value: String): PlayerId = value
|
||||
def apply(value: String): PlayerId = value
|
||||
extension (id: PlayerId) def value: String = id
|
||||
|
||||
/**
|
||||
* 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 is held here.
|
||||
*
|
||||
* @param id unique identifier
|
||||
* @param displayName human-readable name shown in the UI
|
||||
*/
|
||||
/** 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
|
||||
* is held here.
|
||||
*
|
||||
* @param id
|
||||
* unique identifier
|
||||
* @param displayName
|
||||
* human-readable name shown in the UI
|
||||
*/
|
||||
final case class PlayerInfo(
|
||||
id: PlayerId,
|
||||
displayName: String
|
||||
id: PlayerId,
|
||||
displayName: String,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package de.nowchess.api.response
|
||||
|
||||
/**
|
||||
* A standardised envelope for every API response.
|
||||
*
|
||||
* Success and failure are modelled as subtypes so that callers
|
||||
* can pattern-match exhaustively.
|
||||
*
|
||||
* @tparam A the payload type for a successful response
|
||||
*/
|
||||
/** A standardised envelope for every API response.
|
||||
*
|
||||
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
|
||||
*
|
||||
* @tparam A
|
||||
* the payload type for a successful response
|
||||
*/
|
||||
sealed trait ApiResponse[+A]
|
||||
|
||||
object ApiResponse:
|
||||
@@ -20,43 +19,49 @@ object ApiResponse:
|
||||
/** Convenience constructor for a single-error failure. */
|
||||
def error(err: ApiError): Failure = Failure(List(err))
|
||||
|
||||
/**
|
||||
* A structured error descriptor.
|
||||
*
|
||||
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||
* @param message human-readable explanation
|
||||
* @param field optional field name when the error relates to a specific input
|
||||
*/
|
||||
/** A structured error descriptor.
|
||||
*
|
||||
* @param code
|
||||
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||
* @param message
|
||||
* human-readable explanation
|
||||
* @param field
|
||||
* optional field name when the error relates to a specific input
|
||||
*/
|
||||
final case class ApiError(
|
||||
code: String,
|
||||
message: String,
|
||||
field: Option[String] = None
|
||||
code: String,
|
||||
message: String,
|
||||
field: Option[String] = None,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pagination metadata for list responses.
|
||||
*
|
||||
* @param page current 0-based page index
|
||||
* @param pageSize number of items per page
|
||||
* @param totalItems total number of items across all pages
|
||||
*/
|
||||
/** Pagination metadata for list responses.
|
||||
*
|
||||
* @param page
|
||||
* current 0-based page index
|
||||
* @param pageSize
|
||||
* number of items per page
|
||||
* @param totalItems
|
||||
* total number of items across all pages
|
||||
*/
|
||||
final case class Pagination(
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
totalItems: Long
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
totalItems: Long,
|
||||
):
|
||||
def totalPages: Int =
|
||||
if pageSize <= 0 then 0
|
||||
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||
|
||||
/**
|
||||
* A paginated list response envelope.
|
||||
*
|
||||
* @param items the items on the current page
|
||||
* @param pagination pagination metadata
|
||||
* @tparam A the item type
|
||||
*/
|
||||
/** A paginated list response envelope.
|
||||
*
|
||||
* @param items
|
||||
* the items on the current page
|
||||
* @param pagination
|
||||
* pagination metadata
|
||||
* @tparam A
|
||||
* the item type
|
||||
*/
|
||||
final case class PagedResponse[A](
|
||||
items: List[A],
|
||||
pagination: Pagination
|
||||
items: List[A],
|
||||
pagination: Pagination,
|
||||
)
|
||||
|
||||
@@ -22,9 +22,9 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("withMove returns captured piece when destination is occupied") {
|
||||
val from = Square(File.A, Rank.R1)
|
||||
val to = Square(File.A, Rank.R8)
|
||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||
val from = Square(File.A, Rank.R1)
|
||||
val to = Square(File.A, Rank.R8)
|
||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||
val (board, captured) = b.withMove(from, to)
|
||||
captured shouldBe Some(Piece.BlackRook)
|
||||
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
|
||||
@@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("initial board white back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||
@@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("initial board black back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
PieceType.Rook,
|
||||
PieceType.Knight,
|
||||
PieceType.Bishop,
|
||||
PieceType.Queen,
|
||||
PieceType.King,
|
||||
PieceType.Bishop,
|
||||
PieceType.Knight,
|
||||
PieceType.Rook,
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||
@@ -76,12 +88,11 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
for
|
||||
rank <- emptyRanks
|
||||
file <- File.values
|
||||
do
|
||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
do Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
}
|
||||
|
||||
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)
|
||||
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
@@ -91,7 +102,7 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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)
|
||||
removed.pieceAt(e2) shouldBe None
|
||||
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(e2) shouldBe None
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
|
||||
rights.hasAnyRights shouldBe true
|
||||
@@ -54,4 +54,3 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
|
||||
test("Color values expose opposite and label consistently"):
|
||||
val cases = List(
|
||||
(Color.White, Color.Black, "White"),
|
||||
(Color.Black, Color.White, "Black")
|
||||
(Color.Black, Color.White, "Black"),
|
||||
)
|
||||
|
||||
cases.foreach { (color, opposite, label) =>
|
||||
|
||||
@@ -7,24 +7,24 @@ class PieceTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Piece holds color and pieceType") {
|
||||
val p = Piece(Color.White, PieceType.Queen)
|
||||
p.color shouldBe Color.White
|
||||
p.color shouldBe Color.White
|
||||
p.pieceType shouldBe PieceType.Queen
|
||||
}
|
||||
|
||||
test("all convenience constants map to expected color and piece type") {
|
||||
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.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
|
||||
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
|
||||
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
|
||||
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
|
||||
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
|
||||
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
|
||||
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
|
||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
|
||||
expected.foreach { case (actual, wanted) =>
|
||||
|
||||
@@ -7,12 +7,12 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PieceType values expose the expected labels"):
|
||||
val expectedLabels = List(
|
||||
PieceType.Pawn -> "Pawn",
|
||||
PieceType.Pawn -> "Pawn",
|
||||
PieceType.Knight -> "Knight",
|
||||
PieceType.Bishop -> "Bishop",
|
||||
PieceType.Rook -> "Rook",
|
||||
PieceType.Queen -> "Queen",
|
||||
PieceType.King -> "King"
|
||||
PieceType.Rook -> "Rook",
|
||||
PieceType.Queen -> "Queen",
|
||||
PieceType.King -> "King",
|
||||
)
|
||||
|
||||
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
|
||||
"a1" -> Square(File.A, Rank.R1),
|
||||
"e4" -> Square(File.E, Rank.R4),
|
||||
"h8" -> Square(File.H, Rank.R8),
|
||||
"E4" -> Square(File.E, Rank.R4)
|
||||
"E4" -> Square(File.E, Rank.R4),
|
||||
)
|
||||
expected.foreach { case (raw, 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.H, Rank.R8).offset(0, 1) shouldBe None
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
initial.moves shouldBe List.empty
|
||||
|
||||
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 updated = GameContext.initial.withBoard(updatedBoard)
|
||||
val updated = GameContext.initial.withBoard(updatedBoard)
|
||||
updated.board shouldBe updatedBoard
|
||||
updated.turn shouldBe GameContext.initial.turn
|
||||
updated.castlingRights shouldBe GameContext.initial.castlingRights
|
||||
@@ -34,13 +34,13 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
)
|
||||
val square = Some(Square(File.E, Rank.R3))
|
||||
val updatedTurn = initial.withTurn(Color.Black)
|
||||
val square = Some(Square(File.E, Rank.R3))
|
||||
val updatedTurn = initial.withTurn(Color.Black)
|
||||
val updatedRights = initial.withCastlingRights(rights)
|
||||
val updatedEp = initial.withEnPassantSquare(square)
|
||||
val updatedClock = initial.withHalfMoveClock(17)
|
||||
val updatedEp = initial.withEnPassantSquare(square)
|
||||
val updatedClock = initial.withHalfMoveClock(17)
|
||||
|
||||
updatedTurn.turn shouldBe Color.Black
|
||||
updatedTurn.board shouldBe initial.board
|
||||
@@ -57,4 +57,3 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
test("withMove appends move to history"):
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class MoveTest extends AnyFunSuite with Matchers:
|
||||
MoveType.Promotion(PromotionPiece.Queen),
|
||||
MoveType.Promotion(PromotionPiece.Rook),
|
||||
MoveType.Promotion(PromotionPiece.Bishop),
|
||||
MoveType.Promotion(PromotionPiece.Knight)
|
||||
MoveType.Promotion(PromotionPiece.Knight),
|
||||
)
|
||||
|
||||
moveTypes.foreach { moveType =>
|
||||
|
||||
@@ -7,12 +7,12 @@ class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||
val raw = "player-123"
|
||||
val id = PlayerId(raw)
|
||||
val id = PlayerId(raw)
|
||||
|
||||
id.value shouldBe raw
|
||||
|
||||
val playerId = PlayerId("p1")
|
||||
val info = PlayerInfo(playerId, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
val info = PlayerInfo(playerId, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
|
||||
|
||||
val e = ApiError("CODE", "message")
|
||||
e.code shouldBe "CODE"
|
||||
e.code shouldBe "CODE"
|
||||
e.message shouldBe "message"
|
||||
e.field shouldBe None
|
||||
e.field shouldBe None
|
||||
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") {
|
||||
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
|
||||
val pr = PagedResponse(List("a", "b"), pagination)
|
||||
pr.items shouldBe List("a", "b")
|
||||
pr.items shouldBe List("a", "b")
|
||||
pr.pagination shouldBe pagination
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone.
|
||||
* Commands encapsulate user actions and game state transitions.
|
||||
*/
|
||||
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||
* transitions.
|
||||
*/
|
||||
trait Command:
|
||||
/** Execute the command and return true if successful, false otherwise. */
|
||||
def execute(): Boolean
|
||||
@@ -16,15 +16,14 @@ trait Command:
|
||||
/** A human-readable description of this command. */
|
||||
def description: String
|
||||
|
||||
/** Command to move a piece from one square to another.
|
||||
* Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = ""
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = "",
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
@@ -39,18 +38,18 @@ case class MoveCommand(
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
/** Command to quit the game. */
|
||||
case class QuitCommand() extends Command:
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Quit game"
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousContext: Option[GameContext] = None
|
||||
previousContext: Option[GameContext] = None,
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
|
||||
/** Manages command execution and history for undo/redo support. */
|
||||
class CommandInvoker:
|
||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentIndex = -1
|
||||
|
||||
/** Execute a command and add it to history.
|
||||
* Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
def execute(command: Command): Boolean = synchronized {
|
||||
if command.execute() then
|
||||
// Remove any commands after current index (redo stack is discarded)
|
||||
while currentIndex < executedCommands.size - 1 do
|
||||
executedCommands.remove(executedCommands.size - 1)
|
||||
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||
executedCommands += command
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Undo the last executed command if possible. */
|
||||
@@ -27,10 +25,8 @@ class CommandInvoker:
|
||||
if command.undo() then
|
||||
currentIndex -= 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Redo the next command in history if available. */
|
||||
@@ -40,10 +36,8 @@ class CommandInvoker:
|
||||
if command.execute() then
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Get the history of all executed commands. */
|
||||
|
||||
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
|
||||
|
||||
object Parser:
|
||||
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
||||
* Returns None for any input that does not match the expected format.
|
||||
*/
|
||||
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||
* format.
|
||||
*/
|
||||
def parseMove(input: String): Option[(Square, Square)] =
|
||||
val trimmed = input.trim.toLowerCase
|
||||
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
Option
|
||||
.when(trimmed.length == 4)(trimmed)
|
||||
.flatMap: s =>
|
||||
for
|
||||
from <- parseSquare(s.substring(0, 2))
|
||||
to <- parseSquare(s.substring(2, 4))
|
||||
yield (from, to)
|
||||
|
||||
private def parseSquare(s: String): Option[Square] =
|
||||
Option.when(s.length == 2)(s).flatMap: sq =>
|
||||
val fileIdx = sq(0) - 'a'
|
||||
val rankIdx = sq(1) - '1'
|
||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
||||
)
|
||||
Option
|
||||
.when(s.length == 2)(s)
|
||||
.flatMap: sq =>
|
||||
val fileIdx = sq(0) - 'a'
|
||||
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.observer.*
|
||||
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.sets.DefaultRules
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
||||
* All rule queries delegate to the injected RuleSet.
|
||||
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
*/
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||
*/
|
||||
class GameEngine(
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules
|
||||
val initialContext: GameContext = GameContext.initial,
|
||||
val ruleSet: RuleSet = DefaultRules,
|
||||
) extends Observable:
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
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). */
|
||||
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized { currentContext.board }
|
||||
def turn: Color = synchronized { currentContext.turn }
|
||||
def context: GameContext = synchronized { currentContext }
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||
|
||||
/** 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). */
|
||||
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.
|
||||
* Notifies all observers of the outcome via GameEvent.
|
||||
*/
|
||||
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||
* GameEvent.
|
||||
*/
|
||||
def processUserInput(rawInput: String): Unit = synchronized {
|
||||
val trimmed = rawInput.trim.toLowerCase
|
||||
trimmed match
|
||||
@@ -62,10 +63,12 @@ class GameEngine(
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentContext))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentContext,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(
|
||||
currentContext,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||
),
|
||||
)
|
||||
|
||||
case "" =>
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||
@@ -73,10 +76,12 @@ class GameEngine(
|
||||
case moveInput =>
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentContext,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
))
|
||||
notifyObservers(
|
||||
InvalidMoveEvent(
|
||||
currentContext,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||
),
|
||||
)
|
||||
case Some((from, to)) =>
|
||||
handleParsedMove(from, to)
|
||||
}
|
||||
@@ -108,9 +113,8 @@ class GameEngine(
|
||||
to.rank.ordinal == promoRank
|
||||
}
|
||||
|
||||
/** Apply a player's promotion piece choice.
|
||||
* Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||
pendingPromotion match
|
||||
case None =>
|
||||
@@ -120,23 +124,19 @@ class GameEngine(
|
||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||
// Verify it's actually legal
|
||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||
if legal.contains(move) then
|
||||
executeMove(move)
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
if legal.contains(move) then executeMove(move)
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized { performUndo() }
|
||||
def undo(): Unit = synchronized(performUndo())
|
||||
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized { performRedo() }
|
||||
def redo(): Unit = synchronized(performRedo())
|
||||
|
||||
/** Load a game using the provided importer.
|
||||
* If the imported context has moves, they are replayed through the command system.
|
||||
* Otherwise, the position is set directly.
|
||||
* Notifies observers with PgnLoadedEvent on success.
|
||||
*/
|
||||
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
|
||||
*/
|
||||
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||
importer.importGameContext(input) match
|
||||
case Left(err) => Left(err)
|
||||
@@ -155,29 +155,24 @@ class GameEngine(
|
||||
if ctx.moves.isEmpty then
|
||||
currentContext = ctx
|
||||
Right(())
|
||||
else
|
||||
replayMoves(ctx.moves, savedContext)
|
||||
else replayMoves(ctx.moves, savedContext)
|
||||
|
||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||
var error: Option[String] = None
|
||||
moves.foreach: move =>
|
||||
if error.isEmpty then
|
||||
handleParsedMove(move.from, move.to)
|
||||
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||
acc.flatMap(_ => applyReplayMove(move))
|
||||
}
|
||||
result.left.foreach(_ => currentContext = savedContext)
|
||||
result
|
||||
|
||||
move.moveType match {
|
||||
case MoveType.Promotion(pp) =>
|
||||
if pendingPromotion.isDefined then
|
||||
completePromotion(pp)
|
||||
else
|
||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||
case _ => ()
|
||||
}
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentContext = savedContext
|
||||
Left(err)
|
||||
case None =>
|
||||
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||
handleParsedMove(move.from, move.to)
|
||||
move.moveType match
|
||||
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
|
||||
completePromotion(pp)
|
||||
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. */
|
||||
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||
@@ -203,25 +198,27 @@ class GameEngine(
|
||||
|
||||
private def executeMove(move: Move): Unit =
|
||||
val contextBefore = currentContext
|
||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||
val captured = computeCaptured(currentContext, move)
|
||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||
val captured = computeCaptured(currentContext, move)
|
||||
|
||||
val cmd = MoveCommand(
|
||||
from = move.from,
|
||||
to = move.to,
|
||||
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||
previousContext = Some(contextBefore),
|
||||
notation = translateMoveToNotation(move, contextBefore.board)
|
||||
notation = translateMoveToNotation(move, contextBefore.board),
|
||||
)
|
||||
invoker.execute(cmd)
|
||||
currentContext = nextContext
|
||||
|
||||
notifyObservers(MoveExecutedEvent(
|
||||
currentContext,
|
||||
move.from.toString,
|
||||
move.to.toString,
|
||||
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
))
|
||||
notifyObservers(
|
||||
MoveExecutedEvent(
|
||||
currentContext,
|
||||
move.from.toString,
|
||||
move.to.toString,
|
||||
captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
|
||||
),
|
||||
)
|
||||
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
@@ -232,18 +229,16 @@ class GameEngine(
|
||||
notifyObservers(StalemateEvent(currentContext))
|
||||
invoker.clear()
|
||||
currentContext = GameContext.initial
|
||||
else if ruleSet.isCheck(currentContext) then
|
||||
notifyObservers(CheckDetectedEvent(currentContext))
|
||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||
|
||||
if currentContext.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
|
||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => enPassantNotation(move)
|
||||
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => enPassantNotation(move)
|
||||
case MoveType.Promotion(pp) => promotionNotation(move, pp)
|
||||
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
|
||||
|
||||
private def enPassantNotation(move: Move): String =
|
||||
@@ -295,8 +290,7 @@ class GameEngine(
|
||||
moveCmd.previousContext.foreach(currentContext = _)
|
||||
invoker.undo()
|
||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||
|
||||
private def performRedo(): Unit =
|
||||
if invoker.canRedo then
|
||||
@@ -307,12 +301,13 @@ class GameEngine(
|
||||
currentContext = nextCtx
|
||||
invoker.redo()
|
||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveRedoneEvent(
|
||||
currentContext,
|
||||
moveCmd.notation,
|
||||
moveCmd.from.toString,
|
||||
moveCmd.to.toString,
|
||||
capturedDesc
|
||||
))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||
notifyObservers(
|
||||
MoveRedoneEvent(
|
||||
currentContext,
|
||||
moveCmd.notation,
|
||||
moveCmd.from.toString,
|
||||
moveCmd.to.toString,
|
||||
capturedDesc,
|
||||
),
|
||||
)
|
||||
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.game.GameContext
|
||||
|
||||
/** Base trait for all game state events.
|
||||
* Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
sealed trait GameEvent:
|
||||
def context: GameContext
|
||||
|
||||
/** Fired when a move is successfully executed. */
|
||||
case class MoveExecutedEvent(
|
||||
context: GameContext,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
context: GameContext,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String],
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the current player is in check. */
|
||||
case class CheckDetectedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches checkmate. */
|
||||
case class CheckmateEvent(
|
||||
context: GameContext,
|
||||
winner: Color
|
||||
context: GameContext,
|
||||
winner: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches stalemate. */
|
||||
case class StalemateEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is invalid. */
|
||||
case class InvalidMoveEvent(
|
||||
context: GameContext,
|
||||
reason: String
|
||||
context: GameContext,
|
||||
reason: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
to: Square,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||
case class MoveUndoneEvent(
|
||||
context: GameContext,
|
||||
pgnNotation: String
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||
case class MoveRedoneEvent(
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
context: GameContext,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String],
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||
case class PgnLoadedEvent(
|
||||
context: GameContext
|
||||
context: GameContext,
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
|
||||
+26
-23
@@ -1,6 +1,6 @@
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
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 case class FailingCommand() extends Command:
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Failing command"
|
||||
|
||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||
override def execute(): Boolean = !shouldFailOnExecute
|
||||
override def undo(): Boolean = !shouldFailOnUndo
|
||||
private case class ConditionalFailCommand(
|
||||
var shouldFailOnUndo: Boolean = false,
|
||||
var shouldFailOnExecute: Boolean = false,
|
||||
) extends Command:
|
||||
override def execute(): Boolean = !shouldFailOnExecute
|
||||
override def undo(): Boolean = !shouldFailOnUndo
|
||||
override def description: String = "Conditional fail"
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||
@@ -24,12 +27,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
from = from,
|
||||
to = to,
|
||||
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"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = FailingCommand()
|
||||
val cmd = FailingCommand()
|
||||
invoker.execute(cmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
@@ -52,8 +55,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
@@ -62,7 +65,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val invoker = new CommandInvoker()
|
||||
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||
invoker.execute(failingUndoCmd) 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()
|
||||
invoker.execute(successUndoCmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
@@ -85,15 +88,15 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
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.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
}
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val redoFailCmd = ConditionalFailCommand()
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd)
|
||||
@@ -106,7 +109,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
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.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
@@ -115,9 +118,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
@@ -130,10 +133,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
{
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.execute(cmd3)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
|
||||
test("execute appends commands and updates index"):
|
||||
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.history.size shouldBe 1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
@@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("undo and redo update index and availability flags"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.canUndo shouldBe false
|
||||
invoker.execute(cmd)
|
||||
invoker.canUndo shouldBe true
|
||||
@@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("clear removes full history and resets index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.clear()
|
||||
invoker.history.size shouldBe 0
|
||||
@@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("execute after undo discards redo history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, 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 cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
val executable = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
)
|
||||
executable.execute() shouldBe true
|
||||
|
||||
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
undoable.undo() shouldBe true
|
||||
|
||||
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
val result = MoveResult.Successful(GameContext.initial, None)
|
||||
val cmd2 = cmd1.copy(
|
||||
moveResult = Some(result),
|
||||
previousContext = Some(GameContext.initial)
|
||||
previousContext = Some(GameContext.initial),
|
||||
)
|
||||
|
||||
cmd1.moveResult shouldBe None
|
||||
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
previousContext = None,
|
||||
)
|
||||
|
||||
val eq2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousContext = None
|
||||
previousContext = None,
|
||||
)
|
||||
|
||||
eq1 shouldBe eq2
|
||||
|
||||
@@ -27,7 +27,7 @@ object EngineTestHelpers:
|
||||
private val _events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
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 =
|
||||
_events.exists(ct.runtimeClass.isInstance(_))
|
||||
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
|
||||
|
||||
+23
-14
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
|
||||
|
||||
import scala.collection.mutable
|
||||
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.matchers.should.Matchers
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles Checkmate (Fool's Mate)"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -33,7 +33,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -55,21 +55,30 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
// 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
|
||||
test("GameEngine handles Stalemate via 10-move known sequence"):
|
||||
val engine = new GameEngine()
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
"c8e6",
|
||||
)
|
||||
|
||||
moves.dropRight(1).foreach(engine.processUserInput)
|
||||
|
||||
+18
-21
@@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
if square == sq("e2") then List(promotionMove) else List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
|
||||
|
||||
val engine = new GameEngine(ruleSet = permissiveRules)
|
||||
@@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val noLegalMoves = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
|
||||
def allLegalMoves(context: GameContext): List[Move] = List.empty
|
||||
def isCheck(context: GameContext): Boolean = false
|
||||
def isCheckmate(context: GameContext): Boolean = false
|
||||
def isStalemate(context: GameContext): Boolean = false
|
||||
def isInsufficientMaterial(context: GameContext): Boolean = false
|
||||
def isFiftyMoveRule(context: GameContext): Boolean = false
|
||||
def applyMove(context: GameContext)(move: Move): GameContext = context
|
||||
|
||||
val engine = new GameEngine(ruleSet = noLegalMoves)
|
||||
engine.processUserInput("e2e4")
|
||||
@@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
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.context.moves.lastOption shouldBe Some(normalMove)
|
||||
|
||||
test("replayMoves skips later moves after the first move triggers an error"):
|
||||
val engine = new GameEngine()
|
||||
val saved = engine.context
|
||||
val engine = new GameEngine()
|
||||
val saved = engine.context
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||
engine.context shouldBe saved
|
||||
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
val engine = new GameEngine()
|
||||
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
||||
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
engine.observerCount shouldBe 1
|
||||
engine.unsubscribe(observer)
|
||||
engine.observerCount shouldBe 0
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
|
||||
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
|
||||
import de.nowchess.io.pgn.PgnParser
|
||||
import de.nowchess.io.fen.FenParser
|
||||
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"):
|
||||
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)
|
||||
result shouldBe Right(())
|
||||
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"):
|
||||
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)
|
||||
result shouldBe Right(())
|
||||
engine.context.moves.isEmpty shouldBe true
|
||||
|
||||
@@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
/** Tests that exercise moveToPgn branches not covered by other test files:
|
||||
* - CastleQueenside (line 223)
|
||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||
* - Promotion(Bishop) notation (line 230)
|
||||
* - King normal move notation (line 246)
|
||||
*/
|
||||
* - CastleQueenside (line 223)
|
||||
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
|
||||
* - Promotion(Bishop) notation (line 230)
|
||||
* - King normal move notation (line 246)
|
||||
*/
|
||||
class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||
@@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
|
||||
// Castling rights: white queen-side only (no king-side rook present)
|
||||
val castlingRights = de.nowchess.api.board.CastlingRights(
|
||||
whiteKingSide = false,
|
||||
whiteKingSide = false,
|
||||
whiteQueenSide = true,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false
|
||||
blackKingSide = false,
|
||||
blackQueenSide = false,
|
||||
)
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White castles queenside: e1c1
|
||||
engine.processUserInput("e1c1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
|
||||
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
|
||||
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
|
||||
val epSquare = Square.fromAlgebraic("d6")
|
||||
val ctx = GameContext.initial
|
||||
.withBoard(board)
|
||||
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// White pawn on e5 captures en passant to d6
|
||||
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)
|
||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||
moveEvt.capturedPiece shouldBe defined
|
||||
moveEvt.capturedPiece.get should include ("Black")
|
||||
moveEvt.capturedPiece.get should include("Black")
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
@@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// King moves e1 -> f1
|
||||
engine.processUserInput("e1f1")
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||
|
||||
events.clear()
|
||||
engine.undo()
|
||||
|
||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||
evt.pgnNotation should startWith ("K")
|
||||
evt.pgnNotation should include ("f1")
|
||||
evt.pgnNotation should startWith("K")
|
||||
evt.pgnNotation should include("f1")
|
||||
|
||||
@@ -10,7 +10,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Checkmate ───────────────────────────────────────────────────
|
||||
|
||||
test("checkmate ends game with CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -24,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
test("checkmate with white winner"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -45,20 +45,29 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Stalemate ───────────────────────────────────────────────────
|
||||
|
||||
test("stalemate ends game with StalemateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
)
|
||||
moves.foreach(engine.processUserInput)
|
||||
observer.clear()
|
||||
@@ -68,21 +77,30 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[StalemateEvent] shouldBe true
|
||||
|
||||
test("stalemate when king has no moves and no pieces"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
"e2e3",
|
||||
"a7a5",
|
||||
"d1h5",
|
||||
"a8a6",
|
||||
"h5a5",
|
||||
"h7h5",
|
||||
"h2h4",
|
||||
"a6h6",
|
||||
"a5c7",
|
||||
"f7f6",
|
||||
"c7d7",
|
||||
"e8f7",
|
||||
"d7b7",
|
||||
"d8d3",
|
||||
"b7b8",
|
||||
"d3h7",
|
||||
"b8c8",
|
||||
"f7g6",
|
||||
"c8e6",
|
||||
)
|
||||
|
||||
moves.foreach(engine.processUserInput)
|
||||
@@ -93,7 +111,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Check detection ────────────────────────────────────────────
|
||||
|
||||
test("check detected after move puts king in check"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -108,7 +126,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("check by knight"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -122,7 +140,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move rule triggers when half-move clock reaches 100"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -155,7 +173,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
// ── Draw claim ────────────────────────────────────────────────
|
||||
|
||||
test("draw can be claimed when fifty-move rule is available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -167,7 +185,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[DrawClaimedEvent] shouldBe true
|
||||
|
||||
test("draw cannot be claimed when not available"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
+41
-41
@@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||
}
|
||||
|
||||
test("isPendingPromotion is true after PromotionRequired input") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
engine.isPendingPromotion should be (true)
|
||||
engine.isPendingPromotion should be(true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is false before any promotion input") {
|
||||
val engine = new GameEngine()
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||
engine.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") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val engine = engineWith(promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||
}
|
||||
|
||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||
@@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
}
|
||||
|
||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
val engine = engineWith(promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
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") {
|
||||
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 events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||
}
|
||||
|
||||
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 events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
|
||||
}
|
||||
|
||||
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 events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
|
||||
}
|
||||
|
||||
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 events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
engine.isPendingPromotion should be(false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
|
||||
val engine = new GameEngine(initialCtx, delegatingRuleSet)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be (true)
|
||||
engine.isPendingPromotion should be(true)
|
||||
|
||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||
// but only Normal moves exist → fires InvalidMoveEvent
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
engine.isPendingPromotion should be(false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||
invalidEvt.reason should include ("Error completing promotion")
|
||||
invalidEvt.reason should include("Error completing promotion")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.chess.observer.*
|
||||
import de.nowchess.io.fen.FenParser
|
||||
@@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Observer wiring ────────────────────────────────────────────
|
||||
|
||||
test("observer subscribe and unsubscribe behavior"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
@@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Invalid moves (minimal) ────────────────────────────────────
|
||||
|
||||
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
engine.turn shouldBe Color.White // turn unchanged
|
||||
engine.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
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e5e4") // pawn backward
|
||||
engine.processUserInput("e5e4") // pawn backward
|
||||
|
||||
observer.hasEvent[InvalidMoveEvent] shouldBe true
|
||||
|
||||
// ── Undo/Redo ────────────────────────────────────────────────
|
||||
|
||||
test("undo redo success and empty-history failures"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
|
||||
// ── Fifty-move rule ────────────────────────────────────────────
|
||||
|
||||
test("fifty-move event and draw claim success/failure"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── Castling ────────────────────────────────────────────────────
|
||||
|
||||
test("kingside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("queenside castling executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("undo castling emits PGN notation"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── En passant ──────────────────────────────────────────────────
|
||||
|
||||
test("en passant capture executes successfully"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
observer.hasEvent[MoveExecutedEvent] shouldBe true
|
||||
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"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
// ── Pawn promotion ─────────────────────────────────────────────
|
||||
|
||||
test("pawn reaching back rank requires promotion"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckDetectedEvent] shouldBe true
|
||||
|
||||
test("promotion to Queen with checkmate emits CheckmateEvent"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
@@ -171,7 +171,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
|
||||
test("undo promotion emits notation with piece suffix"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
|
||||
import scala.util.Try
|
||||
|
||||
/** Service for persisting and loading game states to/from disk.
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer.
|
||||
* Handles both reading and writing game files.
|
||||
*/
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||
*/
|
||||
trait GameFileService:
|
||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
|
||||
()
|
||||
}.fold(
|
||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||
_ => Right(())
|
||||
_ => Right(()),
|
||||
)
|
||||
|
||||
/** Load a game context from a file using the specified importer. */
|
||||
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
|
||||
importer.importGameContext(json)
|
||||
}.fold(
|
||||
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. */
|
||||
private def buildRankString(board: Board, rank: Rank): String =
|
||||
val rankSquares = File.values.map(file => Square(file, rank))
|
||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
||||
var emptyCount = 0
|
||||
|
||||
for square <- rankSquares do
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
if emptyCount > 0 then
|
||||
rankChars += emptyCount.toString.charAt(0)
|
||||
emptyCount = 0
|
||||
rankChars += pieceToFenChar(piece)
|
||||
case None =>
|
||||
emptyCount += 1
|
||||
|
||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
||||
rankChars.mkString
|
||||
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
|
||||
case ((acc, empty), square) =>
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
val flushed = if empty > 0 then acc + empty.toString else acc
|
||||
(flushed + pieceToFenChar(piece), 0)
|
||||
case None =>
|
||||
(acc, empty + 1)
|
||||
if emptyCount > 0 then result + emptyCount.toString else result
|
||||
|
||||
/** Convert a GameContext to a complete FEN string. */
|
||||
def gameContextToFen(context: GameContext): String =
|
||||
val piecePlacement = boardToFen(context.board)
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val fullMoveNumber = 1 + (context.moves.length / 2)
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||
|
||||
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
|
||||
|
||||
/** Convert castling rights to FEN notation. */
|
||||
private def castlingString(rights: CastlingRights): String =
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val result = s"$wk$wq$bk$bq"
|
||||
if result.isEmpty then "-" else result
|
||||
|
||||
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
|
||||
|
||||
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
|
||||
|
||||
object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse a complete FEN string into a GameContext.
|
||||
* Returns Left with error message if the format is invalid. */
|
||||
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
|
||||
*/
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
val parts = fen.trim.split("\\s+")
|
||||
if parts.length != 6 then
|
||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
else
|
||||
for
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = activeColor,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassant,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
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. */
|
||||
private def parseCastling(s: String): Option[CastlingRights] =
|
||||
if s == "-" then
|
||||
Some(CastlingRights.None)
|
||||
if s == "-" then Some(CastlingRights.None)
|
||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||
Some(CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
))
|
||||
else
|
||||
None
|
||||
Some(
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
),
|
||||
)
|
||||
else None
|
||||
|
||||
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
||||
private def parseEnPassant(s: String): Option[Option[Square]] =
|
||||
if s == "-" then Some(None)
|
||||
else Square.fromAlgebraic(s).map(Some(_))
|
||||
|
||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
|
||||
* Returns None if the format is invalid. */
|
||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format
|
||||
* is invalid.
|
||||
*/
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
val rankStrings = fen.split("/", -1)
|
||||
if rankStrings.length != 8 then None
|
||||
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
|
||||
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
||||
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
||||
|
||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
|
||||
* Returns None if the rank string contains invalid characters or the wrong number of files. */
|
||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the
|
||||
* rank string contains invalid characters or the wrong number of files.
|
||||
*/
|
||||
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
||||
var fileIdx = 0
|
||||
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
|
||||
var failed = false
|
||||
|
||||
for c <- rankStr if !failed do
|
||||
if fileIdx > 7 then
|
||||
failed = true
|
||||
else if c.isDigit then
|
||||
fileIdx += c.asDigit
|
||||
else
|
||||
charToPiece(c) match
|
||||
case None => failed = true
|
||||
case Some(piece) =>
|
||||
val file = File.values(fileIdx)
|
||||
squares += (Square(file, rank) -> piece)
|
||||
fileIdx += 1
|
||||
|
||||
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
|
||||
case ((idx, true, acc), _) => (idx, true, acc)
|
||||
case ((idx, false, acc), c) =>
|
||||
if idx > 7 then (idx, true, acc)
|
||||
else if c.isDigit then (idx + c.asDigit, false, acc)
|
||||
else
|
||||
charToPiece(c) match
|
||||
case None => (idx, true, acc)
|
||||
case Some(piece) =>
|
||||
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
|
||||
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. */
|
||||
private def charToPiece(c: Char): Option[Piece] =
|
||||
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
|
||||
case 'k' => Some(PieceType.King)
|
||||
case _ => None
|
||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def pieceChar: Parser[Piece] =
|
||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||
val c = s.head
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
|
||||
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
|
||||
* placement exceeds board bounds.
|
||||
*/
|
||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||
rankTokens >> { tokens =>
|
||||
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. */
|
||||
private def boardParser: Parser[Board] =
|
||||
rankParser(Rank.R8) ~
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ {
|
||||
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def castlingParser: Parser[CastlingRights] =
|
||||
"-" ^^^ CastlingRights.None |
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser: Parser[Option[Square]] =
|
||||
"-" ^^^ Option.empty[Square] |
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def fenParser: Parser[GameContext] =
|
||||
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
)
|
||||
}
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def pieceChar(using P[Any]): P[Piece] =
|
||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||
val c = s.head
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
@@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def boardParser(using P[Any]): P[Board] =
|
||||
(rankParser(Rank.R8) ~ sep ~
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
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] =
|
||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser(using P[Any]): P[Option[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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def fenParser(using P[Any]): P[GameContext] =
|
||||
(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, _) =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,19 +14,20 @@ private[fen] object FenParserSupport:
|
||||
'n' -> PieceType.Knight,
|
||||
'b' -> PieceType.Bishop,
|
||||
'q' -> PieceType.Queen,
|
||||
'k' -> PieceType.King
|
||||
'k' -> PieceType.King,
|
||||
)
|
||||
|
||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
tokens
|
||||
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
|
||||
@@ -8,18 +8,18 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
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.
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
object JsonExporter extends GameContextExport:
|
||||
private val mapper = createMapper()
|
||||
|
||||
@@ -29,7 +29,7 @@ object JsonExporter extends GameContextExport:
|
||||
|
||||
// Configure pretty printer with custom spacing to match test expectations
|
||||
val indenter = new DefaultIndenter(" ", "\n")
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
printer.indentArraysWith(indenter)
|
||||
printer.indentObjectsWith(indenter)
|
||||
|
||||
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
|
||||
formatJson(mapper.writeValueAsString(record))
|
||||
|
||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||
val pgn = try {
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
} catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
val pgn =
|
||||
try
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
JsonGameRecord(
|
||||
metadata = Some(buildMetadata()),
|
||||
gameState = Some(buildGameState(context)),
|
||||
moveHistory = pgn,
|
||||
moves = Some(buildMoves(context.moves)),
|
||||
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 =
|
||||
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
|
||||
event = Some("Game"),
|
||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||
date = Some(LocalDate.now().toString),
|
||||
result = Some("*")
|
||||
result = Some("*"),
|
||||
)
|
||||
|
||||
private def buildGameState(context: GameContext): JsonGameState =
|
||||
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
|
||||
turn = Some(context.turn.label),
|
||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||
halfMoveClock = Some(context.halfMoveClock)
|
||||
halfMoveClock = Some(context.halfMoveClock),
|
||||
)
|
||||
|
||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
|
||||
Some(rights.whiteKingSide),
|
||||
Some(rights.whiteQueenSide),
|
||||
Some(rights.blackKingSide),
|
||||
Some(rights.blackQueenSide)
|
||||
Some(rights.blackQueenSide),
|
||||
)
|
||||
|
||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||
@@ -128,7 +129,7 @@ object JsonExporter extends GameContextExport:
|
||||
val captured = Square.all.flatMap { square =>
|
||||
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||
board.pieceAt(square) match
|
||||
case None => Some(initialPiece)
|
||||
case None => Some(initialPiece)
|
||||
case Some(_) => None
|
||||
}
|
||||
}
|
||||
@@ -136,4 +137,3 @@ object JsonExporter extends GameContextExport:
|
||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||
(blackCaptured, whiteCaptured)
|
||||
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
case class JsonMetadata(
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonPiece(
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonCastlingRights(
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None,
|
||||
)
|
||||
|
||||
case class JsonGameState(
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None,
|
||||
)
|
||||
|
||||
case class JsonCapturedPieces(
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None,
|
||||
)
|
||||
|
||||
case class JsonMoveType(
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonMove(
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None,
|
||||
)
|
||||
|
||||
case class JsonGameRecord(
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
|
||||
import scala.util.Try
|
||||
|
||||
/** Imports a GameContext from JSON format using Jackson.
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
object JsonParser extends GameContextImport:
|
||||
|
||||
private val mapper = new ObjectMapper()
|
||||
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
||||
.left.map(e => "JSON parsing error: " + e.getMessage)
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||
.map(e => "JSON parsing error: " + e.getMessage)
|
||||
.flatMap { data =>
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val rawBoard = gs.board.getOrElse(Nil)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawMoves = data.moves.getOrElse(Nil)
|
||||
|
||||
for
|
||||
board <- parseBoard(rawBoard)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||
moves <- parseMoves(rawMoves)
|
||||
yield GameContext(
|
||||
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = rawHmc,
|
||||
moves = moves
|
||||
moves = moves,
|
||||
)
|
||||
}
|
||||
|
||||
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||
val parsedPieces = pieces.flatMap { p =>
|
||||
for
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
color <- p.color.flatMap(parseColor)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
yield (sq, Piece(color, pt))
|
||||
}
|
||||
Right(Board(parsedPieces.toMap))
|
||||
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
|
||||
|
||||
private def parsePieceType(pt: String): Option[PieceType] =
|
||||
pt match
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Knight" => Some(PieceType.Knight)
|
||||
case "Bishop" => Some(PieceType.Bishop)
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||
CastlingRights(
|
||||
cr.whiteKingSide.getOrElse(false),
|
||||
cr.whiteQueenSide.getOrElse(false),
|
||||
cr.blackKingSide.getOrElse(false),
|
||||
cr.blackQueenSide.getOrElse(false)
|
||||
cr.blackQueenSide.getOrElse(false),
|
||||
)
|
||||
|
||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||
Right(moves.flatMap { m =>
|
||||
for
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
moveType <- m.`type`.flatMap(parseMoveType)
|
||||
yield Move(from, to, moveType)
|
||||
})
|
||||
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
|
||||
Some(MoveType.EnPassant)
|
||||
case Some("promotion") =>
|
||||
val piece = mt.promotionPiece match
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("bishop") => PromotionPiece.Bishop
|
||||
case Some("knight") => PromotionPiece.Knight
|
||||
case _ => PromotionPiece.Queen // default
|
||||
case _ => PromotionPiece.Queen // default
|
||||
Some(MoveType.Promotion(piece))
|
||||
case _ => None
|
||||
|
||||
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*"
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*",
|
||||
)
|
||||
|
||||
exportGame(headers, context.moves)
|
||||
|
||||
/** Export a game with headers and moves to PGN format. */
|
||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if moves.isEmpty then ""
|
||||
else
|
||||
var ctx = GameContext.initial
|
||||
val sanMoves = moves.map { move =>
|
||||
val algebraic = moveToAlgebraic(move, ctx.board)
|
||||
ctx = DefaultRules.applyMove(ctx)(move)
|
||||
algebraic
|
||||
val headerLines = headers
|
||||
.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}
|
||||
.mkString("\n")
|
||||
|
||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
val moveText =
|
||||
if moves.isEmpty then ""
|
||||
else
|
||||
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
|
||||
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
case MoveType.Promotion(pp) =>
|
||||
case MoveType.Promotion(pp) =>
|
||||
val promSuffix = pp match
|
||||
case PromotionPiece.Queen => "=Q"
|
||||
case PromotionPiece.Rook => "=R"
|
||||
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
|
||||
case PieceType.Rook => s"R$capStr$dest"
|
||||
case PieceType.Queen => s"Q$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. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[Move]
|
||||
headers: Map[String, String],
|
||||
moves: List[Move],
|
||||
)
|
||||
|
||||
object PgnParser extends GameContextImport:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
|
||||
*/
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
|
||||
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
|
||||
* issue.
|
||||
*/
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||
}
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
|
||||
* are silently skipped.
|
||||
*/
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
Some(PgnGame(headers, moves))
|
||||
|
||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
|
||||
private def parseMovesText(moveText: String): List[Move] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
val (_, _, moves) = tokens.foldLeft(
|
||||
(GameContext.initial, Color.White, List.empty[Move])
|
||||
(GameContext.initial, Color.White, List.empty[Move]),
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case None => state
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
(nextCtx, color.opposite, acc :+ move)
|
||||
moves
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
Square
|
||||
.fromAlgebraic(destStr)
|
||||
.flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val promotion = extractPromotion(notation)
|
||||
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board.pieceAt(move.from).exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType)
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board
|
||||
.pieceAt(move.from)
|
||||
.exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType),
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
|
||||
candidates.headOption
|
||||
candidates.headOption
|
||||
|
||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
hint.forall(c =>
|
||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||
else true
|
||||
else true,
|
||||
)
|
||||
|
||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||
promotion match
|
||||
case None => move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case None =>
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
||||
case (acc, token) =>
|
||||
tokens
|
||||
.foldLeft(
|
||||
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
|
||||
) { case (acc, token) =>
|
||||
acc.flatMap { case (ctx, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
}
|
||||
.map(_._3)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
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.io.json.{JsonExporter, JsonParser}
|
||||
import java.nio.file.{Files, Paths}
|
||||
@@ -15,13 +15,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||
try
|
||||
val context = GameContext.initial
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(Files.exists(tmpFile))
|
||||
assert(Files.size(tmpFile) > 0)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: reads JSON file successfully") {
|
||||
@@ -38,13 +37,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
assert(result.isRight)
|
||||
val loaded = result.getOrElse(GameContext.initial)
|
||||
assert(loaded == originalContext)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: returns error on missing file") {
|
||||
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)
|
||||
}
|
||||
@@ -65,8 +63,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: overwrites existing file") {
|
||||
@@ -78,7 +75,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val size1 = Files.size(tmpFile)
|
||||
|
||||
// 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)
|
||||
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
||||
|
||||
@@ -86,8 +83,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 1)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: handles invalid JSON in file") {
|
||||
@@ -97,8 +93,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
assert(result.isLeft)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("round-trip: save and load preserves game state") {
|
||||
@@ -118,8 +113,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
assert(loaded.halfMoveClock == 3)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: handles exporter that throws exception") {
|
||||
@@ -134,6 +128,5 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
@@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers
|
||||
class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def context(
|
||||
piecePlacement: String,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moveCount: Int
|
||||
piecePlacement: String,
|
||||
turn: Color,
|
||||
castlingRights: CastlingRights,
|
||||
enPassantSquare: Option[Square],
|
||||
halfMoveClock: Int,
|
||||
moveCount: Int,
|
||||
): GameContext =
|
||||
val board = FenParser.parseBoard(piecePlacement).getOrElse(
|
||||
fail(s"Invalid test board FEN: $piecePlacement")
|
||||
)
|
||||
val board = FenParser
|
||||
.parseBoard(piecePlacement)
|
||||
.getOrElse(
|
||||
fail(s"Invalid test board FEN: $piecePlacement"),
|
||||
)
|
||||
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
||||
GameContext(
|
||||
board = board,
|
||||
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.fill(moveCount)(dummyMove)
|
||||
moves = List.fill(moveCount)(dummyMove),
|
||||
)
|
||||
|
||||
test("exportGameContextToFen handles initial and typical developed position"):
|
||||
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
castlingRights = CastlingRights.All,
|
||||
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
moveCount = 0,
|
||||
)
|
||||
FenExporter.gameContextToFen(gameContext) shouldBe
|
||||
"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,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moveCount = 0
|
||||
moveCount = 0,
|
||||
)
|
||||
FenExporter.gameContextToFen(noCastling) shouldBe
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
whiteKingSide = true,
|
||||
whiteQueenSide = false,
|
||||
blackKingSide = false,
|
||||
blackQueenSide = true
|
||||
blackQueenSide = true,
|
||||
),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 5,
|
||||
moveCount = 4
|
||||
moveCount = 4,
|
||||
)
|
||||
FenExporter.gameContextToFen(partialCastling) shouldBe
|
||||
"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,
|
||||
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
||||
halfMoveClock = 2,
|
||||
moveCount = 4
|
||||
moveCount = 4,
|
||||
)
|
||||
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
||||
"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,
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 42,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
val fen = FenExporter.gameContextToFen(gameContext)
|
||||
FenParser.parseFen(fen) match
|
||||
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
val ctx = GameContext.initial
|
||||
|
||||
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"):
|
||||
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"
|
||||
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
|
||||
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(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(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
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 =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
FenParserCombinators
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
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"):
|
||||
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"
|
||||
|
||||
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)
|
||||
|
||||
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 =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
FenParserFastParse
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
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"):
|
||||
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"
|
||||
|
||||
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)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0,
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||
)
|
||||
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
FenParser
|
||||
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||
.fold(
|
||||
_ => fail(),
|
||||
ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
@@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
||||
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
|
||||
+21
-21
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
||||
(PromotionPiece.Queen, "queen"),
|
||||
(PromotionPiece.Rook, "rook"),
|
||||
(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))
|
||||
// Empty boards can cause issues in PgnExporter, using initial
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include (s""""$expectedName"""")
|
||||
json should include(s""""$expectedName"""")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export normal non-capture move") {
|
||||
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 json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"normal\"")
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
}
|
||||
|
||||
test("export normal capture move manually") {
|
||||
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 {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"normal\"")
|
||||
json should include ("\"isCapture\": true")
|
||||
json should include("\"normal\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export all move type categories") {
|
||||
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)
|
||||
|
||||
json should include ("\"moves\"")
|
||||
json should include ("\"from\"")
|
||||
json should include ("\"to\"")
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
}
|
||||
|
||||
test("export castle queenside move") {
|
||||
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 {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"castleQueenside\"")
|
||||
json should include("\"castleQueenside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle kingside move") {
|
||||
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 {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"castleKingside\"")
|
||||
json should include("\"castleKingside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export en passant move manually") {
|
||||
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 {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"enPassant\"")
|
||||
json should include ("\"isCapture\": true")
|
||||
json should include("\"enPassant\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -10,7 +10,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: exports initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"metadata\"")
|
||||
json should include("\"gameState\"")
|
||||
@@ -21,7 +21,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: includes board pieces") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"a1\"")
|
||||
json should include("\"Rook\"")
|
||||
@@ -30,23 +30,23 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: includes turn information") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"turn\": \"White\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: includes castling rights") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": true")
|
||||
json should include("\"whiteQueenSide\": true")
|
||||
}
|
||||
|
||||
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 json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
@@ -57,7 +57,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: valid JSON structure") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should startWith("{")
|
||||
json should endWith("}")
|
||||
@@ -67,46 +67,46 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: empty move history for initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports en passant square") {
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": \"e3\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports null en passant square") {
|
||||
val context = GameContext.initial.copy(enPassantSquare = None)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": null")
|
||||
}
|
||||
|
||||
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 json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports empty board") {
|
||||
val emptyBoard = Board(Map.empty)
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"board\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": false")
|
||||
json should include("\"whiteQueenSide\": false")
|
||||
|
||||
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
Some("White"),
|
||||
Some(JsonCastlingRights()),
|
||||
Some("e3"),
|
||||
Some(5)
|
||||
Some(5),
|
||||
)
|
||||
assert(gs.board.contains(Nil))
|
||||
assert(gs.halfMoveClock.contains(5))
|
||||
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
Some(""),
|
||||
Some(Nil),
|
||||
Some(JsonCapturedPieces()),
|
||||
Some("2026-04-08T00:00:00Z")
|
||||
Some("2026-04-08T00:00:00Z"),
|
||||
)
|
||||
assert(record.metadata.nonEmpty)
|
||||
assert(record.timestamp.nonEmpty)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse invalid turn color returns error") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "Invalid", "board": []},
|
||||
"moves": []
|
||||
@@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid piece type filters it out") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid color in board filters piece") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing turn uses default") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"board": []},
|
||||
"moves": []
|
||||
@@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing board uses empty") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White"},
|
||||
"moves": []
|
||||
@@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing moves uses empty list") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []}
|
||||
}"""
|
||||
@@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid square in board filters it") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse all valid piece types") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
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") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
|
||||
@@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse completely invalid JSON returns error") {
|
||||
val invalidJson = "{ this is not valid json at all }"
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
assert(result.isLeft)
|
||||
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") {
|
||||
val malformed = """{"metadata": {"unclosed": """
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse invalid JSON array returns error") {
|
||||
val invalidArray = "[1, 2, 3"
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse JSON with missing required fields") {
|
||||
val json = """{"metadata": {}}"""
|
||||
val json = """{"metadata": {}}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Should still succeed because all fields have defaults
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse valid JSON with invalid turn falls back to default") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse all move type variations") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "result": "*"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [
|
||||
@@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid move type defaults to None") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||
@@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse promotion with default piece") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"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") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"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") {
|
||||
val json = """{"invalid json"""
|
||||
val json = """{"invalid json"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse normal move with isCapture true") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
val ctx = result.toOption.get
|
||||
val move = ctx.moves.head
|
||||
assert(move.moveType == MoveType.Normal(true))
|
||||
}
|
||||
|
||||
test("parse board with invalid pieces filters them") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
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 org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: parses valid JSON") {
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
@@ -17,31 +17,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: restores board state") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result == Right(context))
|
||||
}
|
||||
|
||||
test("importGameContext: restores turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
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 json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles empty board") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||
"gameState": {
|
||||
"board": [],
|
||||
@@ -68,7 +68,8 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
assert(result.isRight)
|
||||
@@ -76,9 +77,9 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: handles castling rights") {
|
||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||
}
|
||||
@@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
.withMove(move2)
|
||||
.withTurn(Color.White)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val restored = JsonParser.importGameContext(json)
|
||||
|
||||
assert(restored.map(_.moves.length) == Right(2))
|
||||
@@ -100,8 +101,8 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: handles half-move clock") {
|
||||
val context = GameContext.initial.withHalfMoveClock(5)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.halfMoveClock) == Right(5))
|
||||
}
|
||||
@@ -109,27 +110,27 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
test("importGameContext: parses en passant square") {
|
||||
// Create a context with en passant square
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||
}
|
||||
|
||||
test("importGameContext: handles black turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||
// 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 json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
@@ -137,18 +138,18 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: handles all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||
}
|
||||
|
||||
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 json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(mixed))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGame renders headers and basic move text"):
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
|
||||
emptyPgn.contains("[Event \"Test\"]") shouldBe true
|
||||
emptyPgn.contains("[White \"A\"]") shouldBe true
|
||||
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
||||
|
||||
test("exportGame renders castling grouping and result markers"):
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
|
||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
|
||||
PgnExporter.exportGame(
|
||||
Map("Event" -> "Test"),
|
||||
List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)),
|
||||
) should include("O-O")
|
||||
PgnExporter.exportGame(
|
||||
Map("Event" -> "Test"),
|
||||
List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)),
|
||||
) should include("O-O-O")
|
||||
|
||||
val seq = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
|
||||
)
|
||||
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
||||
grouped should include("1. e4 c5")
|
||||
@@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGame handles promotion suffixes and normal move formatting"):
|
||||
List(
|
||||
PromotionPiece.Queen -> "=Q",
|
||||
PromotionPiece.Rook -> "=R",
|
||||
PromotionPiece.Queen -> "=Q",
|
||||
PromotionPiece.Rook -> "=R",
|
||||
PromotionPiece.Bishop -> "=B",
|
||||
PromotionPiece.Knight -> "=N"
|
||||
PromotionPiece.Knight -> "=N",
|
||||
).foreach { (piece, suffix) =>
|
||||
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
||||
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
|
||||
}
|
||||
|
||||
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
val normal =
|
||||
PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
normal should include("e4")
|
||||
normal should not include "="
|
||||
|
||||
test("exportGameContext preserves moves and default headers"):
|
||||
val moves = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
|
||||
)
|
||||
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
||||
withMoves.contains("e4") shouldBe true
|
||||
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
Move(sq("c7"), sq("c6")),
|
||||
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
|
||||
)
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||
@@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
pgn should include("Kxe2")
|
||||
|
||||
test("exportGame emits en-passant and promotion capture notation"):
|
||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
|
||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
||||
|
||||
pgn should include("exd3")
|
||||
pgn should include("exf8=Q")
|
||||
pawnCapturePgn should include("exd3")
|
||||
quietPromotionPgn should include("e8=Q")
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
|
||||
val headerOnly = """[Event "Test Game"]
|
||||
val headerOnly = """[Event "Test Game"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]"""
|
||||
@@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
capture.map(_.moves.length) shouldBe Some(3)
|
||||
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.from shouldBe Square(File.E, 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.from shouldBe Square(File.E, 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.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.from shouldBe Square(File.E, 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)
|
||||
PgnParser.parsePgn("""[Event "Test"]
|
||||
1. e4 e5 1-0""")
|
||||
.map(_.moves.length) shouldBe Some(2)
|
||||
PgnParser
|
||||
.parsePgn("""[Event "Test"]
|
||||
|
||||
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
|
||||
1. e4 INVALID e5""")
|
||||
.map(_.moves.length) shouldBe Some(2)
|
||||
|
||||
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
||||
val board = Board.initial
|
||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
|
||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||
File.E,
|
||||
Rank.R4,
|
||||
)
|
||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||
File.F,
|
||||
Rank.R3,
|
||||
)
|
||||
|
||||
val rookPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
val rankPieces: Map[Square, Piece] = Map(
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
)
|
||||
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White)
|
||||
.get
|
||||
.from shouldBe Square(File.A, Rank.R1)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White)
|
||||
.get
|
||||
.from shouldBe Square(File.A, Rank.R1)
|
||||
|
||||
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||
king.isDefined shouldBe true
|
||||
king.get.from shouldBe Square(File.E, Rank.R1)
|
||||
king.get.to shouldBe Square(File.E, Rank.R2)
|
||||
|
||||
test("parseAlgebraicMove handles all promotion targets"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
PgnParser
|
||||
.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
|
||||
.get
|
||||
.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
|
||||
test("importGameContext accepts valid and empty PGN"):
|
||||
val pgn = """[Event "Test"]
|
||||
@@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
||||
|
||||
test("parseAlgebraicMove rejects notation with invalid promotion piece"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||
val context = GameContext.initial.withBoard(board)
|
||||
|
||||
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
||||
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||
|
||||
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)
|
||||
|
||||
test("validatePgn rejects impossible illegal and garbage tokens"):
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. Qd4
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. O-O
|
||||
""").isLeft shouldBe true
|
||||
|
||||
PgnParser.validatePgn("""[Event "Test"]
|
||||
PgnParser
|
||||
.validatePgn("""[Event "Test"]
|
||||
|
||||
1. e4 GARBAGE e5
|
||||
""").isLeft shouldBe true
|
||||
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
test("validatePgn accepts empty move text and minimal valid header"):
|
||||
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
||||
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.move.Move
|
||||
|
||||
/** Extension point for chess rule variants (standard, Chess960, etc.).
|
||||
* All rule queries are stateless: given a GameContext, return the answer.
|
||||
*/
|
||||
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
|
||||
* GameContext, return the answer.
|
||||
*/
|
||||
trait RuleSet:
|
||||
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move]
|
||||
@@ -32,8 +32,7 @@ trait RuleSet:
|
||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||
def isFiftyMoveRule(context: GameContext): Boolean
|
||||
|
||||
/** Apply a legal move to produce the next game context.
|
||||
* Handles all special move types: castling, en passant, promotion.
|
||||
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
|
||||
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
def applyMove(context: GameContext)(move: Move): GameContext
|
||||
|
||||
@@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet
|
||||
|
||||
import scala.annotation.tailrec
|
||||
|
||||
/** Standard chess rules implementation.
|
||||
* Handles move generation, validation, check/checkmate/stalemate detection.
|
||||
*/
|
||||
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
|
||||
*/
|
||||
object DefaultRules extends RuleSet:
|
||||
|
||||
// ── Direction vectors ──────────────────────────────────────────────
|
||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
|
||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
|
||||
private val KnightJumps: List[(Int, Int)] =
|
||||
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
|
||||
|
||||
// ── Pawn configuration helpers ─────────────────────────────────────
|
||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
||||
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
|
||||
private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
|
||||
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
|
||||
|
||||
@@ -29,13 +28,14 @@ object DefaultRules extends RuleSet:
|
||||
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
||||
if piece.color != context.turn then List.empty[Move]
|
||||
else piece.pieceType match
|
||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
||||
else
|
||||
piece.pieceType match
|
||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
|
||||
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
|
||||
case PieceType.King => kingCandidates(context, square, piece.color)
|
||||
}
|
||||
|
||||
override def legalMoves(context: GameContext)(square: Square): List[Move] =
|
||||
@@ -65,18 +65,18 @@ object DefaultRules extends RuleSet:
|
||||
// ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
|
||||
|
||||
private def slidingMoves(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dirs: List[(Int, Int)]
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dirs: List[(Int, Int)],
|
||||
): List[Move] =
|
||||
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
||||
|
||||
private def castRay(
|
||||
board: Board,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dir: (Int, Int)
|
||||
board: Board,
|
||||
from: Square,
|
||||
color: Color,
|
||||
dir: (Int, Int),
|
||||
): List[Move] =
|
||||
@tailrec
|
||||
def loop(sq: Square, acc: List[Move]): List[Move] =
|
||||
@@ -84,40 +84,40 @@ object DefaultRules extends RuleSet:
|
||||
case None => acc
|
||||
case Some(next) =>
|
||||
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(_) => acc
|
||||
case Some(_) => acc
|
||||
loop(from, Nil).reverse
|
||||
|
||||
// ── Knight ─────────────────────────────────────────────────────────
|
||||
|
||||
private def knightCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
KnightJumps.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
// ── King ───────────────────────────────────────────────────────────
|
||||
|
||||
private def kingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
val steps = QueenDirs.flatMap { (df, dr) =>
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
steps ++ castlingCandidates(context, from, color)
|
||||
@@ -125,17 +125,17 @@ object DefaultRules extends RuleSet:
|
||||
// ── Castling ───────────────────────────────────────────────────────
|
||||
|
||||
private case class CastlingMove(
|
||||
kingFromAlg: String,
|
||||
kingToAlg: String,
|
||||
middleAlg: String,
|
||||
rookFromAlg: String,
|
||||
moveType: MoveType
|
||||
kingFromAlg: String,
|
||||
kingToAlg: String,
|
||||
middleAlg: String,
|
||||
rookFromAlg: String,
|
||||
moveType: MoveType,
|
||||
)
|
||||
|
||||
private def castlingCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
color match
|
||||
case Color.White => whiteCastles(context, from)
|
||||
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
|
||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
|
||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.whiteKingSide,
|
||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
|
||||
)
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.whiteQueenSide,
|
||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
|
||||
)
|
||||
moves.toList
|
||||
|
||||
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
||||
@@ -157,10 +165,18 @@ object DefaultRules extends RuleSet:
|
||||
if from != expected then List.empty
|
||||
else
|
||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||
addCastleMove(context, moves, context.castlingRights.blackKingSide,
|
||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
|
||||
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.blackKingSide,
|
||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
|
||||
)
|
||||
addCastleMove(
|
||||
context,
|
||||
moves,
|
||||
context.castlingRights.blackQueenSide,
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
|
||||
)
|
||||
moves.toList
|
||||
|
||||
private def queensideBSquare(kingToAlg: String): List[String] =
|
||||
@@ -170,10 +186,10 @@ object DefaultRules extends RuleSet:
|
||||
case _ => List.empty
|
||||
|
||||
private def addCastleMove(
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
castlingRight: Boolean,
|
||||
castlingMove: CastlingMove
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
castlingRight: Boolean,
|
||||
castlingMove: CastlingMove,
|
||||
): Unit =
|
||||
if castlingRight then
|
||||
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
||||
@@ -185,16 +201,15 @@ object DefaultRules extends RuleSet:
|
||||
kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
|
||||
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
|
||||
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 rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
|
||||
val squaresSafe =
|
||||
!isAttackedBy(context.board, kf, color.opposite) &&
|
||||
!isAttackedBy(context.board, km, color.opposite) &&
|
||||
!isAttackedBy(context.board, kt, color.opposite)
|
||||
!isAttackedBy(context.board, km, color.opposite) &&
|
||||
!isAttackedBy(context.board, kt, color.opposite)
|
||||
|
||||
if kingPresent && rookPresent && squaresSafe then
|
||||
moves += Move(kf, kt, castlingMove.moveType)
|
||||
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
|
||||
|
||||
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
||||
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
||||
@@ -202,22 +217,26 @@ object DefaultRules extends RuleSet:
|
||||
// ── Pawn ───────────────────────────────────────────────────────────
|
||||
|
||||
private def pawnCandidates(
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color
|
||||
context: GameContext,
|
||||
from: Square,
|
||||
color: Color,
|
||||
): List[Move] =
|
||||
val fwd = pawnForward(color)
|
||||
val fwd = pawnForward(color)
|
||||
val startRank = pawnStartRank(color)
|
||||
val promoRank = pawnPromoRank(color)
|
||||
|
||||
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
val double = Option.when(from.rank.ordinal == startRank) {
|
||||
from.offset(0, fwd).flatMap { mid =>
|
||||
Option.when(context.board.pieceAt(mid).isEmpty) {
|
||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
}.flatten
|
||||
val double = Option
|
||||
.when(from.rank.ordinal == startRank) {
|
||||
from.offset(0, fwd).flatMap { mid =>
|
||||
Option
|
||||
.when(context.board.pieceAt(mid).isEmpty) {
|
||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||
}
|
||||
.flatten
|
||||
}
|
||||
}
|
||||
}.flatten
|
||||
.flatten
|
||||
|
||||
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
||||
from.offset(df, fwd).flatMap { to =>
|
||||
@@ -236,22 +255,22 @@ object DefaultRules extends RuleSet:
|
||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||
if dest.rank.ordinal == promoRank then
|
||||
List(
|
||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
||||
PromotionPiece.Queen,
|
||||
PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop,
|
||||
PromotionPiece.Knight,
|
||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
||||
stepMoves ++ captureMoves ++ epCaptures
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────────
|
||||
|
||||
private def kingSquare(board: Board, color: Color): Option[Square] =
|
||||
Square.all.find(sq =>
|
||||
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
|
||||
)
|
||||
Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
|
||||
|
||||
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
||||
Square.all.exists { sq =>
|
||||
@@ -266,26 +285,26 @@ object DefaultRules extends RuleSet:
|
||||
case PieceType.Pawn =>
|
||||
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
||||
case PieceType.Knight =>
|
||||
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||
case PieceType.King =>
|
||||
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
||||
QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||
|
||||
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
||||
dirs.exists { dir =>
|
||||
@tailrec
|
||||
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
|
||||
case None => false
|
||||
case Some(next) if next == target => true
|
||||
case None => false
|
||||
case Some(next) if next == target => true
|
||||
case Some(next) if board.pieceAt(next).isEmpty => loop(next)
|
||||
case Some(_) => false
|
||||
case Some(_) => false
|
||||
loop(from)
|
||||
}
|
||||
|
||||
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)
|
||||
isCheck(nextContext)
|
||||
|
||||
@@ -293,7 +312,7 @@ object DefaultRules extends RuleSet:
|
||||
|
||||
override def applyMove(context: GameContext)(move: Move): GameContext =
|
||||
val color = context.turn
|
||||
val board = context.board
|
||||
val board = context.board
|
||||
|
||||
val newBoard = move.moveType match
|
||||
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.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 isCapture = move.moveType match
|
||||
case MoveType.Normal(capture) => capture
|
||||
case MoveType.EnPassant => true
|
||||
case _ => board.pieceAt(move.to).isDefined
|
||||
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
|
||||
context
|
||||
.withBoard(newBoard)
|
||||
@@ -322,19 +341,18 @@ object DefaultRules extends RuleSet:
|
||||
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||
if kingside then
|
||||
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
else
|
||||
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||
board
|
||||
.removed(kingFrom).removed(rookFrom)
|
||||
.removed(kingFrom)
|
||||
.removed(rookFrom)
|
||||
.updated(kingTo, king)
|
||||
.updated(rookTo, rook)
|
||||
|
||||
private def applyEnPassant(board: Board, move: Move): Board =
|
||||
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
|
||||
val capturedSquare = Square(move.to.file, capturedRank)
|
||||
board.applyMove(move).removed(capturedSquare)
|
||||
|
||||
@@ -347,7 +365,7 @@ object DefaultRules extends RuleSet:
|
||||
board.removed(move.from).updated(move.to, Piece(color, promotedType))
|
||||
|
||||
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
|
||||
val piece = board.pieceAt(move.from)
|
||||
val piece = board.pieceAt(move.from)
|
||||
val isKingMove = piece.exists(_.pieceType == PieceType.King)
|
||||
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
|
||||
|
||||
@@ -360,14 +378,14 @@ object DefaultRules extends RuleSet:
|
||||
var r = rights
|
||||
if isKingMove then r = r.revokeColor(color)
|
||||
else if isRookMove then
|
||||
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
// Also revoke if a rook is captured
|
||||
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
|
||||
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
|
||||
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
|
||||
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
|
||||
r
|
||||
|
||||
@@ -386,9 +404,10 @@ object DefaultRules extends RuleSet:
|
||||
private def insufficientMaterial(board: Board): Boolean =
|
||||
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
|
||||
pieces match
|
||||
case Nil => true
|
||||
case Nil => true
|
||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||
case List(p1, p2)
|
||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||
&& p1.color != p2.color => true
|
||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||
&& p1.color != p2.color =>
|
||||
true
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -73,7 +73,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove resets halfMoveClock on pawn move"):
|
||||
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
val move = Move(sq("e2"), sq("e4"))
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -89,7 +89,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove resets halfMoveClock on capture"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
val next = DefaultRules.applyMove(context)(move)
|
||||
|
||||
@@ -98,7 +98,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove updates castling rights after king move"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||
val move = Move(sq("e1"), sq("e2"))
|
||||
val move = Move(sq("e1"), sq("e2"))
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -118,7 +118,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes opponent castling right when rook on starting square is captured"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -137,7 +137,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes queenside castling and repositions king and rook"):
|
||||
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
|
||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -158,7 +158,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove executes promotion with selected piece type"):
|
||||
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||
val context = GameContext(
|
||||
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||
board =
|
||||
contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||
turn = Color.Black,
|
||||
castlingRights = CastlingRights(true, true, false, false),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
||||
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||
val afterH1Capture =
|
||||
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||
|
||||
afterH1Capture.castlingRights.whiteKingSide 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"):
|
||||
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)
|
||||
|
||||
@@ -244,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes black castling rights when black rook moves from h8"):
|
||||
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||
val move = Move(sq("h8"), sq("h7"))
|
||||
val move = Move(sq("h8"), sq("h7"))
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
@@ -262,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
|
||||
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
|
||||
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||
|
||||
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"):
|
||||
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 }
|
||||
|
||||
promotions.toSet shouldBe Set(
|
||||
PromotionPiece.Queen,
|
||||
PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop,
|
||||
PromotionPiece.Knight
|
||||
PromotionPiece.Knight,
|
||||
)
|
||||
|
||||
test("applyMove promotion supports queen rook and bishop targets"):
|
||||
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||
|
||||
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||
|
||||
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.move.{Move, MoveType}
|
||||
import de.nowchess.io.fen.FenParser
|
||||
@@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
// ── Pawn moves ──────────────────────────────────────────────────
|
||||
|
||||
test("pawn can move forward one square"):
|
||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("pawn can move forward two squares from starting position"):
|
||||
val context = GameContext.initial
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
|
||||
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
|
||||
|
||||
test("pawn can capture diagonally"):
|
||||
// FEN: white pawn e4, black pawn d5
|
||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
|
||||
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
||||
|
||||
test("pawn cannot move backward"):
|
||||
// FEN: white pawn on e4
|
||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
|
||||
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
|
||||
|
||||
@@ -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"):
|
||||
// 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 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
|
||||
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
|
||||
|
||||
test("king cannot move to square attacked by opponent"):
|
||||
// FEN: white king e1, black rook e2 defended by black king e3
|
||||
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King cannot move to e2 (occupied and attacked)
|
||||
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
|
||||
@@ -67,64 +67,67 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
// ── Castling legality ────────────────────────────────────────────
|
||||
|
||||
test("castling kingside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling queenside is legal when king and rook unmoved and path clear"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||
castles.nonEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when castling rights are false"):
|
||||
// FEN: king and rook in position, but castling rights disabled
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when king is in check"):
|
||||
// FEN: white king e1 in check from black rook e8
|
||||
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling is illegal when path has piece in the way"):
|
||||
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling queenside is illegal when knight blocks on b8"):
|
||||
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
||||
val board = Board(Map(
|
||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
|
||||
))
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, 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(
|
||||
board = board,
|
||||
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,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
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"):
|
||||
// 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 moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("en passant is illegal when en passant square is none"):
|
||||
// FEN: white pawn e5, black pawn d5, but no en passant square
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
|
||||
epMoves.isEmpty shouldBe true
|
||||
@@ -155,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("pinned piece cannot move and expose king to check"):
|
||||
// FEN: white king e1, white bishop d2 (pinned), black rook a2
|
||||
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// Bishop on d2 is pinned by rook on a2; it cannot move
|
||||
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
|
||||
@@ -166,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
test("piece blocking a check is legal"):
|
||||
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
|
||||
// Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2
|
||||
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
|
||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// White is in check; only moves that block or move the king are legal
|
||||
moves.nonEmpty shouldBe true
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.ui.terminal.TerminalUI
|
||||
import de.nowchess.ui.gui.ChessGUILauncher
|
||||
|
||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
|
||||
* GameEngine via Observer pattern.
|
||||
*/
|
||||
object Main:
|
||||
def main(args: Array[String]): Unit =
|
||||
// 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)
|
||||
val tui = new TerminalUI(engine)
|
||||
tui.start()
|
||||
|
||||
|
||||
@@ -17,30 +17,29 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||
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 scalafx.stage.FileChooser
|
||||
import scalafx.stage.FileChooser.ExtensionFilter
|
||||
|
||||
/** ScalaFX chess board view that displays the game state.
|
||||
* Uses chess sprites and color palette.
|
||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
|
||||
* interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
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 boardGrid = new GridPane()
|
||||
private val boardGrid = new GridPane()
|
||||
private val messageLabel = new Label {
|
||||
text = "Welcome!"
|
||||
font = Font.font(comicSansFontFamily, 16)
|
||||
padding = Insets(10)
|
||||
}
|
||||
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
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 redoButton: Button = uninitialized
|
||||
@@ -58,7 +57,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 24)
|
||||
style = "-fx-font-weight: bold;"
|
||||
},
|
||||
messageLabel
|
||||
messageLabel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,8 +85,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
disable = !engine.canUndo
|
||||
}
|
||||
undoButton
|
||||
},
|
||||
{
|
||||
}, {
|
||||
redoButton = new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
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)
|
||||
onAction = _ => engine.reset()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
new HBox {
|
||||
@@ -126,7 +124,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doPgnImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
new HBox {
|
||||
@@ -142,9 +140,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doJsonImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +163,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
|
||||
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 bgRect = new Rectangle {
|
||||
@@ -185,8 +183,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
square
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if engine.isPendingPromotion then
|
||||
return // Don't allow moves during promotion
|
||||
if engine.isPendingPromotion then return // Don't allow moves during promotion
|
||||
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
@@ -198,10 +195,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
selectedSquare = Some(clickedSquare)
|
||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||
|
||||
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
val legalDests = engine.ruleSet
|
||||
.legalMoves(engine.context)(clickedSquare)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
legalDests.foreach { sq =>
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +226,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
file <- 0 until 8
|
||||
do
|
||||
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 bgRect = new Rectangle {
|
||||
@@ -239,7 +237,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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 children = pieceOption match
|
||||
@@ -267,7 +265,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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)
|
||||
|
||||
stackPane.children = pieceOption match
|
||||
@@ -291,11 +289,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
|
||||
val result = dialog.showAndWait()
|
||||
result match
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
|
||||
private def doFenExport(): Unit =
|
||||
doExport(FenExporter, "FEN")
|
||||
@@ -322,10 +320,10 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
val result = FileSystemGameService.saveGameToFile(
|
||||
engine.context,
|
||||
selectedFile.toPath,
|
||||
JsonExporter
|
||||
JsonExporter,
|
||||
)
|
||||
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")
|
||||
|
||||
private def doJsonImport(): Unit =
|
||||
@@ -339,7 +337,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
if selectedFile != null then
|
||||
val result = FileSystemGameService.loadGameFromFile(
|
||||
selectedFile.toPath,
|
||||
JsonParser
|
||||
JsonParser,
|
||||
)
|
||||
result match
|
||||
case Right(gameContext) =>
|
||||
@@ -353,7 +351,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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 =>
|
||||
importer.importGameContext(input) match
|
||||
case Right(gameContext) =>
|
||||
@@ -362,7 +360,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ $formatName Error: $err")
|
||||
}
|
||||
}
|
||||
|
||||
private def showCopyDialog(title: String, content: String): Unit =
|
||||
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.getButtonTypes.addAll(
|
||||
javafx.scene.control.ButtonType.OK,
|
||||
javafx.scene.control.ButtonType.CANCEL
|
||||
javafx.scene.control.ButtonType.CANCEL,
|
||||
)
|
||||
dialog.setResultConverter { bt =>
|
||||
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)
|
||||
val result = dialog.showAndWait()
|
||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
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 scalafx.application.Platform
|
||||
import scalafx.scene.Scene
|
||||
import scalafx.stage.Stage
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
|
||||
/** ScalaFX GUI Application for Chess.
|
||||
* This is launched from Main alongside the TUI.
|
||||
* Both subscribe to the same GameEngine via Observer pattern.
|
||||
*/
|
||||
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
|
||||
* GameEngine via Observer pattern.
|
||||
*/
|
||||
class ChessGUIApp extends JFXApplication:
|
||||
|
||||
override def start(primaryStage: JFXStage): Unit =
|
||||
val engine = ChessGUILauncher.getEngine
|
||||
val stage = new Stage(primaryStage)
|
||||
val stage = new Stage(primaryStage)
|
||||
|
||||
stage.title = "Chess"
|
||||
stage.width = 700
|
||||
stage.height = 1000
|
||||
stage.resizable = false
|
||||
|
||||
val boardView = new ChessBoardView(stage, engine)
|
||||
val boardView = new ChessBoardView(stage, engine)
|
||||
val guiObserver = new GUIObserver(boardView)
|
||||
|
||||
// Subscribe GUI observer to engine
|
||||
@@ -33,17 +32,15 @@ class ChessGUIApp extends JFXApplication:
|
||||
// Load CSS if available
|
||||
try {
|
||||
val cssUrl = getClass.getResource("/styles.css")
|
||||
if cssUrl != null then
|
||||
stylesheets.add(cssUrl.toExternalForm)
|
||||
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
|
||||
} catch {
|
||||
case _: Exception => // CSS is optional
|
||||
}
|
||||
}
|
||||
|
||||
stage.onCloseRequest = _ => {
|
||||
stage.onCloseRequest = _ =>
|
||||
// Unsubscribe when window closes
|
||||
engine.unsubscribe(guiObserver)
|
||||
}
|
||||
|
||||
stage.show()
|
||||
|
||||
@@ -55,9 +52,7 @@ object ChessGUILauncher:
|
||||
|
||||
def launch(eng: GameEngine): Unit =
|
||||
engine = eng
|
||||
val guiThread = new Thread(() => {
|
||||
JFXApplication.launch(classOf[ChessGUIApp])
|
||||
})
|
||||
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
|
||||
guiThread.setDaemon(false)
|
||||
guiThread.setName("ScalaFX-GUI-Thread")
|
||||
guiThread.start()
|
||||
|
||||
@@ -3,13 +3,12 @@ package de.nowchess.ui.gui
|
||||
import scalafx.application.Platform
|
||||
import scalafx.scene.control.Alert
|
||||
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
|
||||
|
||||
/** GUI Observer that implements the Observer pattern.
|
||||
* Receives game events from GameEngine and updates the ScalaFX UI.
|
||||
* All UI updates must be done on the JavaFX Application Thread.
|
||||
*/
|
||||
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
|
||||
* All UI updates must be done on the JavaFX Application Thread.
|
||||
*/
|
||||
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||
|
||||
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)
|
||||
if e.capturedPiece.isDefined then
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
||||
else
|
||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||
boardView.updateUndoRedoButtons()
|
||||
|
||||
case e: PgnLoadedEvent =>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package de.nowchess.ui.gui
|
||||
|
||||
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. */
|
||||
object PieceSprites:
|
||||
|
||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||
|
||||
/** Load a piece sprite image from resources.
|
||||
* Sprites are cached for performance.
|
||||
*/
|
||||
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||
*/
|
||||
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))
|
||||
|
||||
new ImageView(image) {
|
||||
@@ -23,16 +22,15 @@ object PieceSprites:
|
||||
}
|
||||
|
||||
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)
|
||||
if stream == null then
|
||||
throw new RuntimeException(s"Could not load sprite: $path")
|
||||
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
|
||||
new Image(stream)
|
||||
|
||||
/** Get square colors for the board using theme. */
|
||||
object SquareColors:
|
||||
val White = "#F3C8A0" // Warm light beige
|
||||
val Black = "#BA6D4B" // Warm terracotta
|
||||
val Selected = "#C19EF5" // Purple highlight
|
||||
val White = "#F3C8A0" // Warm light beige
|
||||
val Black = "#BA6D4B" // Warm terracotta
|
||||
val Selected = "#C19EF5" // Purple highlight
|
||||
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.ui.utils.Renderer
|
||||
|
||||
/** Terminal UI that implements Observer pattern.
|
||||
* Subscribes to GameEngine and receives state change events.
|
||||
* Handles all I/O and user interaction in the terminal.
|
||||
*/
|
||||
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
|
||||
* I/O and user interaction in the terminal.
|
||||
*/
|
||||
class TerminalUI(engine: GameEngine) extends Observer:
|
||||
private var running = true
|
||||
private var running = true
|
||||
private var awaitingPromotion = false
|
||||
|
||||
/** Called by GameEngine whenever a game event occurs. */
|
||||
|
||||
@@ -5,24 +5,26 @@ import de.nowchess.api.board.*
|
||||
object Renderer:
|
||||
|
||||
private val AnsiReset = "\u001b[0m"
|
||||
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
|
||||
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
|
||||
private val AnsiWhitePiece = "\u001b[97m" // bright white text
|
||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
|
||||
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
|
||||
private val AnsiWhitePiece = "\u001b[97m" // bright white text
|
||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||
|
||||
def render(board: Board): String =
|
||||
val rows = (0 until 8).reverse.map { rank =>
|
||||
val cells = (0 until 8).map { file =>
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
board.pieceAt(sq) match
|
||||
case Some(piece) =>
|
||||
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||
case None =>
|
||||
s"$bgColor $AnsiReset"
|
||||
}.mkString
|
||||
s"${rank + 1} $cells ${rank + 1}"
|
||||
}.mkString("\n")
|
||||
val rows = (0 until 8).reverse
|
||||
.map { rank =>
|
||||
val cells = (0 until 8).map { file =>
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
board.pieceAt(sq) match
|
||||
case Some(piece) =>
|
||||
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||
case None =>
|
||||
s"$bgColor $AnsiReset"
|
||||
}.mkString
|
||||
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"
|
||||
|
||||
@@ -19,16 +19,16 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
||||
(Piece(Color.Black, PieceType.Rook), "\u265C"),
|
||||
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
|
||||
(Piece(Color.Black, PieceType.Knight), "\u265E"),
|
||||
(Piece(Color.Black, PieceType.Pawn), "\u265F")
|
||||
(Piece(Color.Black, PieceType.Pawn), "\u265F"),
|
||||
)
|
||||
pieces.foreach { (piece, expected) =>
|
||||
piece.unicode shouldBe expected
|
||||
}
|
||||
|
||||
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 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.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[")
|
||||
|
||||
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)
|
||||
rendered should include("\u265A") // Black king unicode
|
||||
rendered should include("\u001b[30m") // ANSI black text color
|
||||
|
||||
|
||||
rendered should include("\u265A") // Black king unicode
|
||||
rendered should include("\u001b[30m") // ANSI black text color
|
||||
|
||||
Reference in New Issue
Block a user