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.
|
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
||||||
|
|
||||||
|
### Linters
|
||||||
|
|
||||||
|
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
|
||||||
|
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
|
||||||
|
|
||||||
## Architecture Decisions
|
## Architecture Decisions
|
||||||
|
|
||||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.sonarqube") version "7.2.3.7755"
|
id("org.sonarqube") version "7.2.3.7755"
|
||||||
id("org.scoverage") version "8.1" apply false
|
id("org.scoverage") version "8.1" apply false
|
||||||
|
id("com.diffplug.spotless") version "8.4.0" apply false
|
||||||
|
id("io.github.cosmicsilence.scalafix") version "0.2.6" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "de.nowchess"
|
group = "de.nowchess"
|
||||||
@@ -40,3 +42,27 @@ val versions = mapOf(
|
|||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply(plugin = "com.diffplug.spotless")
|
||||||
|
|
||||||
|
pluginManager.withPlugin("scala") {
|
||||||
|
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||||
|
scala {
|
||||||
|
scalafmt().configFile(rootProject.file(".scalafmt.conf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(plugin = "io.github.cosmicsilence.scalafix")
|
||||||
|
configure<io.github.cosmicsilence.scalafix.ScalafixExtension> {
|
||||||
|
configFile.set(rootProject.file(".scalafix.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable SemanticDB config for the scoverage source set — it sets -sourceroot to
|
||||||
|
// the root project dir, which conflicts with scoverage's own -sourceroot and causes
|
||||||
|
// reportTestScoverage to fail with "No source root found".
|
||||||
|
tasks.matching { it.name in setOf("configSemanticDBScoverage", "checkScalafixScoverage", "checkScalafixTest") }.configureEach {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ object Board:
|
|||||||
|
|
||||||
val initial: Board =
|
val initial: Board =
|
||||||
val backRank: Vector[PieceType] = Vector(
|
val backRank: Vector[PieceType] = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
val entries = for
|
val entries = for
|
||||||
fileIdx <- 0 until 8
|
fileIdx <- 0 until 8
|
||||||
@@ -30,7 +36,7 @@ object Board:
|
|||||||
(Color.White, Rank.R1, backRank(fileIdx)),
|
(Color.White, Rank.R1, backRank(fileIdx)),
|
||||||
(Color.White, Rank.R2, PieceType.Pawn),
|
(Color.White, Rank.R2, PieceType.Pawn),
|
||||||
(Color.Black, Rank.R8, backRank(fileIdx)),
|
(Color.Black, Rank.R8, backRank(fileIdx)),
|
||||||
(Color.Black, Rank.R7, PieceType.Pawn)
|
(Color.Black, Rank.R7, PieceType.Pawn),
|
||||||
)
|
)
|
||||||
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
|
||||||
Board(entries.toMap)
|
Board(entries.toMap)
|
||||||
|
|||||||
@@ -1,49 +1,47 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
/**
|
/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and
|
||||||
* Unified castling rights tracker for all four sides.
|
* direction.
|
||||||
* Tracks whether castling is still available for each side and direction.
|
|
||||||
*
|
*
|
||||||
* @param whiteKingSide White's king-side castling (0-0) still legally available
|
* @param whiteKingSide
|
||||||
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
|
* White's king-side castling (0-0) still legally available
|
||||||
* @param blackKingSide Black's king-side castling (0-0) still legally available
|
* @param whiteQueenSide
|
||||||
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
|
* 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(
|
final case class CastlingRights(
|
||||||
whiteKingSide: Boolean,
|
whiteKingSide: Boolean,
|
||||||
whiteQueenSide: Boolean,
|
whiteQueenSide: Boolean,
|
||||||
blackKingSide: Boolean,
|
blackKingSide: Boolean,
|
||||||
blackQueenSide: Boolean
|
blackQueenSide: Boolean,
|
||||||
):
|
):
|
||||||
/**
|
/** Check if either side has any castling rights remaining.
|
||||||
* Check if either side has any castling rights remaining.
|
|
||||||
*/
|
*/
|
||||||
def hasAnyRights: Boolean =
|
def hasAnyRights: Boolean =
|
||||||
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
|
||||||
|
|
||||||
/**
|
/** Check if a specific color has any castling rights remaining.
|
||||||
* Check if a specific color has any castling rights remaining.
|
|
||||||
*/
|
*/
|
||||||
def hasRights(color: Color): Boolean = color match
|
def hasRights(color: Color): Boolean = color match
|
||||||
case Color.White => whiteKingSide || whiteQueenSide
|
case Color.White => whiteKingSide || whiteQueenSide
|
||||||
case Color.Black => blackKingSide || blackQueenSide
|
case Color.Black => blackKingSide || blackQueenSide
|
||||||
|
|
||||||
/**
|
/** Revoke all castling rights for a specific color.
|
||||||
* Revoke all castling rights for a specific color.
|
|
||||||
*/
|
*/
|
||||||
def revokeColor(color: Color): CastlingRights = color match
|
def revokeColor(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
|
||||||
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
|
||||||
|
|
||||||
/**
|
/** Revoke a specific castling right.
|
||||||
* Revoke a specific castling right.
|
|
||||||
*/
|
*/
|
||||||
def revokeKingSide(color: Color): CastlingRights = color match
|
def revokeKingSide(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteKingSide = false)
|
case Color.White => copy(whiteKingSide = false)
|
||||||
case Color.Black => copy(blackKingSide = false)
|
case Color.Black => copy(blackKingSide = false)
|
||||||
|
|
||||||
/**
|
/** Revoke a specific castling right.
|
||||||
* Revoke a specific castling right.
|
|
||||||
*/
|
*/
|
||||||
def revokeQueenSide(color: Color): CastlingRights = color match
|
def revokeQueenSide(color: Color): CastlingRights = color match
|
||||||
case Color.White => copy(whiteQueenSide = false)
|
case Color.White => copy(whiteQueenSide = false)
|
||||||
@@ -55,7 +53,7 @@ object CastlingRights:
|
|||||||
whiteKingSide = false,
|
whiteKingSide = false,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = false
|
blackQueenSide = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** All castling rights available. */
|
/** All castling rights available. */
|
||||||
@@ -63,7 +61,7 @@ object CastlingRights:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = true,
|
whiteQueenSide = true,
|
||||||
blackKingSide = true,
|
blackKingSide = true,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Standard starting position castling rights (both sides can castle both ways). */
|
/** Standard starting position castling rights (both sides can castle both ways). */
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
/**
|
/** A file (column) on the chess board, a–h. Ordinal values 0–7 correspond to a–h.
|
||||||
* A file (column) on the chess board, a–h.
|
|
||||||
* Ordinal values 0–7 correspond to a–h.
|
|
||||||
*/
|
*/
|
||||||
enum File:
|
enum File:
|
||||||
case A, B, C, D, E, F, G, H
|
case A, B, C, D, E, F, G, H
|
||||||
|
|
||||||
/**
|
/** A rank (row) on the chess board, 1–8. Ordinal values 0–7 correspond to ranks 1–8.
|
||||||
* A rank (row) on the chess board, 1–8.
|
|
||||||
* Ordinal values 0–7 correspond to ranks 1–8.
|
|
||||||
*/
|
*/
|
||||||
enum Rank:
|
enum Rank:
|
||||||
case R1, R2, R3, R4, R5, R6, R7, R8
|
case R1, R2, R3, R4, R5, R6, R7, R8
|
||||||
|
|
||||||
/**
|
/** A unique square on the board, identified by its file and rank.
|
||||||
* A unique square on the board, identified by its file and rank.
|
|
||||||
*
|
*
|
||||||
* @param file the column, a–h
|
* @param file
|
||||||
* @param rank the row, 1–8
|
* the column, a–h
|
||||||
|
* @param rank
|
||||||
|
* the row, 1–8
|
||||||
*/
|
*/
|
||||||
final case class Square(file: File, rank: Rank):
|
final case class Square(file: File, rank: Rank):
|
||||||
/** Algebraic notation string, e.g. "e4". */
|
/** Algebraic notation string, e.g. "e4". */
|
||||||
@@ -26,8 +23,8 @@ final case class Square(file: File, rank: Rank):
|
|||||||
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
s"${file.toString.toLowerCase}${rank.ordinal + 1}"
|
||||||
|
|
||||||
object Square:
|
object Square:
|
||||||
/** Parse a square from algebraic notation (e.g. "e4").
|
/** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
|
||||||
* Returns None if the input is not a valid square name. */
|
*/
|
||||||
def fromAlgebraic(s: String): Option[Square] =
|
def fromAlgebraic(s: String): Option[Square] =
|
||||||
if s.length != 2 then None
|
if s.length != 2 then None
|
||||||
else
|
else
|
||||||
@@ -35,9 +32,7 @@ object Square:
|
|||||||
val rankChar = s.charAt(1)
|
val rankChar = s.charAt(1)
|
||||||
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
|
||||||
val rankOpt =
|
val rankOpt =
|
||||||
rankChar.toString.toIntOption.flatMap(n =>
|
rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
|
||||||
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
|
|
||||||
)
|
|
||||||
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
for f <- fileOpt; r <- rankOpt yield Square(f, r)
|
||||||
|
|
||||||
val all: IndexedSeq[Square] =
|
val all: IndexedSeq[Square] =
|
||||||
@@ -46,8 +41,9 @@ object Square:
|
|||||||
f <- File.values.toIndexedSeq
|
f <- File.values.toIndexedSeq
|
||||||
yield Square(f, r)
|
yield Square(f, r)
|
||||||
|
|
||||||
/** Compute a target square by offsetting file and rank.
|
/** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board
|
||||||
* Returns None if the resulting square is outside the board (0-7 range). */
|
* (0-7 range).
|
||||||
|
*/
|
||||||
extension (sq: Square)
|
extension (sq: Square)
|
||||||
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
|
||||||
val newFileOrd = sq.file.ordinal + fileDelta
|
val newFileOrd = sq.file.ordinal + fileDelta
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.nowchess.api.game
|
package de.nowchess.api.game
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, Square}
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
/** Immutable bundle of complete game state.
|
/** Immutable bundle of complete game state. All state changes produce new GameContext instances.
|
||||||
* All state changes produce new GameContext instances.
|
|
||||||
*/
|
*/
|
||||||
case class GameContext(
|
case class GameContext(
|
||||||
board: Board,
|
board: Board,
|
||||||
@@ -12,7 +11,7 @@ case class GameContext(
|
|||||||
castlingRights: CastlingRights,
|
castlingRights: CastlingRights,
|
||||||
enPassantSquare: Option[Square],
|
enPassantSquare: Option[Square],
|
||||||
halfMoveClock: Int,
|
halfMoveClock: Int,
|
||||||
moves: List[Move]
|
moves: List[Move],
|
||||||
):
|
):
|
||||||
/** Create new context with updated board. */
|
/** Create new context with updated board. */
|
||||||
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
|
||||||
@@ -40,5 +39,5 @@ object GameContext:
|
|||||||
castlingRights = CastlingRights.Initial,
|
castlingRights = CastlingRights.Initial,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,24 +10,30 @@ enum PromotionPiece:
|
|||||||
enum MoveType:
|
enum MoveType:
|
||||||
/** A normal move or capture with no special rule. */
|
/** A normal move or capture with no special rule. */
|
||||||
case Normal(isCapture: Boolean = false)
|
case Normal(isCapture: Boolean = false)
|
||||||
|
|
||||||
/** Kingside castling (O-O). */
|
/** Kingside castling (O-O). */
|
||||||
case CastleKingside
|
case CastleKingside
|
||||||
|
|
||||||
/** Queenside castling (O-O-O). */
|
/** Queenside castling (O-O-O). */
|
||||||
case CastleQueenside
|
case CastleQueenside
|
||||||
|
|
||||||
/** En-passant pawn capture. */
|
/** En-passant pawn capture. */
|
||||||
case EnPassant
|
case EnPassant
|
||||||
|
|
||||||
/** Pawn promotion; carries the chosen promotion piece. */
|
/** Pawn promotion; carries the chosen promotion piece. */
|
||||||
case Promotion(piece: PromotionPiece)
|
case Promotion(piece: PromotionPiece)
|
||||||
|
|
||||||
/**
|
/** A half-move (ply) in a chess game.
|
||||||
* A half-move (ply) in a chess game.
|
|
||||||
*
|
*
|
||||||
* @param from origin square
|
* @param from
|
||||||
* @param to destination square
|
* origin square
|
||||||
* @param moveType special semantics; defaults to Normal
|
* @param to
|
||||||
|
* destination square
|
||||||
|
* @param moveType
|
||||||
|
* special semantics; defaults to Normal
|
||||||
*/
|
*/
|
||||||
final case class Move(
|
final case class Move(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveType: MoveType = MoveType.Normal()
|
moveType: MoveType = MoveType.Normal(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package de.nowchess.api.player
|
package de.nowchess.api.player
|
||||||
|
|
||||||
/**
|
/** An opaque player identifier.
|
||||||
* An opaque player identifier.
|
|
||||||
*
|
*
|
||||||
* Wraps a plain String so that IDs are not accidentally interchanged with
|
* Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
|
||||||
* other String values at compile time.
|
|
||||||
*/
|
*/
|
||||||
opaque type PlayerId = String
|
opaque type PlayerId = String
|
||||||
|
|
||||||
@@ -12,16 +10,17 @@ object PlayerId:
|
|||||||
def apply(value: String): PlayerId = value
|
def apply(value: String): PlayerId = value
|
||||||
extension (id: PlayerId) def value: String = id
|
extension (id: PlayerId) def value: String = id
|
||||||
|
|
||||||
/**
|
/** The minimal cross-service identity stub for a player.
|
||||||
* The minimal cross-service identity stub for a player.
|
|
||||||
*
|
*
|
||||||
* Full profile data (email, rating history, etc.) lives in the user-management
|
* Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs
|
||||||
* service. Only what every service needs is held here.
|
* is held here.
|
||||||
*
|
*
|
||||||
* @param id unique identifier
|
* @param id
|
||||||
* @param displayName human-readable name shown in the UI
|
* unique identifier
|
||||||
|
* @param displayName
|
||||||
|
* human-readable name shown in the UI
|
||||||
*/
|
*/
|
||||||
final case class PlayerInfo(
|
final case class PlayerInfo(
|
||||||
id: PlayerId,
|
id: PlayerId,
|
||||||
displayName: String
|
displayName: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package de.nowchess.api.response
|
package de.nowchess.api.response
|
||||||
|
|
||||||
/**
|
/** A standardised envelope for every API response.
|
||||||
* A standardised envelope for every API response.
|
|
||||||
*
|
*
|
||||||
* Success and failure are modelled as subtypes so that callers
|
* Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
|
||||||
* can pattern-match exhaustively.
|
|
||||||
*
|
*
|
||||||
* @tparam A the payload type for a successful response
|
* @tparam A
|
||||||
|
* the payload type for a successful response
|
||||||
*/
|
*/
|
||||||
sealed trait ApiResponse[+A]
|
sealed trait ApiResponse[+A]
|
||||||
|
|
||||||
@@ -20,43 +19,49 @@ object ApiResponse:
|
|||||||
/** Convenience constructor for a single-error failure. */
|
/** Convenience constructor for a single-error failure. */
|
||||||
def error(err: ApiError): Failure = Failure(List(err))
|
def error(err: ApiError): Failure = Failure(List(err))
|
||||||
|
|
||||||
/**
|
/** A structured error descriptor.
|
||||||
* A structured error descriptor.
|
|
||||||
*
|
*
|
||||||
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
* @param code
|
||||||
* @param message human-readable explanation
|
* machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
|
||||||
* @param field optional field name when the error relates to a specific input
|
* @param message
|
||||||
|
* human-readable explanation
|
||||||
|
* @param field
|
||||||
|
* optional field name when the error relates to a specific input
|
||||||
*/
|
*/
|
||||||
final case class ApiError(
|
final case class ApiError(
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
field: Option[String] = None
|
field: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Pagination metadata for list responses.
|
||||||
* Pagination metadata for list responses.
|
|
||||||
*
|
*
|
||||||
* @param page current 0-based page index
|
* @param page
|
||||||
* @param pageSize number of items per page
|
* current 0-based page index
|
||||||
* @param totalItems total number of items across all pages
|
* @param pageSize
|
||||||
|
* number of items per page
|
||||||
|
* @param totalItems
|
||||||
|
* total number of items across all pages
|
||||||
*/
|
*/
|
||||||
final case class Pagination(
|
final case class Pagination(
|
||||||
page: Int,
|
page: Int,
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
totalItems: Long
|
totalItems: Long,
|
||||||
):
|
):
|
||||||
def totalPages: Int =
|
def totalPages: Int =
|
||||||
if pageSize <= 0 then 0
|
if pageSize <= 0 then 0
|
||||||
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
else Math.ceil(totalItems.toDouble / pageSize).toInt
|
||||||
|
|
||||||
/**
|
/** A paginated list response envelope.
|
||||||
* A paginated list response envelope.
|
|
||||||
*
|
*
|
||||||
* @param items the items on the current page
|
* @param items
|
||||||
* @param pagination pagination metadata
|
* the items on the current page
|
||||||
* @tparam A the item type
|
* @param pagination
|
||||||
|
* pagination metadata
|
||||||
|
* @tparam A
|
||||||
|
* the item type
|
||||||
*/
|
*/
|
||||||
final case class PagedResponse[A](
|
final case class PagedResponse[A](
|
||||||
items: List[A],
|
items: List[A],
|
||||||
pagination: Pagination
|
pagination: Pagination,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("initial board white back rank") {
|
test("initial board white back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||||
@@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("initial board black back rank") {
|
test("initial board black back rank") {
|
||||||
val expectedBackRank = Vector(
|
val expectedBackRank = Vector(
|
||||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
PieceType.Rook,
|
||||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
PieceType.Knight,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Queen,
|
||||||
|
PieceType.King,
|
||||||
|
PieceType.Bishop,
|
||||||
|
PieceType.Knight,
|
||||||
|
PieceType.Rook,
|
||||||
)
|
)
|
||||||
File.values.zipWithIndex.foreach { (file, i) =>
|
File.values.zipWithIndex.foreach { (file, i) =>
|
||||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||||
@@ -76,8 +88,7 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
for
|
for
|
||||||
rank <- emptyRanks
|
rank <- emptyRanks
|
||||||
file <- File.values
|
file <- File.values
|
||||||
do
|
do Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("updated adds and replaces piece at squares") {
|
test("updated adds and replaces piece at squares") {
|
||||||
@@ -105,4 +116,3 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||||
moved.pieceAt(e2) shouldBe None
|
moved.pieceAt(e2) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
rights.hasAnyRights shouldBe true
|
rights.hasAnyRights shouldBe true
|
||||||
@@ -54,4 +54,3 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
|
|||||||
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||||
blackQueenSideRevoked.blackKingSide shouldBe true
|
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||||
blackQueenSideRevoked.blackQueenSide shouldBe false
|
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
|
|||||||
test("Color values expose opposite and label consistently"):
|
test("Color values expose opposite and label consistently"):
|
||||||
val cases = List(
|
val cases = List(
|
||||||
(Color.White, Color.Black, "White"),
|
(Color.White, Color.Black, "White"),
|
||||||
(Color.Black, Color.White, "Black")
|
(Color.Black, Color.White, "Black"),
|
||||||
)
|
)
|
||||||
|
|
||||||
cases.foreach { (color, opposite, label) =>
|
cases.foreach { (color, opposite, label) =>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PieceTest extends AnyFunSuite with Matchers:
|
|||||||
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
|
||||||
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
|
||||||
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
|
||||||
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
|
Piece.BlackKing -> Piece(Color.Black, PieceType.King),
|
||||||
)
|
)
|
||||||
|
|
||||||
expected.foreach { case (actual, wanted) =>
|
expected.foreach { case (actual, wanted) =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
|
|||||||
PieceType.Bishop -> "Bishop",
|
PieceType.Bishop -> "Bishop",
|
||||||
PieceType.Rook -> "Rook",
|
PieceType.Rook -> "Rook",
|
||||||
PieceType.Queen -> "Queen",
|
PieceType.Queen -> "Queen",
|
||||||
PieceType.King -> "King"
|
PieceType.King -> "King",
|
||||||
)
|
)
|
||||||
|
|
||||||
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
|
|||||||
"a1" -> Square(File.A, Rank.R1),
|
"a1" -> Square(File.A, Rank.R1),
|
||||||
"e4" -> Square(File.E, Rank.R4),
|
"e4" -> Square(File.E, Rank.R4),
|
||||||
"h8" -> Square(File.H, Rank.R8),
|
"h8" -> Square(File.H, Rank.R8),
|
||||||
"E4" -> Square(File.E, Rank.R4)
|
"E4" -> Square(File.E, Rank.R4),
|
||||||
)
|
)
|
||||||
expected.foreach { case (raw, sq) =>
|
expected.foreach { case (raw, sq) =>
|
||||||
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
Square.fromAlgebraic(raw) shouldBe Some(sq)
|
||||||
@@ -34,4 +34,3 @@ class SquareTest extends AnyFunSuite with Matchers:
|
|||||||
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||||
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
)
|
)
|
||||||
val square = Some(Square(File.E, Rank.R3))
|
val square = Some(Square(File.E, Rank.R3))
|
||||||
val updatedTurn = initial.withTurn(Color.Black)
|
val updatedTurn = initial.withTurn(Color.Black)
|
||||||
@@ -57,4 +57,3 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
|||||||
test("withMove appends move to history"):
|
test("withMove appends move to history"):
|
||||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||||
GameContext.initial.withMove(move).moves shouldBe List(move)
|
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class MoveTest extends AnyFunSuite with Matchers:
|
|||||||
MoveType.Promotion(PromotionPiece.Queen),
|
MoveType.Promotion(PromotionPiece.Queen),
|
||||||
MoveType.Promotion(PromotionPiece.Rook),
|
MoveType.Promotion(PromotionPiece.Rook),
|
||||||
MoveType.Promotion(PromotionPiece.Bishop),
|
MoveType.Promotion(PromotionPiece.Bishop),
|
||||||
MoveType.Promotion(PromotionPiece.Knight)
|
MoveType.Promotion(PromotionPiece.Knight),
|
||||||
)
|
)
|
||||||
|
|
||||||
moveTypes.foreach { moveType =>
|
moveTypes.foreach { moveType =>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, Piece}
|
import de.nowchess.api.board.{Piece, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Marker trait for all commands that can be executed and undone.
|
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||||
* Commands encapsulate user actions and game state transitions.
|
* transitions.
|
||||||
*/
|
*/
|
||||||
trait Command:
|
trait Command:
|
||||||
/** Execute the command and return true if successful, false otherwise. */
|
/** Execute the command and return true if successful, false otherwise. */
|
||||||
@@ -16,15 +16,14 @@ trait Command:
|
|||||||
/** A human-readable description of this command. */
|
/** A human-readable description of this command. */
|
||||||
def description: String
|
def description: String
|
||||||
|
|
||||||
/** Command to move a piece from one square to another.
|
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||||
* Stores the move result so undo can restore previous state.
|
|
||||||
*/
|
*/
|
||||||
case class MoveCommand(
|
case class MoveCommand(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
moveResult: Option[MoveResult] = None,
|
||||||
previousContext: Option[GameContext] = None,
|
previousContext: Option[GameContext] = None,
|
||||||
notation: String = ""
|
notation: String = "",
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
@@ -50,7 +49,7 @@ case class QuitCommand() extends Command:
|
|||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousContext: Option[GameContext] = None
|
previousContext: Option[GameContext] = None,
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
|
|||||||
/** Manages command execution and history for undo/redo support. */
|
/** Manages command execution and history for undo/redo support. */
|
||||||
class CommandInvoker:
|
class CommandInvoker:
|
||||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentIndex = -1
|
private var currentIndex = -1
|
||||||
|
|
||||||
/** Execute a command and add it to history.
|
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||||
* Discards any redo history if not at the end of the stack.
|
|
||||||
*/
|
*/
|
||||||
def execute(command: Command): Boolean = synchronized {
|
def execute(command: Command): Boolean = synchronized {
|
||||||
if command.execute() then
|
if command.execute() then
|
||||||
// Remove any commands after current index (redo stack is discarded)
|
// Remove any commands after current index (redo stack is discarded)
|
||||||
while currentIndex < executedCommands.size - 1 do
|
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||||
executedCommands.remove(executedCommands.size - 1)
|
|
||||||
executedCommands += command
|
executedCommands += command
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last executed command if possible. */
|
/** Undo the last executed command if possible. */
|
||||||
@@ -27,10 +25,8 @@ class CommandInvoker:
|
|||||||
if command.undo() then
|
if command.undo() then
|
||||||
currentIndex -= 1
|
currentIndex -= 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redo the next command in history if available. */
|
/** Redo the next command in history if available. */
|
||||||
@@ -40,10 +36,8 @@ class CommandInvoker:
|
|||||||
if command.execute() then
|
if command.execute() then
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
true
|
true
|
||||||
else
|
else false
|
||||||
false
|
else false
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the history of all executed commands. */
|
/** Get the history of all executed commands. */
|
||||||
|
|||||||
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
|
|||||||
|
|
||||||
object Parser:
|
object Parser:
|
||||||
|
|
||||||
/** Parses coordinate notation such as "e2e4" or "g1f3".
|
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
|
||||||
* Returns None for any input that does not match the expected format.
|
* format.
|
||||||
*/
|
*/
|
||||||
def parseMove(input: String): Option[(Square, Square)] =
|
def parseMove(input: String): Option[(Square, Square)] =
|
||||||
val trimmed = input.trim.toLowerCase
|
val trimmed = input.trim.toLowerCase
|
||||||
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
|
Option
|
||||||
|
.when(trimmed.length == 4)(trimmed)
|
||||||
|
.flatMap: s =>
|
||||||
for
|
for
|
||||||
from <- parseSquare(s.substring(0, 2))
|
from <- parseSquare(s.substring(0, 2))
|
||||||
to <- parseSquare(s.substring(2, 4))
|
to <- parseSquare(s.substring(2, 4))
|
||||||
yield (from, to)
|
yield (from, to)
|
||||||
|
|
||||||
private def parseSquare(s: String): Option[Square] =
|
private def parseSquare(s: String): Option[Square] =
|
||||||
Option.when(s.length == 2)(s).flatMap: sq =>
|
Option
|
||||||
|
.when(s.length == 2)(s)
|
||||||
|
.flatMap: sq =>
|
||||||
val fileIdx = sq(0) - 'a'
|
val fileIdx = sq(0) - 'a'
|
||||||
val rankIdx = sq(1) - '1'
|
val rankIdx = sq(1) - '1'
|
||||||
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
|
||||||
Square(File.values(fileIdx), Rank.values(rankIdx))
|
Square(File.values(fileIdx), Rank.values(rankIdx)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,44 +6,45 @@ import de.nowchess.api.game.GameContext
|
|||||||
import de.nowchess.chess.controller.Parser
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||||
import de.nowchess.io.{GameContextImport, GameContextExport}
|
import de.nowchess.io.{GameContextExport, GameContextImport}
|
||||||
import de.nowchess.rules.RuleSet
|
import de.nowchess.rules.RuleSet
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||||
* All rule queries delegate to the injected RuleSet.
|
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
|
||||||
* All user interactions go through Commands; state changes are broadcast via GameEvents.
|
|
||||||
*/
|
*/
|
||||||
class GameEngine(
|
class GameEngine(
|
||||||
val initialContext: GameContext = GameContext.initial,
|
val initialContext: GameContext = GameContext.initial,
|
||||||
val ruleSet: RuleSet = DefaultRules
|
val ruleSet: RuleSet = DefaultRules,
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentContext: GameContext = initialContext
|
private var currentContext: GameContext = initialContext
|
||||||
private val invoker = new CommandInvoker()
|
private val invoker = new CommandInvoker()
|
||||||
|
|
||||||
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
|
||||||
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var pendingPromotion: Option[PendingPromotion] = None
|
private var pendingPromotion: Option[PendingPromotion] = None
|
||||||
|
|
||||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized { currentContext.board }
|
def board: Board = synchronized(currentContext.board)
|
||||||
def turn: Color = synchronized { currentContext.turn }
|
def turn: Color = synchronized(currentContext.turn)
|
||||||
def context: GameContext = synchronized { currentContext }
|
def context: GameContext = synchronized(currentContext)
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||||
|
|
||||||
/** Check if redo is available. */
|
/** Check if redo is available. */
|
||||||
def canRedo: Boolean = synchronized { invoker.canRedo }
|
def canRedo: Boolean = synchronized(invoker.canRedo)
|
||||||
|
|
||||||
/** Get the command history for inspection (testing/debugging). */
|
/** Get the command history for inspection (testing/debugging). */
|
||||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
|
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
|
||||||
|
|
||||||
/** Process a raw move input string and update game state if valid.
|
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||||
* Notifies all observers of the outcome via GameEvent.
|
* GameEvent.
|
||||||
*/
|
*/
|
||||||
def processUserInput(rawInput: String): Unit = synchronized {
|
def processUserInput(rawInput: String): Unit = synchronized {
|
||||||
val trimmed = rawInput.trim.toLowerCase
|
val trimmed = rawInput.trim.toLowerCase
|
||||||
@@ -62,10 +63,12 @@ class GameEngine(
|
|||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(DrawClaimedEvent(currentContext))
|
notifyObservers(DrawClaimedEvent(currentContext))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
|
InvalidMoveEvent(
|
||||||
currentContext,
|
currentContext,
|
||||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
"Draw cannot be claimed: the 50-move rule has not been triggered.",
|
||||||
))
|
),
|
||||||
|
)
|
||||||
|
|
||||||
case "" =>
|
case "" =>
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
|
||||||
@@ -73,10 +76,12 @@ class GameEngine(
|
|||||||
case moveInput =>
|
case moveInput =>
|
||||||
Parser.parseMove(moveInput) match
|
Parser.parseMove(moveInput) match
|
||||||
case None =>
|
case None =>
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(
|
||||||
|
InvalidMoveEvent(
|
||||||
currentContext,
|
currentContext,
|
||||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
|
||||||
))
|
),
|
||||||
|
)
|
||||||
case Some((from, to)) =>
|
case Some((from, to)) =>
|
||||||
handleParsedMove(from, to)
|
handleParsedMove(from, to)
|
||||||
}
|
}
|
||||||
@@ -108,8 +113,7 @@ class GameEngine(
|
|||||||
to.rank.ordinal == promoRank
|
to.rank.ordinal == promoRank
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a player's promotion piece choice.
|
/** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
|
||||||
* Must only be called when isPendingPromotion is true.
|
|
||||||
*/
|
*/
|
||||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||||
pendingPromotion match
|
pendingPromotion match
|
||||||
@@ -120,22 +124,18 @@ class GameEngine(
|
|||||||
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
|
||||||
// Verify it's actually legal
|
// Verify it's actually legal
|
||||||
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
val legal = ruleSet.legalMoves(currentContext)(pending.from)
|
||||||
if legal.contains(move) then
|
if legal.contains(move) then executeMove(move)
|
||||||
executeMove(move)
|
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
||||||
else
|
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo the last move. */
|
/** Undo the last move. */
|
||||||
def undo(): Unit = synchronized { performUndo() }
|
def undo(): Unit = synchronized(performUndo())
|
||||||
|
|
||||||
/** Redo the last undone move. */
|
/** Redo the last undone move. */
|
||||||
def redo(): Unit = synchronized { performRedo() }
|
def redo(): Unit = synchronized(performRedo())
|
||||||
|
|
||||||
/** Load a game using the provided importer.
|
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||||
* If the imported context has moves, they are replayed through the command system.
|
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
|
||||||
* Otherwise, the position is set directly.
|
|
||||||
* Notifies observers with PgnLoadedEvent on success.
|
|
||||||
*/
|
*/
|
||||||
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||||
importer.importGameContext(input) match
|
importer.importGameContext(input) match
|
||||||
@@ -155,29 +155,24 @@ class GameEngine(
|
|||||||
if ctx.moves.isEmpty then
|
if ctx.moves.isEmpty then
|
||||||
currentContext = ctx
|
currentContext = ctx
|
||||||
Right(())
|
Right(())
|
||||||
else
|
else replayMoves(ctx.moves, savedContext)
|
||||||
replayMoves(ctx.moves, savedContext)
|
|
||||||
|
|
||||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||||
var error: Option[String] = None
|
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||||
moves.foreach: move =>
|
acc.flatMap(_ => applyReplayMove(move))
|
||||||
if error.isEmpty then
|
|
||||||
handleParsedMove(move.from, move.to)
|
|
||||||
|
|
||||||
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
|
result.left.foreach(_ => currentContext = savedContext)
|
||||||
case Some(err) =>
|
result
|
||||||
currentContext = savedContext
|
|
||||||
Left(err)
|
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||||
case None =>
|
handleParsedMove(move.from, move.to)
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
|
||||||
|
completePromotion(pp)
|
||||||
Right(())
|
Right(())
|
||||||
|
case MoveType.Promotion(_) =>
|
||||||
|
Left(s"Promotion required for move ${move.from}${move.to}")
|
||||||
|
case _ => Right(())
|
||||||
|
|
||||||
/** Export the current game context using the provided exporter. */
|
/** Export the current game context using the provided exporter. */
|
||||||
def exportGame(exporter: GameContextExport): String = synchronized {
|
def exportGame(exporter: GameContextExport): String = synchronized {
|
||||||
@@ -211,17 +206,19 @@ class GameEngine(
|
|||||||
to = move.to,
|
to = move.to,
|
||||||
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
||||||
previousContext = Some(contextBefore),
|
previousContext = Some(contextBefore),
|
||||||
notation = translateMoveToNotation(move, contextBefore.board)
|
notation = translateMoveToNotation(move, contextBefore.board),
|
||||||
)
|
)
|
||||||
invoker.execute(cmd)
|
invoker.execute(cmd)
|
||||||
currentContext = nextContext
|
currentContext = nextContext
|
||||||
|
|
||||||
notifyObservers(MoveExecutedEvent(
|
notifyObservers(
|
||||||
|
MoveExecutedEvent(
|
||||||
currentContext,
|
currentContext,
|
||||||
move.from.toString,
|
move.from.toString,
|
||||||
move.to.toString,
|
move.to.toString,
|
||||||
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if ruleSet.isCheckmate(currentContext) then
|
if ruleSet.isCheckmate(currentContext) then
|
||||||
val winner = currentContext.turn.opposite
|
val winner = currentContext.turn.opposite
|
||||||
@@ -232,11 +229,9 @@ class GameEngine(
|
|||||||
notifyObservers(StalemateEvent(currentContext))
|
notifyObservers(StalemateEvent(currentContext))
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
currentContext = GameContext.initial
|
currentContext = GameContext.initial
|
||||||
else if ruleSet.isCheck(currentContext) then
|
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
notifyObservers(CheckDetectedEvent(currentContext))
|
|
||||||
|
|
||||||
if currentContext.halfMoveClock >= 100 then
|
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
|
||||||
|
|
||||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||||
move.moveType match
|
move.moveType match
|
||||||
@@ -295,8 +290,7 @@ class GameEngine(
|
|||||||
moveCmd.previousContext.foreach(currentContext = _)
|
moveCmd.previousContext.foreach(currentContext = _)
|
||||||
invoker.undo()
|
invoker.undo()
|
||||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
||||||
else
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
|
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then
|
||||||
@@ -307,12 +301,13 @@ class GameEngine(
|
|||||||
currentContext = nextCtx
|
currentContext = nextCtx
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||||
notifyObservers(MoveRedoneEvent(
|
notifyObservers(
|
||||||
|
MoveRedoneEvent(
|
||||||
currentContext,
|
currentContext,
|
||||||
moveCmd.notation,
|
moveCmd.notation,
|
||||||
moveCmd.from.toString,
|
moveCmd.from.toString,
|
||||||
moveCmd.to.toString,
|
moveCmd.to.toString,
|
||||||
capturedDesc
|
capturedDesc,
|
||||||
))
|
),
|
||||||
else
|
)
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package de.nowchess.chess.observer
|
|||||||
import de.nowchess.api.board.{Color, Square}
|
import de.nowchess.api.board.{Color, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
|
|
||||||
/** Base trait for all game state events.
|
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
|
||||||
* Events are immutable snapshots of game state changes.
|
|
||||||
*/
|
*/
|
||||||
sealed trait GameEvent:
|
sealed trait GameEvent:
|
||||||
def context: GameContext
|
def context: GameContext
|
||||||
@@ -14,57 +13,57 @@ case class MoveExecutedEvent(
|
|||||||
context: GameContext,
|
context: GameContext,
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the current player is in check. */
|
/** Fired when the current player is in check. */
|
||||||
case class CheckDetectedEvent(
|
case class CheckDetectedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches checkmate. */
|
/** Fired when the game reaches checkmate. */
|
||||||
case class CheckmateEvent(
|
case class CheckmateEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
winner: Color
|
winner: Color,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the game reaches stalemate. */
|
/** Fired when the game reaches stalemate. */
|
||||||
case class StalemateEvent(
|
case class StalemateEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is invalid. */
|
/** Fired when a move is invalid. */
|
||||||
case class InvalidMoveEvent(
|
case class InvalidMoveEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
reason: String
|
reason: String,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||||
case class PromotionRequiredEvent(
|
case class PromotionRequiredEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square
|
to: Square,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when the board is reset. */
|
/** Fired when the board is reset. */
|
||||||
case class BoardResetEvent(
|
case class BoardResetEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||||
case class FiftyMoveRuleAvailableEvent(
|
case class FiftyMoveRuleAvailableEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||||
case class DrawClaimedEvent(
|
case class DrawClaimedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||||
case class MoveUndoneEvent(
|
case class MoveUndoneEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
pgnNotation: String
|
pgnNotation: String,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||||
@@ -73,12 +72,12 @@ case class MoveRedoneEvent(
|
|||||||
pgnNotation: String,
|
pgnNotation: String,
|
||||||
fromSquare: String,
|
fromSquare: String,
|
||||||
toSquare: String,
|
toSquare: String,
|
||||||
capturedPiece: Option[String]
|
capturedPiece: Option[String],
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||||
case class PgnLoadedEvent(
|
case class PgnLoadedEvent(
|
||||||
context: GameContext
|
context: GameContext,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Observer trait: implement to receive game state updates. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -14,7 +14,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
override def undo(): Boolean = false
|
override def undo(): Boolean = false
|
||||||
override def description: String = "Failing command"
|
override def description: String = "Failing command"
|
||||||
|
|
||||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
private case class ConditionalFailCommand(
|
||||||
|
var shouldFailOnUndo: Boolean = false,
|
||||||
|
var shouldFailOnExecute: Boolean = false,
|
||||||
|
) extends Command:
|
||||||
override def execute(): Boolean = !shouldFailOnExecute
|
override def execute(): Boolean = !shouldFailOnExecute
|
||||||
override def undo(): Boolean = !shouldFailOnUndo
|
override def undo(): Boolean = !shouldFailOnUndo
|
||||||
override def description: String = "Conditional fail"
|
override def description: String = "Conditional fail"
|
||||||
@@ -24,7 +27,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("execute rejects failing commands and keeps history unchanged"):
|
test("execute rejects failing commands and keeps history unchanged"):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -14,7 +14,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
from = from,
|
from = from,
|
||||||
to = to,
|
to = to,
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("execute appends commands and updates index"):
|
test("execute appends commands and updates index"):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.command
|
package de.nowchess.chess.command
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
val executable = MoveCommand(
|
val executable = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
)
|
)
|
||||||
executable.execute() shouldBe true
|
executable.execute() shouldBe true
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
undoable.undo() shouldBe true
|
undoable.undo() shouldBe true
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
val result = MoveResult.Successful(GameContext.initial, None)
|
val result = MoveResult.Successful(GameContext.initial, None)
|
||||||
val cmd2 = cmd1.copy(
|
val cmd2 = cmd1.copy(
|
||||||
moveResult = Some(result),
|
moveResult = Some(result),
|
||||||
previousContext = Some(GameContext.initial)
|
previousContext = Some(GameContext.initial),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd1.moveResult shouldBe None
|
cmd1.moveResult shouldBe None
|
||||||
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
|
|||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousContext = None
|
previousContext = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
val eq2 = MoveCommand(
|
val eq2 = MoveCommand(
|
||||||
from = sq(File.E, Rank.R2),
|
from = sq(File.E, Rank.R2),
|
||||||
to = sq(File.E, Rank.R4),
|
to = sq(File.E, Rank.R4),
|
||||||
moveResult = None,
|
moveResult = None,
|
||||||
previousContext = None
|
previousContext = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
eq1 shouldBe eq2
|
eq1 shouldBe eq2
|
||||||
|
|||||||
+20
-11
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
|
|||||||
|
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color}
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -60,16 +60,25 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6",
|
"a5c7",
|
||||||
"c8e6"
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
|
"c8e6",
|
||||||
)
|
)
|
||||||
|
|
||||||
moves.dropRight(1).foreach(engine.processUserInput)
|
moves.dropRight(1).foreach(engine.processUserInput)
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
|
||||||
engine.context shouldBe saved
|
engine.context shouldBe saved
|
||||||
|
|
||||||
|
|
||||||
test("normalMoveNotation handles missing source piece"):
|
test("normalMoveNotation handles missing source piece"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
|
||||||
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.observerCount shouldBe 1
|
engine.observerCount shouldBe 1
|
||||||
engine.unsubscribe(observer)
|
engine.unsubscribe(observer)
|
||||||
engine.observerCount shouldBe 0
|
engine.observerCount shouldBe 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
|||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import de.nowchess.api.board.{Board, Color}
|
import de.nowchess.api.board.{Board, Color}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
|
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
|
||||||
import de.nowchess.io.pgn.PgnParser
|
import de.nowchess.io.pgn.PgnParser
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
import de.nowchess.io.pgn.PgnExporter
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = false,
|
whiteKingSide = false,
|
||||||
whiteQueenSide = true,
|
whiteQueenSide = true,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = false
|
blackQueenSide = false,
|
||||||
)
|
)
|
||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
.withBoard(board)
|
.withBoard(board)
|
||||||
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// White castles queenside: e1c1
|
// White castles queenside: e1c1
|
||||||
engine.processUserInput("e1c1")
|
engine.processUserInput("e1c1")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// White pawn on e5 captures en passant to d6
|
// White pawn on e5 captures en passant to d6
|
||||||
engine.processUserInput("e5d6")
|
engine.processUserInput("e5d6")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
// Verify the captured pawn was found (computeCaptured EnPassant branch)
|
||||||
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
|
||||||
moveEvt.capturedPiece shouldBe defined
|
moveEvt.capturedPiece shouldBe defined
|
||||||
moveEvt.capturedPiece.get should include ("Black")
|
moveEvt.capturedPiece.get should include("Black")
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
@@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
// King moves e1 -> f1
|
// King moves e1 -> f1
|
||||||
engine.processUserInput("e1f1")
|
engine.processUserInput("e1f1")
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
|
|
||||||
events.clear()
|
events.clear()
|
||||||
engine.undo()
|
engine.undo()
|
||||||
|
|
||||||
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
val evt = events.collect { case e: MoveUndoneEvent => e }.head
|
||||||
evt.pgnNotation should startWith ("K")
|
evt.pgnNotation should startWith("K")
|
||||||
evt.pgnNotation should include ("f1")
|
evt.pgnNotation should include("f1")
|
||||||
|
|||||||
@@ -50,15 +50,24 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6"
|
"a5c7",
|
||||||
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
)
|
)
|
||||||
moves.foreach(engine.processUserInput)
|
moves.foreach(engine.processUserInput)
|
||||||
observer.clear()
|
observer.clear()
|
||||||
@@ -73,16 +82,25 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe(observer)
|
engine.subscribe(observer)
|
||||||
|
|
||||||
val moves = List(
|
val moves = List(
|
||||||
"e2e3", "a7a5",
|
"e2e3",
|
||||||
"d1h5", "a8a6",
|
"a7a5",
|
||||||
"h5a5", "h7h5",
|
"d1h5",
|
||||||
"h2h4", "a6h6",
|
"a8a6",
|
||||||
"a5c7", "f7f6",
|
"h5a5",
|
||||||
"c7d7", "e8f7",
|
"h7h5",
|
||||||
"d7b7", "d8d3",
|
"h2h4",
|
||||||
"b7b8", "d3h7",
|
"a6h6",
|
||||||
"b8c8", "f7g6",
|
"a5c7",
|
||||||
"c8e6"
|
"f7f6",
|
||||||
|
"c7d7",
|
||||||
|
"e8f7",
|
||||||
|
"d7b7",
|
||||||
|
"d8d3",
|
||||||
|
"b7b8",
|
||||||
|
"d3h7",
|
||||||
|
"b8c8",
|
||||||
|
"f7g6",
|
||||||
|
"c8e6",
|
||||||
)
|
)
|
||||||
|
|
||||||
moves.foreach(engine.processUserInput)
|
moves.foreach(engine.processUserInput)
|
||||||
|
|||||||
+26
-26
@@ -29,8 +29,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true)
|
||||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("isPendingPromotion is true after PromotionRequired input") {
|
test("isPendingPromotion is true after PromotionRequired input") {
|
||||||
@@ -40,12 +40,12 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
|
|
||||||
engine.isPendingPromotion should be (true)
|
engine.isPendingPromotion should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("isPendingPromotion is false before any promotion input") {
|
test("isPendingPromotion is false before any promotion input") {
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||||
@@ -56,11 +56,11 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
|
||||||
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with rook underpromotion") {
|
test("completePromotion with rook underpromotion") {
|
||||||
@@ -71,7 +71,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Rook)
|
engine.completePromotion(PromotionPiece.Rook)
|
||||||
|
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||||
@@ -80,8 +80,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||||
@@ -92,7 +92,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||||
@@ -103,10 +103,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
|
||||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||||
@@ -117,8 +117,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("h7h8")
|
engine.processUserInput("h7h8")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
|
events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||||
@@ -129,8 +129,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("b7b8")
|
engine.processUserInput("b7b8")
|
||||||
engine.completePromotion(PromotionPiece.Knight)
|
engine.completePromotion(PromotionPiece.Knight)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
|
events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion with black pawn promotion results in Moved") {
|
test("completePromotion with black pawn promotion results in Moved") {
|
||||||
@@ -141,10 +141,10 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
engine.processUserInput("e2e1")
|
engine.processUserInput("e2e1")
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
|
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
|
||||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
|
||||||
@@ -184,14 +184,14 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|||||||
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
|
||||||
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
|
||||||
engine.processUserInput("e7e8")
|
engine.processUserInput("e7e8")
|
||||||
engine.isPendingPromotion should be (true)
|
engine.isPendingPromotion should be(true)
|
||||||
|
|
||||||
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
|
||||||
// but only Normal moves exist → fires InvalidMoveEvent
|
// but only Normal moves exist → fires InvalidMoveEvent
|
||||||
engine.completePromotion(PromotionPiece.Queen)
|
engine.completePromotion(PromotionPiece.Queen)
|
||||||
|
|
||||||
engine.isPendingPromotion should be (false)
|
engine.isPendingPromotion should be(false)
|
||||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
|
||||||
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
|
||||||
invalidEvt.reason should include ("Error completing promotion")
|
invalidEvt.reason should include("Error completing promotion")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.engine
|
package de.nowchess.chess.engine
|
||||||
|
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
|
import de.nowchess.api.board.{Color, File, Piece, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import scala.util.Try
|
|||||||
|
|
||||||
/** Service for persisting and loading game states to/from disk.
|
/** Service for persisting and loading game states to/from disk.
|
||||||
*
|
*
|
||||||
* Abstracts file I/O operations away from the UI layer.
|
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||||
* Handles both reading and writing game files.
|
|
||||||
*/
|
*/
|
||||||
trait GameFileService:
|
trait GameFileService:
|
||||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||||
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
|
|||||||
()
|
()
|
||||||
}.fold(
|
}.fold(
|
||||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||||
_ => Right(())
|
_ => Right(()),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Load a game context from a file using the specified importer. */
|
/** Load a game context from a file using the specified importer. */
|
||||||
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
|
|||||||
importer.importGameContext(json)
|
importer.importGameContext(json)
|
||||||
}.fold(
|
}.fold(
|
||||||
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||||
result => result
|
result => result,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,21 +15,15 @@ object FenExporter extends GameContextExport:
|
|||||||
/** Build the FEN representation for a single rank. */
|
/** Build the FEN representation for a single rank. */
|
||||||
private def buildRankString(board: Board, rank: Rank): String =
|
private def buildRankString(board: Board, rank: Rank): String =
|
||||||
val rankSquares = File.values.map(file => Square(file, rank))
|
val rankSquares = File.values.map(file => Square(file, rank))
|
||||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
|
||||||
var emptyCount = 0
|
case ((acc, empty), square) =>
|
||||||
|
|
||||||
for square <- rankSquares do
|
|
||||||
board.pieceAt(square) match
|
board.pieceAt(square) match
|
||||||
case Some(piece) =>
|
case Some(piece) =>
|
||||||
if emptyCount > 0 then
|
val flushed = if empty > 0 then acc + empty.toString else acc
|
||||||
rankChars += emptyCount.toString.charAt(0)
|
(flushed + pieceToFenChar(piece), 0)
|
||||||
emptyCount = 0
|
|
||||||
rankChars += pieceToFenChar(piece)
|
|
||||||
case None =>
|
case None =>
|
||||||
emptyCount += 1
|
(acc, empty + 1)
|
||||||
|
if emptyCount > 0 then result + emptyCount.toString else result
|
||||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
|
||||||
rankChars.mkString
|
|
||||||
|
|
||||||
/** Convert a GameContext to a complete FEN string. */
|
/** Convert a GameContext to a complete FEN string. */
|
||||||
def gameContextToFen(context: GameContext): String =
|
def gameContextToFen(context: GameContext): String =
|
||||||
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
|
|||||||
case PieceType.Queen => 'q'
|
case PieceType.Queen => 'q'
|
||||||
case PieceType.King => 'k'
|
case PieceType.King => 'k'
|
||||||
if piece.color == Color.White then base.toUpper else base
|
if piece.color == Color.White then base.toUpper else base
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import de.nowchess.io.GameContextImport
|
|||||||
|
|
||||||
object FenParser extends GameContextImport:
|
object FenParser extends GameContextImport:
|
||||||
|
|
||||||
/** Parse a complete FEN string into a GameContext.
|
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
|
||||||
* Returns Left with error message if the format is invalid. */
|
*/
|
||||||
def parseFen(fen: String): Either[String, GameContext] =
|
def parseFen(fen: String): Either[String, GameContext] =
|
||||||
val parts = fen.trim.split("\\s+")
|
val parts = fen.trim.split("\\s+")
|
||||||
if parts.length != 6 then
|
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
|
||||||
else
|
else
|
||||||
for
|
for
|
||||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||||
@@ -27,7 +26,7 @@ object FenParser extends GameContextImport:
|
|||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassant,
|
enPassantSquare = enPassant,
|
||||||
halfMoveClock = halfMoveClock,
|
halfMoveClock = halfMoveClock,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
|
|||||||
|
|
||||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
||||||
private def parseCastling(s: String): Option[CastlingRights] =
|
private def parseCastling(s: String): Option[CastlingRights] =
|
||||||
if s == "-" then
|
if s == "-" then Some(CastlingRights.None)
|
||||||
Some(CastlingRights.None)
|
|
||||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||||
Some(CastlingRights(
|
Some(
|
||||||
|
CastlingRights(
|
||||||
whiteKingSide = s.contains('K'),
|
whiteKingSide = s.contains('K'),
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackKingSide = s.contains('k'),
|
blackKingSide = s.contains('k'),
|
||||||
blackQueenSide = s.contains('q')
|
blackQueenSide = s.contains('q'),
|
||||||
))
|
),
|
||||||
else
|
)
|
||||||
None
|
else None
|
||||||
|
|
||||||
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
||||||
private def parseEnPassant(s: String): Option[Option[Square]] =
|
private def parseEnPassant(s: String): Option[Option[Square]] =
|
||||||
if s == "-" then Some(None)
|
if s == "-" then Some(None)
|
||||||
else Square.fromAlgebraic(s).map(Some(_))
|
else Square.fromAlgebraic(s).map(Some(_))
|
||||||
|
|
||||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
|
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format
|
||||||
* Returns None if the format is invalid. */
|
* is invalid.
|
||||||
|
*/
|
||||||
def parseBoard(fen: String): Option[Board] =
|
def parseBoard(fen: String): Option[Board] =
|
||||||
val rankStrings = fen.split("/", -1)
|
val rankStrings = fen.split("/", -1)
|
||||||
if rankStrings.length != 8 then None
|
if rankStrings.length != 8 then None
|
||||||
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
|
|||||||
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
||||||
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
||||||
|
|
||||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
|
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the
|
||||||
* Returns None if the rank string contains invalid characters or the wrong number of files. */
|
* rank string contains invalid characters or the wrong number of files.
|
||||||
|
*/
|
||||||
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
||||||
var fileIdx = 0
|
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
|
||||||
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
|
case ((idx, true, acc), _) => (idx, true, acc)
|
||||||
var failed = false
|
case ((idx, false, acc), c) =>
|
||||||
|
if idx > 7 then (idx, true, acc)
|
||||||
for c <- rankStr if !failed do
|
else if c.isDigit then (idx + c.asDigit, false, acc)
|
||||||
if fileIdx > 7 then
|
|
||||||
failed = true
|
|
||||||
else if c.isDigit then
|
|
||||||
fileIdx += c.asDigit
|
|
||||||
else
|
else
|
||||||
charToPiece(c) match
|
charToPiece(c) match
|
||||||
case None => failed = true
|
case None => (idx, true, acc)
|
||||||
case Some(piece) =>
|
case Some(piece) =>
|
||||||
val file = File.values(fileIdx)
|
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
|
||||||
squares += (Square(file, rank) -> piece)
|
|
||||||
fileIdx += 1
|
|
||||||
|
|
||||||
if failed || fileIdx != 8 then None
|
if failed || fileIdx != 8 then None
|
||||||
else Some(squares.toList)
|
else Some(squares)
|
||||||
|
|
||||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
||||||
private def charToPiece(c: Char): Option[Piece] =
|
private def charToPiece(c: Char): Option[Piece] =
|
||||||
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
|
|||||||
case 'k' => Some(PieceType.King)
|
case 'k' => Some(PieceType.King)
|
||||||
case _ => None
|
case _ => None
|
||||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||||
|
|
||||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
|
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
|
||||||
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
* placement exceeds board bounds.
|
||||||
|
*/
|
||||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||||
rankTokens >> { tokens =>
|
rankTokens >> { tokens =>
|
||||||
buildSquares(rank, tokens) match
|
buildSquares(rank, tokens) match
|
||||||
@@ -51,8 +52,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
(rankSep ~> rankParser(Rank.R4)) ~
|
(rankSep ~> rankParser(Rank.R4)) ~
|
||||||
(rankSep ~> rankParser(Rank.R3)) ~
|
(rankSep ~> rankParser(Rank.R3)) ~
|
||||||
(rankSep ~> rankParser(Rank.R2)) ~
|
(rankSep ~> rankParser(Rank.R2)) ~
|
||||||
(rankSep ~> rankParser(Rank.R1)) ^^ {
|
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||||
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
|
||||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
whiteKingSide = s.contains('K'),
|
whiteKingSide = s.contains('K'),
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackKingSide = s.contains('k'),
|
blackKingSide = s.contains('k'),
|
||||||
blackQueenSide = s.contains('q')
|
blackQueenSide = s.contains('q'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
castlingRights = castling,
|
castlingRights = castling,
|
||||||
enPassantSquare = ep,
|
enPassantSquare = ep,
|
||||||
halfMoveClock = halfMove,
|
halfMoveClock = halfMove,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
whiteKingSide = s.contains('K'),
|
whiteKingSide = s.contains('K'),
|
||||||
whiteQueenSide = s.contains('Q'),
|
whiteQueenSide = s.contains('Q'),
|
||||||
blackKingSide = s.contains('k'),
|
blackKingSide = s.contains('k'),
|
||||||
blackQueenSide = s.contains('q')
|
blackQueenSide = s.contains('q'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ object FenParserFastParse extends GameContextImport:
|
|||||||
castlingRights = castling,
|
castlingRights = castling,
|
||||||
enPassantSquare = ep,
|
enPassantSquare = ep,
|
||||||
halfMoveClock = halfMove,
|
halfMoveClock = halfMove,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ private[fen] object FenParserSupport:
|
|||||||
'n' -> PieceType.Knight,
|
'n' -> PieceType.Knight,
|
||||||
'b' -> PieceType.Bishop,
|
'b' -> PieceType.Bishop,
|
||||||
'q' -> PieceType.Queen,
|
'q' -> PieceType.Queen,
|
||||||
'k' -> PieceType.King
|
'k' -> PieceType.King,
|
||||||
)
|
)
|
||||||
|
|
||||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||||
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
tokens
|
||||||
|
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||||
case (None, _) => None
|
case (None, _) => None
|
||||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||||
if fileIdx > 7 then None
|
if fileIdx > 7 then None
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
|||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.io.GameContextExport
|
import de.nowchess.io.GameContextExport
|
||||||
import de.nowchess.io.pgn.PgnExporter
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
import java.time.{LocalDate, ZonedDateTime, ZoneId}
|
import java.time.{LocalDate, ZoneId, ZonedDateTime}
|
||||||
|
|
||||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||||
*
|
*
|
||||||
@@ -42,9 +42,10 @@ object JsonExporter extends GameContextExport:
|
|||||||
formatJson(mapper.writeValueAsString(record))
|
formatJson(mapper.writeValueAsString(record))
|
||||||
|
|
||||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||||
val pgn = try {
|
val pgn =
|
||||||
|
try
|
||||||
Some(PgnExporter.exportGameContext(context))
|
Some(PgnExporter.exportGameContext(context))
|
||||||
} catch {
|
catch {
|
||||||
case _: Exception => None
|
case _: Exception => None
|
||||||
}
|
}
|
||||||
JsonGameRecord(
|
JsonGameRecord(
|
||||||
@@ -53,7 +54,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
moveHistory = pgn,
|
moveHistory = pgn,
|
||||||
moves = Some(buildMoves(context.moves)),
|
moves = Some(buildMoves(context.moves)),
|
||||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildMetadata(): JsonMetadata =
|
private def buildMetadata(): JsonMetadata =
|
||||||
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
event = Some("Game"),
|
event = Some("Game"),
|
||||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||||
date = Some(LocalDate.now().toString),
|
date = Some(LocalDate.now().toString),
|
||||||
result = Some("*")
|
result = Some("*"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildGameState(context: GameContext): JsonGameState =
|
private def buildGameState(context: GameContext): JsonGameState =
|
||||||
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
turn = Some(context.turn.label),
|
turn = Some(context.turn.label),
|
||||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||||
halfMoveClock = Some(context.halfMoveClock)
|
halfMoveClock = Some(context.halfMoveClock),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||||
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
|
|||||||
Some(rights.whiteKingSide),
|
Some(rights.whiteKingSide),
|
||||||
Some(rights.whiteQueenSide),
|
Some(rights.whiteQueenSide),
|
||||||
Some(rights.blackKingSide),
|
Some(rights.blackKingSide),
|
||||||
Some(rights.blackQueenSide)
|
Some(rights.blackQueenSide),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||||
@@ -136,4 +137,3 @@ object JsonExporter extends GameContextExport:
|
|||||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||||
(blackCaptured, whiteCaptured)
|
(blackCaptured, whiteCaptured)
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ case class JsonMetadata(
|
|||||||
event: Option[String] = None,
|
event: Option[String] = None,
|
||||||
players: Option[Map[String, String]] = None,
|
players: Option[Map[String, String]] = None,
|
||||||
date: Option[String] = None,
|
date: Option[String] = None,
|
||||||
result: Option[String] = None
|
result: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonPiece(
|
case class JsonPiece(
|
||||||
square: Option[String] = None,
|
square: Option[String] = None,
|
||||||
color: Option[String] = None,
|
color: Option[String] = None,
|
||||||
piece: Option[String] = None
|
piece: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonCastlingRights(
|
case class JsonCastlingRights(
|
||||||
whiteKingSide: Option[Boolean] = None,
|
whiteKingSide: Option[Boolean] = None,
|
||||||
whiteQueenSide: Option[Boolean] = None,
|
whiteQueenSide: Option[Boolean] = None,
|
||||||
blackKingSide: Option[Boolean] = None,
|
blackKingSide: Option[Boolean] = None,
|
||||||
blackQueenSide: Option[Boolean] = None
|
blackQueenSide: Option[Boolean] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonGameState(
|
case class JsonGameState(
|
||||||
@@ -25,24 +25,24 @@ case class JsonGameState(
|
|||||||
turn: Option[String] = None,
|
turn: Option[String] = None,
|
||||||
castlingRights: Option[JsonCastlingRights] = None,
|
castlingRights: Option[JsonCastlingRights] = None,
|
||||||
enPassantSquare: Option[String] = None,
|
enPassantSquare: Option[String] = None,
|
||||||
halfMoveClock: Option[Int] = None
|
halfMoveClock: Option[Int] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonCapturedPieces(
|
case class JsonCapturedPieces(
|
||||||
byWhite: Option[List[String]] = None,
|
byWhite: Option[List[String]] = None,
|
||||||
byBlack: Option[List[String]] = None
|
byBlack: Option[List[String]] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonMoveType(
|
case class JsonMoveType(
|
||||||
`type`: Option[String] = None,
|
`type`: Option[String] = None,
|
||||||
isCapture: Option[Boolean] = None,
|
isCapture: Option[Boolean] = None,
|
||||||
promotionPiece: Option[String] = None
|
promotionPiece: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonMove(
|
case class JsonMove(
|
||||||
from: Option[String] = None,
|
from: Option[String] = None,
|
||||||
to: Option[String] = None,
|
to: Option[String] = None,
|
||||||
`type`: Option[JsonMoveType] = None
|
`type`: Option[JsonMoveType] = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
case class JsonGameRecord(
|
case class JsonGameRecord(
|
||||||
@@ -51,5 +51,5 @@ case class JsonGameRecord(
|
|||||||
moveHistory: Option[String] = None,
|
moveHistory: Option[String] = None,
|
||||||
moves: Option[List[JsonMove]] = None,
|
moves: Option[List[JsonMove]] = None,
|
||||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||||
timestamp: Option[String] = None
|
timestamp: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
|
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
|
||||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
@@ -27,8 +27,8 @@ object JsonParser extends GameContextImport:
|
|||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||||
.left.map(e => "JSON parsing error: " + e.getMessage)
|
.map(e => "JSON parsing error: " + e.getMessage)
|
||||||
.flatMap { data =>
|
.flatMap { data =>
|
||||||
val gs = data.gameState.getOrElse(JsonGameState())
|
val gs = data.gameState.getOrElse(JsonGameState())
|
||||||
val rawBoard = gs.board.getOrElse(Nil)
|
val rawBoard = gs.board.getOrElse(Nil)
|
||||||
@@ -49,7 +49,7 @@ object JsonParser extends GameContextImport:
|
|||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassantSquare,
|
enPassantSquare = enPassantSquare,
|
||||||
halfMoveClock = rawHmc,
|
halfMoveClock = rawHmc,
|
||||||
moves = moves
|
moves = moves,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ object JsonParser extends GameContextImport:
|
|||||||
cr.whiteKingSide.getOrElse(false),
|
cr.whiteKingSide.getOrElse(false),
|
||||||
cr.whiteQueenSide.getOrElse(false),
|
cr.whiteQueenSide.getOrElse(false),
|
||||||
cr.blackKingSide.getOrElse(false),
|
cr.blackKingSide.getOrElse(false),
|
||||||
cr.blackQueenSide.getOrElse(false)
|
cr.blackQueenSide.getOrElse(false),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||||
|
|||||||
@@ -14,25 +14,24 @@ object PgnExporter extends GameContextExport:
|
|||||||
"Event" -> "?",
|
"Event" -> "?",
|
||||||
"White" -> "?",
|
"White" -> "?",
|
||||||
"Black" -> "?",
|
"Black" -> "?",
|
||||||
"Result" -> "*"
|
"Result" -> "*",
|
||||||
)
|
)
|
||||||
|
|
||||||
exportGame(headers, context.moves)
|
exportGame(headers, context.moves)
|
||||||
|
|
||||||
/** Export a game with headers and moves to PGN format. */
|
/** Export a game with headers and moves to PGN format. */
|
||||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||||
val headerLines = headers.map { case (key, value) =>
|
val headerLines = headers
|
||||||
|
.map { case (key, value) =>
|
||||||
s"""[$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
|
|
||||||
}
|
}
|
||||||
|
.mkString("\n")
|
||||||
|
|
||||||
|
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 groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||||
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
|
|||||||
case PieceType.Rook => s"R$capStr$dest"
|
case PieceType.Rook => s"R$capStr$dest"
|
||||||
case PieceType.Queen => s"Q$capStr$dest"
|
case PieceType.Queen => s"Q$capStr$dest"
|
||||||
case PieceType.King => s"K$capStr$dest"
|
case PieceType.King => s"K$capStr$dest"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import de.nowchess.rules.sets.DefaultRules
|
|||||||
/** A parsed PGN game containing headers and the resolved move list. */
|
/** A parsed PGN game containing headers and the resolved move list. */
|
||||||
case class PgnGame(
|
case class PgnGame(
|
||||||
headers: Map[String, String],
|
headers: Map[String, String],
|
||||||
moves: List[Move]
|
moves: List[Move],
|
||||||
)
|
)
|
||||||
|
|
||||||
object PgnParser extends GameContextImport:
|
object PgnParser extends GameContextImport:
|
||||||
|
|
||||||
/** Strictly validate a PGN text.
|
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
|
||||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
*/
|
||||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
val lines = pgn.split("\n").map(_.trim)
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||||
@@ -24,16 +24,18 @@ object PgnParser extends GameContextImport:
|
|||||||
val moveText = rest.mkString(" ")
|
val moveText = rest.mkString(" ")
|
||||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||||
|
|
||||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
|
||||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
|
||||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
* issue.
|
||||||
|
*/
|
||||||
def importGameContext(input: String): Either[String, GameContext] =
|
def importGameContext(input: String): Either[String, GameContext] =
|
||||||
validatePgn(input).flatMap { game =>
|
validatePgn(input).flatMap { game =>
|
||||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
/** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
|
||||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
* are silently skipped.
|
||||||
|
*/
|
||||||
def parsePgn(pgn: String): Option[PgnGame] =
|
def parsePgn(pgn: String): Option[PgnGame] =
|
||||||
val lines = pgn.split("\n").map(_.trim)
|
val lines = pgn.split("\n").map(_.trim)
|
||||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||||
@@ -51,7 +53,7 @@ object PgnParser extends GameContextImport:
|
|||||||
private def parseMovesText(moveText: String): List[Move] =
|
private def parseMovesText(moveText: String): List[Move] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
val (_, _, moves) = tokens.foldLeft(
|
val (_, _, moves) = tokens.foldLeft(
|
||||||
(GameContext.initial, Color.White, List.empty[Move])
|
(GameContext.initial, Color.White, List.empty[Move]),
|
||||||
):
|
):
|
||||||
case (state @ (ctx, color, acc), token) =>
|
case (state @ (ctx, color, acc), token) =>
|
||||||
if isMoveNumberOrResult(token) then state
|
if isMoveNumberOrResult(token) then state
|
||||||
@@ -98,7 +100,9 @@ object PgnParser extends GameContextImport:
|
|||||||
if clean.length < 2 then None
|
if clean.length < 2 then None
|
||||||
else
|
else
|
||||||
val destStr = clean.takeRight(2)
|
val destStr = clean.takeRight(2)
|
||||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
Square
|
||||||
|
.fromAlgebraic(destStr)
|
||||||
|
.flatMap: toSquare =>
|
||||||
val disambig = clean.dropRight(2)
|
val disambig = clean.dropRight(2)
|
||||||
|
|
||||||
val requiredPieceType: Option[PieceType] =
|
val requiredPieceType: Option[PieceType] =
|
||||||
@@ -116,9 +120,11 @@ object PgnParser extends GameContextImport:
|
|||||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||||
val candidates = allLegal.filter { move =>
|
val candidates = allLegal.filter { move =>
|
||||||
move.to == toSquare &&
|
move.to == toSquare &&
|
||||||
ctx.board.pieceAt(move.from).exists(p =>
|
ctx.board
|
||||||
|
.pieceAt(move.from)
|
||||||
|
.exists(p =>
|
||||||
p.color == color &&
|
p.color == color &&
|
||||||
requiredPieceType.forall(_ == p.pieceType)
|
requiredPieceType.forall(_ == p.pieceType),
|
||||||
) &&
|
) &&
|
||||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||||
promotionMatches(move, promotion)
|
promotionMatches(move, promotion)
|
||||||
@@ -131,12 +137,13 @@ object PgnParser extends GameContextImport:
|
|||||||
hint.forall(c =>
|
hint.forall(c =>
|
||||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||||
else true
|
else true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||||
promotion match
|
promotion match
|
||||||
case None => move.moveType match
|
case None =>
|
||||||
|
move.moveType match
|
||||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||||
case _ => false
|
case _ => false
|
||||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||||
@@ -168,8 +175,10 @@ object PgnParser extends GameContextImport:
|
|||||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
tokens
|
||||||
case (acc, token) =>
|
.foldLeft(
|
||||||
|
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
|
||||||
|
) { case (acc, token) =>
|
||||||
acc.flatMap { case (ctx, color, moves) =>
|
acc.flatMap { case (ctx, color, moves) =>
|
||||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||||
else
|
else
|
||||||
@@ -179,6 +188,5 @@ object PgnParser extends GameContextImport:
|
|||||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||||
Right((nextCtx, color.opposite, moves :+ move))
|
Right((nextCtx, color.opposite, moves :+ move))
|
||||||
}
|
}
|
||||||
}.map(_._3)
|
}
|
||||||
|
.map(_._3)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io
|
package de.nowchess.io
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Square, File, Rank}
|
import de.nowchess.api.board.{File, Rank, Square}
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
import java.nio.file.{Files, Paths}
|
import java.nio.file.{Files, Paths}
|
||||||
@@ -20,8 +20,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
assert(Files.exists(tmpFile))
|
assert(Files.exists(tmpFile))
|
||||||
assert(Files.size(tmpFile) > 0)
|
assert(Files.size(tmpFile) > 0)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: reads JSON file successfully") {
|
test("loadGameFromFile: reads JSON file successfully") {
|
||||||
@@ -38,8 +37,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
val loaded = result.getOrElse(GameContext.initial)
|
val loaded = result.getOrElse(GameContext.initial)
|
||||||
assert(loaded == originalContext)
|
assert(loaded == originalContext)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: returns error on missing file") {
|
test("loadGameFromFile: returns error on missing file") {
|
||||||
@@ -65,8 +63,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(loadResult.isRight)
|
assert(loadResult.isRight)
|
||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 2)
|
assert(loaded.moves.length == 2)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("saveGameToFile: overwrites existing file") {
|
test("saveGameToFile: overwrites existing file") {
|
||||||
@@ -86,8 +83,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(loadResult.isRight)
|
assert(loadResult.isRight)
|
||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 1)
|
assert(loaded.moves.length == 1)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("loadGameFromFile: handles invalid JSON in file") {
|
test("loadGameFromFile: handles invalid JSON in file") {
|
||||||
@@ -97,8 +93,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||||
|
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("round-trip: save and load preserves game state") {
|
test("round-trip: save and load preserves game state") {
|
||||||
@@ -118,8 +113,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||||
assert(loaded.moves.length == 2)
|
assert(loaded.moves.length == 2)
|
||||||
assert(loaded.halfMoveClock == 3)
|
assert(loaded.halfMoveClock == 3)
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("saveGameToFile: handles exporter that throws exception") {
|
test("saveGameToFile: handles exporter that throws exception") {
|
||||||
@@ -134,6 +128,5 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
|||||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||||
assert(result.isLeft)
|
assert(result.isLeft)
|
||||||
assert(result.left.toOption.get.contains("Failed to save file"))
|
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||||
finally
|
finally Files.deleteIfExists(tmpFile)
|
||||||
Files.deleteIfExists(tmpFile)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights: CastlingRights,
|
castlingRights: CastlingRights,
|
||||||
enPassantSquare: Option[Square],
|
enPassantSquare: Option[Square],
|
||||||
halfMoveClock: Int,
|
halfMoveClock: Int,
|
||||||
moveCount: Int
|
moveCount: Int,
|
||||||
): GameContext =
|
): GameContext =
|
||||||
val board = FenParser.parseBoard(piecePlacement).getOrElse(
|
val board = FenParser
|
||||||
fail(s"Invalid test board FEN: $piecePlacement")
|
.parseBoard(piecePlacement)
|
||||||
|
.getOrElse(
|
||||||
|
fail(s"Invalid test board FEN: $piecePlacement"),
|
||||||
)
|
)
|
||||||
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
|
||||||
GameContext(
|
GameContext(
|
||||||
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = castlingRights,
|
castlingRights = castlingRights,
|
||||||
enPassantSquare = enPassantSquare,
|
enPassantSquare = enPassantSquare,
|
||||||
halfMoveClock = halfMoveClock,
|
halfMoveClock = halfMoveClock,
|
||||||
moves = List.fill(moveCount)(dummyMove)
|
moves = List.fill(moveCount)(dummyMove),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("exportGameContextToFen handles initial and typical developed position"):
|
test("exportGameContextToFen handles initial and typical developed position"):
|
||||||
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
enPassantSquare = Some(Square(File.E, Rank.R3)),
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moveCount = 0
|
moveCount = 0,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(gameContext) shouldBe
|
FenExporter.gameContextToFen(gameContext) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
|
||||||
@@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.None,
|
castlingRights = CastlingRights.None,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moveCount = 0
|
moveCount = 0,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(noCastling) shouldBe
|
FenExporter.gameContextToFen(noCastling) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||||
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
whiteKingSide = true,
|
whiteKingSide = true,
|
||||||
whiteQueenSide = false,
|
whiteQueenSide = false,
|
||||||
blackKingSide = false,
|
blackKingSide = false,
|
||||||
blackQueenSide = true
|
blackQueenSide = true,
|
||||||
),
|
),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 5,
|
halfMoveClock = 5,
|
||||||
moveCount = 4
|
moveCount = 4,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(partialCastling) shouldBe
|
FenExporter.gameContextToFen(partialCastling) shouldBe
|
||||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
|
||||||
@@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
enPassantSquare = Some(Square(File.C, Rank.R6)),
|
||||||
halfMoveClock = 2,
|
halfMoveClock = 2,
|
||||||
moveCount = 4
|
moveCount = 4,
|
||||||
)
|
)
|
||||||
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
FenExporter.gameContextToFen(withEnPassant) shouldBe
|
||||||
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
|
||||||
@@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
castlingRights = CastlingRights.All,
|
castlingRights = CastlingRights.All,
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 42,
|
halfMoveClock = 42,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
val fen = FenExporter.gameContextToFen(gameContext)
|
val fen = FenExporter.gameContextToFen(gameContext)
|
||||||
FenParser.parseFen(fen) match
|
FenParser.parseFen(fen) match
|
||||||
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial
|
val ctx = GameContext.initial
|
||||||
|
|
||||||
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -11,30 +11,48 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
|
|||||||
val empty = "8/8/8/8/8/8/8/8"
|
val empty = "8/8/8/8/8/8/8/8"
|
||||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||||
|
|
||||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
|
||||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
Some(Piece.WhitePawn),
|
||||||
|
)
|
||||||
|
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(
|
||||||
|
Some(Piece.BlackKing),
|
||||||
|
)
|
||||||
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||||
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(
|
||||||
|
Some(Piece.BlackKing),
|
||||||
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||||
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
test("parseFen parses full state for common valid inputs"):
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.White
|
ctx.turn shouldBe Color.White
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.enPassantSquare shouldBe None
|
ctx.enPassantSquare shouldBe None
|
||||||
ctx.halfMoveClock shouldBe 0
|
ctx.halfMoveClock shouldBe 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.Black
|
ctx.turn shouldBe Color.Black
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
FenParserCombinators
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
test("parseFen rejects invalid color and castling tokens"):
|
||||||
|
|||||||
@@ -20,21 +20,33 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
|||||||
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
test("parseFen parses full state for common valid inputs"):
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.White
|
ctx.turn shouldBe Color.White
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.enPassantSquare shouldBe None
|
ctx.enPassantSquare shouldBe None
|
||||||
ctx.halfMoveClock shouldBe 0
|
ctx.halfMoveClock shouldBe 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.Black
|
ctx.turn shouldBe Color.Black
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
test("parseFen rejects invalid color and castling tokens"):
|
||||||
|
|||||||
@@ -20,21 +20,33 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
|||||||
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
test("parseFen parses full state for common valid inputs"):
|
test("parseFen parses full state for common valid inputs"):
|
||||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParser
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.White
|
ctx.turn shouldBe Color.White
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.enPassantSquare shouldBe None
|
ctx.enPassantSquare shouldBe None
|
||||||
ctx.halfMoveClock shouldBe 0
|
ctx.halfMoveClock shouldBe 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
FenParser
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.turn shouldBe Color.Black
|
ctx.turn shouldBe Color.Black
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
FenParser
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("parseFen rejects invalid color and castling tokens"):
|
test("parseFen rejects invalid color and castling tokens"):
|
||||||
@@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
|||||||
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||||
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||||
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -13,17 +13,17 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
(PromotionPiece.Queen, "queen"),
|
(PromotionPiece.Queen, "queen"),
|
||||||
(PromotionPiece.Rook, "rook"),
|
(PromotionPiece.Rook, "rook"),
|
||||||
(PromotionPiece.Bishop, "bishop"),
|
(PromotionPiece.Bishop, "bishop"),
|
||||||
(PromotionPiece.Knight, "knight")
|
(PromotionPiece.Knight, "knight"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((piece, expectedName) <- promotions) do
|
for (piece, expectedName) <- promotions do
|
||||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||||
// Empty boards can cause issues in PgnExporter, using initial
|
// Empty boards can cause issues in PgnExporter, using initial
|
||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include (s""""$expectedName"""")
|
json should include(s""""$expectedName"""")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"normal\"")
|
json should include("\"normal\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export normal capture move manually") {
|
test("export normal capture move manually") {
|
||||||
@@ -39,8 +39,8 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"normal\"")
|
json should include("\"normal\"")
|
||||||
json should include ("\"isCapture\": true")
|
json should include("\"isCapture\": true")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +49,9 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
|
|
||||||
json should include ("\"moves\"")
|
json should include("\"moves\"")
|
||||||
json should include ("\"from\"")
|
json should include("\"from\"")
|
||||||
json should include ("\"to\"")
|
json should include("\"to\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export castle queenside move") {
|
test("export castle queenside move") {
|
||||||
@@ -59,7 +59,7 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"castleQueenside\"")
|
json should include("\"castleQueenside\"")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"castleKingside\"")
|
json should include("\"castleKingside\"")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
|||||||
val ctx = GameContext.initial.copy(moves = List(move))
|
val ctx = GameContext.initial.copy(moves = List(move))
|
||||||
try {
|
try {
|
||||||
val json = JsonExporter.exportGameContext(ctx)
|
val json = JsonExporter.exportGameContext(ctx)
|
||||||
json should include ("\"enPassant\"")
|
json should include("\"enPassant\"")
|
||||||
json should include ("\"isCapture\": true")
|
json should include("\"isCapture\": true")
|
||||||
} catch { case _: Exception => }
|
} catch { case _: Exception => }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
|||||||
Some("White"),
|
Some("White"),
|
||||||
Some(JsonCastlingRights()),
|
Some(JsonCastlingRights()),
|
||||||
Some("e3"),
|
Some("e3"),
|
||||||
Some(5)
|
Some(5),
|
||||||
)
|
)
|
||||||
assert(gs.board.contains(Nil))
|
assert(gs.board.contains(Nil))
|
||||||
assert(gs.halfMoveClock.contains(5))
|
assert(gs.halfMoveClock.contains(5))
|
||||||
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
|||||||
Some(""),
|
Some(""),
|
||||||
Some(Nil),
|
Some(Nil),
|
||||||
Some(JsonCapturedPieces()),
|
Some(JsonCapturedPieces()),
|
||||||
Some("2026-04-08T00:00:00Z")
|
Some("2026-04-08T00:00:00Z"),
|
||||||
)
|
)
|
||||||
assert(record.metadata.nonEmpty)
|
assert(record.metadata.nonEmpty)
|
||||||
assert(record.timestamp.nonEmpty)
|
assert(record.timestamp.nonEmpty)
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
|||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
val ctx = result.toOption.get
|
val ctx = result.toOption.get
|
||||||
assert(ctx.board.pieces.size == 6)
|
assert(ctx.board.pieces.size == 6)
|
||||||
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
|
assert(
|
||||||
|
ctx.board
|
||||||
|
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||||
|
.get
|
||||||
|
.pieceType == PieceType.Pawn,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parse with all castling rights false") {
|
test("parse with all castling rights false") {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
|
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.json
|
package de.nowchess.io.json
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
|
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
@@ -68,7 +68,8 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("importGameContext: handles missing fields with defaults") {
|
test("importGameContext: handles missing fields with defaults") {
|
||||||
val json = "{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
val json =
|
||||||
|
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||||
val result = JsonParser.importGameContext(json)
|
val result = JsonParser.importGameContext(json)
|
||||||
|
|
||||||
assert(result.isRight)
|
assert(result.isRight)
|
||||||
|
|||||||
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
|
||||||
|
|
||||||
test("exportGame renders castling grouping and result markers"):
|
test("exportGame renders castling grouping and result markers"):
|
||||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
|
PgnExporter.exportGame(
|
||||||
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
|
Map("Event" -> "Test"),
|
||||||
|
List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)),
|
||||||
|
) should include("O-O")
|
||||||
|
PgnExporter.exportGame(
|
||||||
|
Map("Event" -> "Test"),
|
||||||
|
List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)),
|
||||||
|
) should include("O-O-O")
|
||||||
|
|
||||||
val seq = List(
|
val seq = List(
|
||||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||||
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
|
||||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
|
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
|
||||||
)
|
)
|
||||||
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
|
||||||
grouped should include("1. e4 c5")
|
grouped should include("1. e4 c5")
|
||||||
@@ -40,20 +46,21 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
PromotionPiece.Queen -> "=Q",
|
PromotionPiece.Queen -> "=Q",
|
||||||
PromotionPiece.Rook -> "=R",
|
PromotionPiece.Rook -> "=R",
|
||||||
PromotionPiece.Bishop -> "=B",
|
PromotionPiece.Bishop -> "=B",
|
||||||
PromotionPiece.Knight -> "=N"
|
PromotionPiece.Knight -> "=N",
|
||||||
).foreach { (piece, suffix) =>
|
).foreach { (piece, suffix) =>
|
||||||
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
|
||||||
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
|
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
val normal =
|
||||||
|
PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||||
normal should include("e4")
|
normal should include("e4")
|
||||||
normal should not include "="
|
normal should not include "="
|
||||||
|
|
||||||
test("exportGameContext preserves moves and default headers"):
|
test("exportGameContext preserves moves and default headers"):
|
||||||
val moves = List(
|
val moves = List(
|
||||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
|
||||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
|
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
|
||||||
)
|
)
|
||||||
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
|
||||||
withMoves.contains("e4") shouldBe true
|
withMoves.contains("e4") shouldBe true
|
||||||
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
Move(sq("c7"), sq("c6")),
|
Move(sq("c7"), sq("c6")),
|
||||||
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||||
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||||
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||||
@@ -105,4 +112,3 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
pgn should include("exf8=Q")
|
pgn should include("exf8=Q")
|
||||||
pawnCapturePgn should include("exd3")
|
pawnCapturePgn should include("exd3")
|
||||||
quietPromotionPgn should include("e8=Q")
|
quietPromotionPgn should include("e8=Q")
|
||||||
|
|
||||||
|
|||||||
@@ -30,59 +30,91 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
capture.map(_.moves.length) shouldBe Some(3)
|
capture.map(_.moves.length) shouldBe Some(3)
|
||||||
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
|
||||||
|
|
||||||
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
|
val whiteKs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
|
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
whiteKs.moveType shouldBe MoveType.CastleKingside
|
whiteKs.moveType shouldBe MoveType.CastleKingside
|
||||||
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
whiteKs.from shouldBe Square(File.E, Rank.R1)
|
||||||
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
whiteKs.to shouldBe Square(File.G, Rank.R1)
|
||||||
|
|
||||||
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
|
val whiteQs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
|
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
whiteQs.moveType shouldBe MoveType.CastleQueenside
|
whiteQs.moveType shouldBe MoveType.CastleQueenside
|
||||||
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
whiteQs.from shouldBe Square(File.E, Rank.R1)
|
||||||
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
whiteQs.to shouldBe Square(File.C, Rank.R1)
|
||||||
|
|
||||||
val blackKs = PgnParser.parsePgn("""[Event "Test"]
|
val blackKs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
|
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
blackKs.moveType shouldBe MoveType.CastleKingside
|
blackKs.moveType shouldBe MoveType.CastleKingside
|
||||||
blackKs.from shouldBe Square(File.E, Rank.R8)
|
blackKs.from shouldBe Square(File.E, Rank.R8)
|
||||||
|
|
||||||
val blackQs = PgnParser.parsePgn("""[Event "Test"]
|
val blackQs = PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
|
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""")
|
||||||
|
.get
|
||||||
|
.moves
|
||||||
|
.last
|
||||||
blackQs.moveType shouldBe MoveType.CastleQueenside
|
blackQs.moveType shouldBe MoveType.CastleQueenside
|
||||||
blackQs.from shouldBe Square(File.E, Rank.R8)
|
blackQs.from shouldBe Square(File.E, Rank.R8)
|
||||||
blackQs.to shouldBe Square(File.C, Rank.R8)
|
blackQs.to shouldBe Square(File.C, Rank.R8)
|
||||||
|
|
||||||
PgnParser.parsePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
|
1. e4 e5 1-0""")
|
||||||
PgnParser.parsePgn("""[Event "Test"]
|
.map(_.moves.length) shouldBe Some(2)
|
||||||
|
PgnParser
|
||||||
|
.parsePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
|
1. e4 INVALID e5""")
|
||||||
|
.map(_.moves.length) shouldBe Some(2)
|
||||||
|
|
||||||
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
|
||||||
val board = Board.initial
|
val board = Board.initial
|
||||||
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
|
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||||
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
|
File.E,
|
||||||
|
Rank.R4,
|
||||||
|
)
|
||||||
|
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
|
||||||
|
File.F,
|
||||||
|
Rank.R3,
|
||||||
|
)
|
||||||
|
|
||||||
val rookPieces: Map[Square, Piece] = Map(
|
val rookPieces: Map[Square, Piece] = Map(
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||||
)
|
)
|
||||||
val rankPieces: Map[Square, Piece] = Map(
|
val rankPieces: Map[Square, Piece] = Map(
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||||
)
|
)
|
||||||
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
PgnParser
|
||||||
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
|
.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White)
|
||||||
|
.get
|
||||||
|
.from shouldBe Square(File.A, Rank.R1)
|
||||||
|
PgnParser
|
||||||
|
.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White)
|
||||||
|
.get
|
||||||
|
.from shouldBe Square(File.A, Rank.R1)
|
||||||
|
|
||||||
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||||
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
|
||||||
@@ -92,10 +124,22 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("parseAlgebraicMove handles all promotion targets"):
|
test("parseAlgebraicMove handles all promotion targets"):
|
||||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
PgnParser
|
||||||
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||||
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
.get
|
||||||
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||||
|
PgnParser
|
||||||
|
.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
|
||||||
|
.get
|
||||||
|
.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||||
|
PgnParser
|
||||||
|
.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
|
||||||
|
.get
|
||||||
|
.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||||
|
PgnParser
|
||||||
|
.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
|
||||||
|
.get
|
||||||
|
.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||||
|
|
||||||
test("importGameContext accepts valid and empty PGN"):
|
test("importGameContext accepts valid and empty PGN"):
|
||||||
val pgn = """[Event "Test"]
|
val pgn = """[Event "Test"]
|
||||||
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||||
|
|
||||||
parsed.map(_.moves.size) shouldBe Some(2)
|
parsed.map(_.moves.size) shouldBe Some(2)
|
||||||
|
|
||||||
|
|||||||
@@ -37,17 +37,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
|
||||||
|
|
||||||
test("validatePgn rejects impossible illegal and garbage tokens"):
|
test("validatePgn rejects impossible illegal and garbage tokens"):
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. Qd4
|
1. Qd4
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
|
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. O-O
|
1. O-O
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
|
|
||||||
PgnParser.validatePgn("""[Event "Test"]
|
PgnParser
|
||||||
|
.validatePgn("""[Event "Test"]
|
||||||
|
|
||||||
1. e4 GARBAGE e5
|
1. e4 GARBAGE e5
|
||||||
""").isLeft shouldBe true
|
""").isLeft shouldBe true
|
||||||
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
test("validatePgn accepts empty move text and minimal valid header"):
|
test("validatePgn accepts empty move text and minimal valid header"):
|
||||||
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
|
||||||
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import de.nowchess.api.game.GameContext
|
|||||||
import de.nowchess.api.board.Square
|
import de.nowchess.api.board.Square
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
|
||||||
/** Extension point for chess rule variants (standard, Chess960, etc.).
|
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
|
||||||
* All rule queries are stateless: given a GameContext, return the answer.
|
* GameContext, return the answer.
|
||||||
*/
|
*/
|
||||||
trait RuleSet:
|
trait RuleSet:
|
||||||
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
/** All pseudo-legal moves for the piece on `square` (ignores check). */
|
||||||
@@ -32,8 +32,7 @@ trait RuleSet:
|
|||||||
/** True if halfMoveClock >= 100 (50-move rule). */
|
/** True if halfMoveClock >= 100 (50-move rule). */
|
||||||
def isFiftyMoveRule(context: GameContext): Boolean
|
def isFiftyMoveRule(context: GameContext): Boolean
|
||||||
|
|
||||||
/** Apply a legal move to produce the next game context.
|
/** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
|
||||||
* Handles all special move types: castling, en passant, promotion.
|
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||||
* Updates castling rights, en passant square, half-move clock, turn, and move history.
|
|
||||||
*/
|
*/
|
||||||
def applyMove(context: GameContext)(move: Move): GameContext
|
def applyMove(context: GameContext)(move: Move): GameContext
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import de.nowchess.rules.RuleSet
|
|||||||
|
|
||||||
import scala.annotation.tailrec
|
import scala.annotation.tailrec
|
||||||
|
|
||||||
/** Standard chess rules implementation.
|
/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
|
||||||
* Handles move generation, validation, check/checkmate/stalemate detection.
|
|
||||||
*/
|
*/
|
||||||
object DefaultRules extends RuleSet:
|
object DefaultRules extends RuleSet:
|
||||||
|
|
||||||
@@ -29,7 +28,8 @@ object DefaultRules extends RuleSet:
|
|||||||
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
|
||||||
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
|
||||||
if piece.color != context.turn then List.empty[Move]
|
if piece.color != context.turn then List.empty[Move]
|
||||||
else piece.pieceType match
|
else
|
||||||
|
piece.pieceType match
|
||||||
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
case PieceType.Pawn => pawnCandidates(context, square, piece.color)
|
||||||
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
case PieceType.Knight => knightCandidates(context, square, piece.color)
|
||||||
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
|
||||||
@@ -68,7 +68,7 @@ object DefaultRules extends RuleSet:
|
|||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color,
|
color: Color,
|
||||||
dirs: List[(Int, Int)]
|
dirs: List[(Int, Int)],
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
dirs.flatMap(dir => castRay(context.board, from, color, dir))
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ object DefaultRules extends RuleSet:
|
|||||||
board: Board,
|
board: Board,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color,
|
color: Color,
|
||||||
dir: (Int, Int)
|
dir: (Int, Int),
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
@tailrec
|
@tailrec
|
||||||
def loop(sq: Square, acc: List[Move]): List[Move] =
|
def loop(sq: Square, acc: List[Move]): List[Move] =
|
||||||
@@ -94,7 +94,7 @@ object DefaultRules extends RuleSet:
|
|||||||
private def knightCandidates(
|
private def knightCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
KnightJumps.flatMap { (df, dr) =>
|
KnightJumps.flatMap { (df, dr) =>
|
||||||
from.offset(df, dr).flatMap { to =>
|
from.offset(df, dr).flatMap { to =>
|
||||||
@@ -110,7 +110,7 @@ object DefaultRules extends RuleSet:
|
|||||||
private def kingCandidates(
|
private def kingCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
val steps = QueenDirs.flatMap { (df, dr) =>
|
val steps = QueenDirs.flatMap { (df, dr) =>
|
||||||
from.offset(df, dr).flatMap { to =>
|
from.offset(df, dr).flatMap { to =>
|
||||||
@@ -129,13 +129,13 @@ object DefaultRules extends RuleSet:
|
|||||||
kingToAlg: String,
|
kingToAlg: String,
|
||||||
middleAlg: String,
|
middleAlg: String,
|
||||||
rookFromAlg: String,
|
rookFromAlg: String,
|
||||||
moveType: MoveType
|
moveType: MoveType,
|
||||||
)
|
)
|
||||||
|
|
||||||
private def castlingCandidates(
|
private def castlingCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
color match
|
color match
|
||||||
case Color.White => whiteCastles(context, from)
|
case Color.White => whiteCastles(context, from)
|
||||||
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
|
|||||||
if from != expected then List.empty
|
if from != expected then List.empty
|
||||||
else
|
else
|
||||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||||
addCastleMove(context, moves, context.castlingRights.whiteKingSide,
|
addCastleMove(
|
||||||
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside))
|
context,
|
||||||
addCastleMove(context, moves, context.castlingRights.whiteQueenSide,
|
moves,
|
||||||
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside))
|
context.castlingRights.whiteKingSide,
|
||||||
|
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
|
||||||
|
)
|
||||||
|
addCastleMove(
|
||||||
|
context,
|
||||||
|
moves,
|
||||||
|
context.castlingRights.whiteQueenSide,
|
||||||
|
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
|
||||||
|
)
|
||||||
moves.toList
|
moves.toList
|
||||||
|
|
||||||
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
private def blackCastles(context: GameContext, from: Square): List[Move] =
|
||||||
@@ -157,10 +165,18 @@ object DefaultRules extends RuleSet:
|
|||||||
if from != expected then List.empty
|
if from != expected then List.empty
|
||||||
else
|
else
|
||||||
val moves = scala.collection.mutable.ListBuffer[Move]()
|
val moves = scala.collection.mutable.ListBuffer[Move]()
|
||||||
addCastleMove(context, moves, context.castlingRights.blackKingSide,
|
addCastleMove(
|
||||||
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside))
|
context,
|
||||||
addCastleMove(context, moves, context.castlingRights.blackQueenSide,
|
moves,
|
||||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
context.castlingRights.blackKingSide,
|
||||||
|
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
|
||||||
|
)
|
||||||
|
addCastleMove(
|
||||||
|
context,
|
||||||
|
moves,
|
||||||
|
context.castlingRights.blackQueenSide,
|
||||||
|
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
|
||||||
|
)
|
||||||
moves.toList
|
moves.toList
|
||||||
|
|
||||||
private def queensideBSquare(kingToAlg: String): List[String] =
|
private def queensideBSquare(kingToAlg: String): List[String] =
|
||||||
@@ -173,7 +189,7 @@ object DefaultRules extends RuleSet:
|
|||||||
context: GameContext,
|
context: GameContext,
|
||||||
moves: scala.collection.mutable.ListBuffer[Move],
|
moves: scala.collection.mutable.ListBuffer[Move],
|
||||||
castlingRight: Boolean,
|
castlingRight: Boolean,
|
||||||
castlingMove: CastlingMove
|
castlingMove: CastlingMove,
|
||||||
): Unit =
|
): Unit =
|
||||||
if castlingRight then
|
if castlingRight then
|
||||||
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
||||||
@@ -193,8 +209,7 @@ object DefaultRules extends RuleSet:
|
|||||||
!isAttackedBy(context.board, km, color.opposite) &&
|
!isAttackedBy(context.board, km, color.opposite) &&
|
||||||
!isAttackedBy(context.board, kt, color.opposite)
|
!isAttackedBy(context.board, kt, color.opposite)
|
||||||
|
|
||||||
if kingPresent && rookPresent && squaresSafe then
|
if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
|
||||||
moves += Move(kf, kt, castlingMove.moveType)
|
|
||||||
|
|
||||||
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
|
||||||
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
squares.forall(sq => board.pieceAt(sq).isEmpty)
|
||||||
@@ -204,20 +219,24 @@ object DefaultRules extends RuleSet:
|
|||||||
private def pawnCandidates(
|
private def pawnCandidates(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
from: Square,
|
from: Square,
|
||||||
color: Color
|
color: Color,
|
||||||
): List[Move] =
|
): List[Move] =
|
||||||
val fwd = pawnForward(color)
|
val fwd = pawnForward(color)
|
||||||
val startRank = pawnStartRank(color)
|
val startRank = pawnStartRank(color)
|
||||||
val promoRank = pawnPromoRank(color)
|
val promoRank = pawnPromoRank(color)
|
||||||
|
|
||||||
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
|
||||||
val double = Option.when(from.rank.ordinal == startRank) {
|
val double = Option
|
||||||
|
.when(from.rank.ordinal == startRank) {
|
||||||
from.offset(0, fwd).flatMap { mid =>
|
from.offset(0, fwd).flatMap { mid =>
|
||||||
Option.when(context.board.pieceAt(mid).isEmpty) {
|
Option
|
||||||
|
.when(context.board.pieceAt(mid).isEmpty) {
|
||||||
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
|
||||||
}.flatten
|
|
||||||
}
|
}
|
||||||
}.flatten
|
.flatten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flatten
|
||||||
|
|
||||||
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
val diagonalCaptures = List(-1, 1).flatMap { df =>
|
||||||
from.offset(df, fwd).flatMap { to =>
|
from.offset(df, fwd).flatMap { to =>
|
||||||
@@ -236,8 +255,10 @@ object DefaultRules extends RuleSet:
|
|||||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||||
if dest.rank.ordinal == promoRank then
|
if dest.rank.ordinal == promoRank then
|
||||||
List(
|
List(
|
||||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
PromotionPiece.Queen,
|
||||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
PromotionPiece.Rook,
|
||||||
|
PromotionPiece.Bishop,
|
||||||
|
PromotionPiece.Knight,
|
||||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||||
|
|
||||||
@@ -249,9 +270,7 @@ object DefaultRules extends RuleSet:
|
|||||||
// ── Check detection ────────────────────────────────────────────────
|
// ── Check detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
private def kingSquare(board: Board, color: Color): Option[Square] =
|
private def kingSquare(board: Board, color: Color): Option[Square] =
|
||||||
Square.all.find(sq =>
|
Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
|
||||||
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
|
|
||||||
)
|
|
||||||
|
|
||||||
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
|
||||||
Square.all.exists { sq =>
|
Square.all.exists { sq =>
|
||||||
@@ -266,12 +285,12 @@ object DefaultRules extends RuleSet:
|
|||||||
case PieceType.Pawn =>
|
case PieceType.Pawn =>
|
||||||
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
|
||||||
case PieceType.Knight =>
|
case PieceType.Knight =>
|
||||||
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||||
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
|
||||||
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
case PieceType.Rook => rayReaches(board, from, RookDirs, target)
|
||||||
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
|
||||||
case PieceType.King =>
|
case PieceType.King =>
|
||||||
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) }
|
QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
|
||||||
|
|
||||||
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
|
||||||
dirs.exists { dir =>
|
dirs.exists { dir =>
|
||||||
@@ -322,14 +341,13 @@ object DefaultRules extends RuleSet:
|
|||||||
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||||
val (kingFrom, kingTo, rookFrom, rookTo) =
|
val (kingFrom, kingTo, rookFrom, rookTo) =
|
||||||
if kingside then
|
if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||||
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||||
else
|
|
||||||
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
|
||||||
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
|
||||||
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
|
||||||
board
|
board
|
||||||
.removed(kingFrom).removed(rookFrom)
|
.removed(kingFrom)
|
||||||
|
.removed(rookFrom)
|
||||||
.updated(kingTo, king)
|
.updated(kingTo, king)
|
||||||
.updated(rookTo, rook)
|
.updated(rookTo, rook)
|
||||||
|
|
||||||
@@ -390,5 +408,6 @@ object DefaultRules extends RuleSet:
|
|||||||
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
|
||||||
case List(p1, p2)
|
case List(p1, p2)
|
||||||
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
|
||||||
&& p1.color != p2.color => true
|
&& p1.color != p2.color =>
|
||||||
|
true
|
||||||
case _ => false
|
case _ => false
|
||||||
|
|||||||
+6
-10
@@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||||
val context = GameContext(
|
val context = GameContext(
|
||||||
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
board =
|
||||||
|
contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||||
turn = Color.Black,
|
turn = Color.Black,
|
||||||
castlingRights = CastlingRights(true, true, false, false),
|
castlingRights = CastlingRights(true, true, false, false),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
||||||
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
val afterH1Capture =
|
||||||
|
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||||
|
|
||||||
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
||||||
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
||||||
@@ -279,7 +281,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
PromotionPiece.Queen,
|
PromotionPiece.Queen,
|
||||||
PromotionPiece.Rook,
|
PromotionPiece.Rook,
|
||||||
PromotionPiece.Bishop,
|
PromotionPiece.Bishop,
|
||||||
PromotionPiece.Knight
|
PromotionPiece.Knight,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("applyMove promotion supports queen rook and bishop targets"):
|
test("applyMove promotion supports queen rook and bishop targets"):
|
||||||
@@ -292,9 +294,3 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
|||||||
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||||
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.rule
|
package de.nowchess.rule
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights}
|
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType}
|
import de.nowchess.api.move.{Move, MoveType}
|
||||||
import de.nowchess.io.fen.FenParser
|
import de.nowchess.io.fen.FenParser
|
||||||
@@ -111,20 +111,23 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
test("castling queenside is illegal when knight blocks on b8"):
|
test("castling queenside is illegal when knight blocks on b8"):
|
||||||
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
||||||
val board = Board(Map(
|
val board = Board(
|
||||||
|
Map(
|
||||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
|
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
val context = GameContext(
|
val context = GameContext(
|
||||||
board = board,
|
board = board,
|
||||||
turn = Color.Black,
|
turn = Color.Black,
|
||||||
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
castlingRights =
|
||||||
|
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
||||||
enPassantSquare = None,
|
enPassantSquare = None,
|
||||||
halfMoveClock = 0,
|
halfMoveClock = 0,
|
||||||
moves = List.empty
|
moves = List.empty,
|
||||||
)
|
)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.ui.terminal.TerminalUI
|
import de.nowchess.ui.terminal.TerminalUI
|
||||||
import de.nowchess.ui.gui.ChessGUILauncher
|
import de.nowchess.ui.gui.ChessGUILauncher
|
||||||
|
|
||||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
|
||||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
* GameEngine via Observer pattern.
|
||||||
*/
|
*/
|
||||||
object Main:
|
object Main:
|
||||||
def main(args: Array[String]): Unit =
|
def main(args: Array[String]): Unit =
|
||||||
@@ -18,4 +18,3 @@ object Main:
|
|||||||
// Create and start the terminal UI (blocks on main thread)
|
// Create and start the terminal UI (blocks on main thread)
|
||||||
val tui = new TerminalUI(engine)
|
val tui = new TerminalUI(engine)
|
||||||
tui.start()
|
tui.start()
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||||
import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService}
|
import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import scalafx.stage.FileChooser
|
import scalafx.stage.FileChooser
|
||||||
import scalafx.stage.FileChooser.ExtensionFilter
|
import scalafx.stage.FileChooser.ExtensionFilter
|
||||||
|
|
||||||
/** ScalaFX chess board view that displays the game state.
|
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
|
||||||
* Uses chess sprites and color palette.
|
* interactions (clicks) and sends moves to GameEngine.
|
||||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
|
||||||
*/
|
*/
|
||||||
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 24)
|
font = Font.font(comicSansFontFamily, 24)
|
||||||
style = "-fx-font-weight: bold;"
|
style = "-fx-font-weight: bold;"
|
||||||
},
|
},
|
||||||
messageLabel
|
messageLabel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +85,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
disable = !engine.canUndo
|
disable = !engine.canUndo
|
||||||
}
|
}
|
||||||
undoButton
|
undoButton
|
||||||
},
|
}, {
|
||||||
{
|
|
||||||
redoButton = new Button("Redo") {
|
redoButton = new Button("Redo") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => if engine.canRedo then engine.redo()
|
onAction = _ => if engine.canRedo then engine.redo()
|
||||||
@@ -100,7 +98,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => engine.reset()
|
onAction = _ => engine.reset()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
new HBox {
|
new HBox {
|
||||||
@@ -126,7 +124,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => doPgnImport()
|
onAction = _ => doPgnImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
new HBox {
|
new HBox {
|
||||||
@@ -142,9 +140,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font(comicSansFontFamily, 12)
|
||||||
onAction = _ => doJsonImport()
|
onAction = _ => doJsonImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +183,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
square
|
square
|
||||||
|
|
||||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||||
if engine.isPendingPromotion then
|
if engine.isPendingPromotion then return // Don't allow moves during promotion
|
||||||
return // Don't allow moves during promotion
|
|
||||||
|
|
||||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||||
|
|
||||||
@@ -198,7 +195,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
selectedSquare = Some(clickedSquare)
|
selectedSquare = Some(clickedSquare)
|
||||||
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
|
||||||
|
|
||||||
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
|
val legalDests = engine.ruleSet
|
||||||
|
.legalMoves(engine.context)(clickedSquare)
|
||||||
.collect { case move if move.from == clickedSquare => move.to }
|
.collect { case move if move.from == clickedSquare => move.to }
|
||||||
legalDests.foreach { sq =>
|
legalDests.foreach { sq =>
|
||||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||||
@@ -322,7 +320,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
val result = FileSystemGameService.saveGameToFile(
|
val result = FileSystemGameService.saveGameToFile(
|
||||||
engine.context,
|
engine.context,
|
||||||
selectedFile.toPath,
|
selectedFile.toPath,
|
||||||
JsonExporter
|
JsonExporter,
|
||||||
)
|
)
|
||||||
result match
|
result match
|
||||||
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
|
||||||
@@ -339,7 +337,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
if selectedFile != null then
|
if selectedFile != null then
|
||||||
val result = FileSystemGameService.loadGameFromFile(
|
val result = FileSystemGameService.loadGameFromFile(
|
||||||
selectedFile.toPath,
|
selectedFile.toPath,
|
||||||
JsonParser
|
JsonParser,
|
||||||
)
|
)
|
||||||
result match
|
result match
|
||||||
case Right(gameContext) =>
|
case Right(gameContext) =>
|
||||||
@@ -353,7 +351,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
showCopyDialog(s"$formatName Export", exported)
|
showCopyDialog(s"$formatName Export", exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
private def doImport(importer: GameContextImport, formatName: String): Unit = {
|
private def doImport(importer: GameContextImport, formatName: String): Unit =
|
||||||
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
|
||||||
importer.importGameContext(input) match
|
importer.importGameContext(input) match
|
||||||
case Right(gameContext) =>
|
case Right(gameContext) =>
|
||||||
@@ -362,7 +360,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
case Left(err) =>
|
case Left(err) =>
|
||||||
showMessage(s"⚠️ $formatName Error: $err")
|
showMessage(s"⚠️ $formatName Error: $err")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private def showCopyDialog(title: String, content: String): Unit =
|
private def showCopyDialog(title: String, content: String): Unit =
|
||||||
val area = new javafx.scene.control.TextArea(content)
|
val area = new javafx.scene.control.TextArea(content)
|
||||||
@@ -386,7 +383,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
dialog.getDialogPane.setContent(area)
|
dialog.getDialogPane.setContent(area)
|
||||||
dialog.getDialogPane.getButtonTypes.addAll(
|
dialog.getDialogPane.getButtonTypes.addAll(
|
||||||
javafx.scene.control.ButtonType.OK,
|
javafx.scene.control.ButtonType.OK,
|
||||||
javafx.scene.control.ButtonType.CANCEL
|
javafx.scene.control.ButtonType.CANCEL,
|
||||||
)
|
)
|
||||||
dialog.setResultConverter { bt =>
|
dialog.setResultConverter { bt =>
|
||||||
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
|
||||||
@@ -394,4 +391,3 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
dialog.initOwner(stage.delegate)
|
dialog.initOwner(stage.delegate)
|
||||||
val result = dialog.showAndWait()
|
val result = dialog.showAndWait()
|
||||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package de.nowchess.ui.gui
|
package de.nowchess.ui.gui
|
||||||
|
|
||||||
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform}
|
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
|
||||||
import javafx.stage.Stage as JFXStage
|
import javafx.stage.Stage as JFXStage
|
||||||
import scalafx.application.Platform
|
import scalafx.application.Platform
|
||||||
import scalafx.scene.Scene
|
import scalafx.scene.Scene
|
||||||
import scalafx.stage.Stage
|
import scalafx.stage.Stage
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
|
||||||
/** ScalaFX GUI Application for Chess.
|
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
|
||||||
* This is launched from Main alongside the TUI.
|
* GameEngine via Observer pattern.
|
||||||
* Both subscribe to the same GameEngine via Observer pattern.
|
|
||||||
*/
|
*/
|
||||||
class ChessGUIApp extends JFXApplication:
|
class ChessGUIApp extends JFXApplication:
|
||||||
|
|
||||||
@@ -33,17 +32,15 @@ class ChessGUIApp extends JFXApplication:
|
|||||||
// Load CSS if available
|
// Load CSS if available
|
||||||
try {
|
try {
|
||||||
val cssUrl = getClass.getResource("/styles.css")
|
val cssUrl = getClass.getResource("/styles.css")
|
||||||
if cssUrl != null then
|
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
|
||||||
stylesheets.add(cssUrl.toExternalForm)
|
|
||||||
} catch {
|
} catch {
|
||||||
case _: Exception => // CSS is optional
|
case _: Exception => // CSS is optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage.onCloseRequest = _ => {
|
stage.onCloseRequest = _ =>
|
||||||
// Unsubscribe when window closes
|
// Unsubscribe when window closes
|
||||||
engine.unsubscribe(guiObserver)
|
engine.unsubscribe(guiObserver)
|
||||||
}
|
|
||||||
|
|
||||||
stage.show()
|
stage.show()
|
||||||
|
|
||||||
@@ -55,9 +52,7 @@ object ChessGUILauncher:
|
|||||||
|
|
||||||
def launch(eng: GameEngine): Unit =
|
def launch(eng: GameEngine): Unit =
|
||||||
engine = eng
|
engine = eng
|
||||||
val guiThread = new Thread(() => {
|
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
|
||||||
JFXApplication.launch(classOf[ChessGUIApp])
|
|
||||||
})
|
|
||||||
guiThread.setDaemon(false)
|
guiThread.setDaemon(false)
|
||||||
guiThread.setName("ScalaFX-GUI-Thread")
|
guiThread.setName("ScalaFX-GUI-Thread")
|
||||||
guiThread.start()
|
guiThread.start()
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ package de.nowchess.ui.gui
|
|||||||
import scalafx.application.Platform
|
import scalafx.application.Platform
|
||||||
import scalafx.scene.control.Alert
|
import scalafx.scene.control.Alert
|
||||||
import scalafx.scene.control.Alert.AlertType
|
import scalafx.scene.control.Alert.AlertType
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
import de.nowchess.chess.observer.{GameEvent, Observer, *}
|
||||||
import de.nowchess.api.board.Board
|
import de.nowchess.api.board.Board
|
||||||
|
|
||||||
/** GUI Observer that implements the Observer pattern.
|
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
|
||||||
* Receives game events from GameEngine and updates the ScalaFX UI.
|
|
||||||
* All UI updates must be done on the JavaFX Application Thread.
|
* All UI updates must be done on the JavaFX Application Thread.
|
||||||
*/
|
*/
|
||||||
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
||||||
@@ -60,8 +59,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
|||||||
boardView.updateBoard(e.context.board, e.context.turn)
|
boardView.updateBoard(e.context.board, e.context.turn)
|
||||||
if e.capturedPiece.isDefined then
|
if e.capturedPiece.isDefined then
|
||||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
|
||||||
else
|
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
||||||
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
|
|
||||||
boardView.updateUndoRedoButtons()
|
boardView.updateUndoRedoButtons()
|
||||||
|
|
||||||
case e: PgnLoadedEvent =>
|
case e: PgnLoadedEvent =>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package de.nowchess.ui.gui
|
package de.nowchess.ui.gui
|
||||||
|
|
||||||
import scalafx.scene.image.{Image, ImageView}
|
import scalafx.scene.image.{Image, ImageView}
|
||||||
import de.nowchess.api.board.{Piece, PieceType, Color}
|
import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||||
|
|
||||||
/** Utility object for loading chess piece sprites. */
|
/** Utility object for loading chess piece sprites. */
|
||||||
object PieceSprites:
|
object PieceSprites:
|
||||||
|
|
||||||
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
private val spriteCache = scala.collection.mutable.Map[String, Image]()
|
||||||
|
|
||||||
/** Load a piece sprite image from resources.
|
/** Load a piece sprite image from resources. Sprites are cached for performance.
|
||||||
* Sprites are cached for performance.
|
|
||||||
*/
|
*/
|
||||||
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
|
||||||
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
|
||||||
@@ -25,8 +24,7 @@ object PieceSprites:
|
|||||||
private def loadImage(key: String): Image =
|
private def loadImage(key: String): Image =
|
||||||
val path = s"/sprites/pieces/$key.png"
|
val path = s"/sprites/pieces/$key.png"
|
||||||
val stream = getClass.getResourceAsStream(path)
|
val stream = getClass.getResourceAsStream(path)
|
||||||
if stream == null then
|
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
|
||||||
throw new RuntimeException(s"Could not load sprite: $path")
|
|
||||||
new Image(stream)
|
new Image(stream)
|
||||||
|
|
||||||
/** Get square colors for the board using theme. */
|
/** Get square colors for the board using theme. */
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.ui.utils.Renderer
|
import de.nowchess.ui.utils.Renderer
|
||||||
|
|
||||||
/** Terminal UI that implements Observer pattern.
|
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
|
||||||
* Subscribes to GameEngine and receives state change events.
|
* I/O and user interaction in the terminal.
|
||||||
* Handles all I/O and user interaction in the terminal.
|
|
||||||
*/
|
*/
|
||||||
class TerminalUI(engine: GameEngine) extends Observer:
|
class TerminalUI(engine: GameEngine) extends Observer:
|
||||||
private var running = true
|
private var running = true
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ object Renderer:
|
|||||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||||
|
|
||||||
def render(board: Board): String =
|
def render(board: Board): String =
|
||||||
val rows = (0 until 8).reverse.map { rank =>
|
val rows = (0 until 8).reverse
|
||||||
|
.map { rank =>
|
||||||
val cells = (0 until 8).map { file =>
|
val cells = (0 until 8).map { file =>
|
||||||
val sq = Square(File.values(file), Rank.values(rank))
|
val sq = Square(File.values(file), Rank.values(rank))
|
||||||
val isLightSq = (file + rank) % 2 != 0
|
val isLightSq = (file + rank) % 2 != 0
|
||||||
@@ -24,5 +25,6 @@ object Renderer:
|
|||||||
s"$bgColor $AnsiReset"
|
s"$bgColor $AnsiReset"
|
||||||
}.mkString
|
}.mkString
|
||||||
s"${rank + 1} $cells ${rank + 1}"
|
s"${rank + 1} $cells ${rank + 1}"
|
||||||
}.mkString("\n")
|
}
|
||||||
|
.mkString("\n")
|
||||||
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
|||||||
(Piece(Color.Black, PieceType.Rook), "\u265C"),
|
(Piece(Color.Black, PieceType.Rook), "\u265C"),
|
||||||
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
|
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
|
||||||
(Piece(Color.Black, PieceType.Knight), "\u265E"),
|
(Piece(Color.Black, PieceType.Knight), "\u265E"),
|
||||||
(Piece(Color.Black, PieceType.Pawn), "\u265F")
|
(Piece(Color.Black, PieceType.Pawn), "\u265F"),
|
||||||
)
|
)
|
||||||
pieces.foreach { (piece, expected) =>
|
pieces.foreach { (piece, expected) =>
|
||||||
piece.unicode shouldBe expected
|
piece.unicode shouldBe expected
|
||||||
@@ -42,5 +42,3 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
|||||||
val rendered = Renderer.render(board)
|
val rendered = Renderer.render(board)
|
||||||
rendered should include("\u265A") // Black king unicode
|
rendered should include("\u265A") // Black king unicode
|
||||||
rendered should include("\u001b[30m") // ANSI black text color
|
rendered should include("\u001b[30m") // ANSI black text color
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user