feat: NCS-25 Add linters to keep quality up
Build & Test (NowChessSystems) TeamCity build finished

This commit is contained in:
2026-04-12 18:51:39 +02:00
parent 3ecb2c9d66
commit eb8cc060cf
67 changed files with 1416 additions and 1200 deletions
+41
View File
@@ -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
+15
View File
@@ -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
+8
View File
@@ -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
+6 -6
View File
@@ -1,7 +1,7 @@
YOU CAN: YOU CAN:
- Edit and use the asset in any commercial or non commercial project - Edit and use the asset in any commercial or non commercial project
- Use the asset in any commercial or non commercial project - Use the asset in any commercial or non commercial project
YOU CAN'T: YOU CAN'T:
- Resell or distribute the asset to others - Resell or distribute the asset to others
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/ - Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
+5
View File
@@ -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 scalafmtAll` to auto-format before committing.
- **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.
+26
View File
@@ -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"
@@ -38,3 +40,27 @@ val versions = mapOf(
) )
extra["VERSIONS"] = versions extra["VERSIONS"] = versions
subprojects {
apply(plugin = "com.diffplug.spotless")
pluginManager.withPlugin("scala") {
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
scala {
scalafmt().configFile(rootProject.file(".scalafmt.conf"))
}
}
apply(plugin = "io.github.cosmicsilence.scalafix")
configure<io.github.cosmicsilence.scalafix.ScalafixExtension> {
configFile.set(rootProject.file(".scalafix.conf"))
}
// Disable SemanticDB config for the scoverage source set — it sets -sourceroot to
// the root project dir, which conflicts with scoverage's own -sourceroot and causes
// reportTestScoverage to fail with "No source root found".
tasks.matching { it.name in setOf("configSemanticDBScoverage", "checkScalafixScoverage", "checkScalafixTest") }.configureEach {
enabled = false
}
}
}
@@ -7,11 +7,11 @@ object Board:
def apply(pieces: Map[Square, Piece]): Board = pieces def apply(pieces: Map[Square, Piece]): Board = pieces
extension (b: Board) extension (b: Board)
def pieceAt(sq: Square): Option[Piece] = b.get(sq) def pieceAt(sq: Square): Option[Piece] = b.get(sq)
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece) def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
def removed(sq: Square): Board = b.removed(sq) def removed(sq: Square): Board = b.removed(sq)
def withMove(from: Square, to: Square): (Board, Option[Piece]) = def withMove(from: Square, to: Square): (Board, Option[Piece]) =
val captured = b.get(to) val captured = b.get(to)
val updatedBoard = b.removed(from).updated(to, b(from)) val updatedBoard = b.removed(from).updated(to, b(from))
(updatedBoard, captured) (updatedBoard, captured)
def applyMove(move: de.nowchess.api.move.Move): Board = def applyMove(move: de.nowchess.api.move.Move): Board =
@@ -21,8 +21,14 @@ object Board:
val initial: Board = val initial: Board =
val backRank: Vector[PieceType] = Vector( val backRank: Vector[PieceType] = Vector(
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, PieceType.Rook,
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook PieceType.Knight,
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
val entries = for val entries = for
fileIdx <- 0 until 8 fileIdx <- 0 until 8
@@ -30,7 +36,7 @@ object Board:
(Color.White, Rank.R1, backRank(fileIdx)), (Color.White, Rank.R1, backRank(fileIdx)),
(Color.White, Rank.R2, PieceType.Pawn), (Color.White, Rank.R2, PieceType.Pawn),
(Color.Black, Rank.R8, backRank(fileIdx)), (Color.Black, Rank.R8, backRank(fileIdx)),
(Color.Black, Rank.R7, PieceType.Pawn) (Color.Black, Rank.R7, PieceType.Pawn),
) )
yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType) yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType)
Board(entries.toMap) Board(entries.toMap)
@@ -1,50 +1,48 @@
package de.nowchess.api.board package de.nowchess.api.board
/** /** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and
* Unified castling rights tracker for all four sides. * direction.
* Tracks whether castling is still available for each side and direction. *
* * @param whiteKingSide
* @param whiteKingSide White's king-side castling (0-0) still legally available * White's king-side castling (0-0) still legally available
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available * @param whiteQueenSide
* @param blackKingSide Black's king-side castling (0-0) still legally available * White's queen-side castling (0-0-0) still legally available
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available * @param blackKingSide
*/ * Black's king-side castling (0-0) still legally available
* @param blackQueenSide
* Black's queen-side castling (0-0-0) still legally available
*/
final case class CastlingRights( final case class CastlingRights(
whiteKingSide: Boolean, whiteKingSide: Boolean,
whiteQueenSide: Boolean, whiteQueenSide: Boolean,
blackKingSide: Boolean, blackKingSide: Boolean,
blackQueenSide: Boolean blackQueenSide: Boolean,
): ):
/** /** Check if either side has any castling rights remaining.
* Check if either side has any castling rights remaining. */
*/
def hasAnyRights: Boolean = def hasAnyRights: Boolean =
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
/** /** Check if a specific color has any castling rights remaining.
* Check if a specific color has any castling rights remaining. */
*/
def hasRights(color: Color): Boolean = color match def hasRights(color: Color): Boolean = color match
case Color.White => whiteKingSide || whiteQueenSide case Color.White => whiteKingSide || whiteQueenSide
case Color.Black => blackKingSide || blackQueenSide case Color.Black => blackKingSide || blackQueenSide
/** /** Revoke all castling rights for a specific color.
* Revoke all castling rights for a specific color. */
*/
def revokeColor(color: Color): CastlingRights = color match def revokeColor(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false) case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
case Color.Black => copy(blackKingSide = false, blackQueenSide = false) case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
/** /** Revoke a specific castling right.
* Revoke a specific castling right. */
*/
def revokeKingSide(color: Color): CastlingRights = color match def revokeKingSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false) case Color.White => copy(whiteKingSide = false)
case Color.Black => copy(blackKingSide = false) case Color.Black => copy(blackKingSide = false)
/** /** Revoke a specific castling right.
* Revoke a specific castling right. */
*/
def revokeQueenSide(color: Color): CastlingRights = color match def revokeQueenSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteQueenSide = false) case Color.White => copy(whiteQueenSide = false)
case Color.Black => copy(blackQueenSide = false) case Color.Black => copy(blackQueenSide = false)
@@ -55,7 +53,7 @@ object CastlingRights:
whiteKingSide = false, whiteKingSide = false,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = false blackQueenSide = false,
) )
/** All castling rights available. */ /** All castling rights available. */
@@ -63,7 +61,7 @@ object CastlingRights:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = true, whiteQueenSide = true,
blackKingSide = true, blackKingSide = true,
blackQueenSide = true blackQueenSide = true,
) )
/** Standard starting position castling rights (both sides can castle both ways). */ /** Standard starting position castling rights (both sides can castle both ways). */
@@ -5,16 +5,16 @@ final case class Piece(color: Color, pieceType: PieceType)
object Piece: object Piece:
// Convenience constructors // Convenience constructors
val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn) val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn)
val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight) val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight)
val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop) val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop)
val WhiteRook: Piece = Piece(Color.White, PieceType.Rook) val WhiteRook: Piece = Piece(Color.White, PieceType.Rook)
val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen) val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen)
val WhiteKing: Piece = Piece(Color.White, PieceType.King) val WhiteKing: Piece = Piece(Color.White, PieceType.King)
val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn) val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn)
val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight) val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight)
val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop) val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop)
val BlackRook: Piece = Piece(Color.Black, PieceType.Rook) val BlackRook: Piece = Piece(Color.Black, PieceType.Rook)
val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen) val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen)
val BlackKing: Piece = Piece(Color.Black, PieceType.King) val BlackKing: Piece = Piece(Color.Black, PieceType.King)
@@ -1,43 +1,38 @@
package de.nowchess.api.board package de.nowchess.api.board
/** /** A file (column) on the chess board, ah. Ordinal values 07 correspond to ah.
* A file (column) on the chess board, ah. */
* Ordinal values 07 correspond to ah.
*/
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, 18. Ordinal values 07 correspond to ranks 18.
* A rank (row) on the chess board, 18. */
* Ordinal values 07 correspond to ranks 18.
*/
enum Rank: enum Rank:
case R1, R2, R3, R4, R5, R6, R7, R8 case R1, R2, R3, R4, R5, R6, R7, R8
/** /** A unique square on the board, identified by its file and rank.
* A unique square on the board, identified by its file and rank. *
* * @param file
* @param file the column, ah * the column, ah
* @param rank the row, 18 * @param rank
*/ * the row, 18
*/
final case class Square(file: File, rank: Rank): final case class Square(file: File, rank: Rank):
/** Algebraic notation string, e.g. "e4". */ /** Algebraic notation string, e.g. "e4". */
override def toString: String = override def toString: String =
s"${file.toString.toLowerCase}${rank.ordinal + 1}" s"${file.toString.toLowerCase}${rank.ordinal + 1}"
object Square: object Square:
/** Parse a square from algebraic notation (e.g. "e4"). /** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name.
* Returns None if the input is not a valid square name. */ */
def fromAlgebraic(s: String): Option[Square] = def fromAlgebraic(s: String): Option[Square] =
if s.length != 2 then None if s.length != 2 then None
else else
val fileChar = s.charAt(0) val fileChar = s.charAt(0)
val rankChar = s.charAt(1) val rankChar = s.charAt(1)
val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString)) val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString))
val rankOpt = val rankOpt =
rankChar.toString.toIntOption.flatMap(n => rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None)
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
)
for f <- fileOpt; r <- rankOpt yield Square(f, r) for f <- fileOpt; r <- rankOpt yield Square(f, r)
val all: IndexedSeq[Square] = val all: IndexedSeq[Square] =
@@ -46,12 +41,13 @@ object Square:
f <- File.values.toIndexedSeq f <- File.values.toIndexedSeq
yield Square(f, r) yield Square(f, r)
/** Compute a target square by offsetting file and rank. /** Compute a target square by offsetting file and rank. Returns None if the resulting square is outside the board
* Returns None if the resulting square is outside the board (0-7 range). */ * (0-7 range).
*/
extension (sq: Square) extension (sq: Square)
def offset(fileDelta: Int, rankDelta: Int): Option[Square] = def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
val newFileOrd = sq.file.ordinal + fileDelta val newFileOrd = sq.file.ordinal + fileDelta
val newRankOrd = sq.rank.ordinal + rankDelta val newRankOrd = sq.rank.ordinal + rankDelta
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd))) Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
else None else None
@@ -1,18 +1,17 @@
package de.nowchess.api.game package de.nowchess.api.game
import de.nowchess.api.board.{Board, Color, Square, CastlingRights} import de.nowchess.api.board.{Board, CastlingRights, Color, Square}
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
/** Immutable bundle of complete game state. /** Immutable bundle of complete game state. All state changes produce new GameContext instances.
* All state changes produce new GameContext instances. */
*/
case class GameContext( case class GameContext(
board: Board, board: Board,
turn: Color, turn: Color,
castlingRights: CastlingRights, castlingRights: CastlingRights,
enPassantSquare: Option[Square], enPassantSquare: Option[Square],
halfMoveClock: Int, halfMoveClock: Int,
moves: List[Move] moves: List[Move],
): ):
/** Create new context with updated board. */ /** Create new context with updated board. */
def withBoard(newBoard: Board): GameContext = copy(board = newBoard) def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
@@ -40,5 +39,5 @@ object GameContext:
castlingRights = CastlingRights.Initial, castlingRights = CastlingRights.Initial,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty moves = List.empty,
) )
@@ -10,24 +10,30 @@ enum PromotionPiece:
enum MoveType: enum MoveType:
/** A normal move or capture with no special rule. */ /** A normal move or capture with no special rule. */
case Normal(isCapture: Boolean = false) case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */ /** Kingside castling (O-O). */
case CastleKingside case CastleKingside
/** Queenside castling (O-O-O). */ /** Queenside castling (O-O-O). */
case CastleQueenside case CastleQueenside
/** En-passant pawn capture. */ /** En-passant pawn capture. */
case EnPassant case EnPassant
/** Pawn promotion; carries the chosen promotion piece. */ /** Pawn promotion; carries the chosen promotion piece. */
case Promotion(piece: PromotionPiece) case Promotion(piece: PromotionPiece)
/** /** A half-move (ply) in a chess game.
* A half-move (ply) in a chess game. *
* * @param from
* @param from origin square * origin square
* @param to destination square * @param to
* @param moveType special semantics; defaults to Normal * destination square
*/ * @param moveType
* special semantics; defaults to Normal
*/
final case class Move( final case class Move(
from: Square, from: Square,
to: Square, to: Square,
moveType: MoveType = MoveType.Normal() moveType: MoveType = MoveType.Normal(),
) )
@@ -1,27 +1,26 @@
package de.nowchess.api.player package de.nowchess.api.player
/** /** An opaque player identifier.
* An opaque player identifier. *
* * Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time.
* Wraps a plain String so that IDs are not accidentally interchanged with */
* other String values at compile time.
*/
opaque type PlayerId = String opaque type PlayerId = String
object PlayerId: object PlayerId:
def apply(value: String): PlayerId = value def apply(value: String): PlayerId = value
extension (id: PlayerId) def value: String = id extension (id: PlayerId) def value: String = id
/** /** The minimal cross-service identity stub for a player.
* The minimal cross-service identity stub for a player. *
* * Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs
* Full profile data (email, rating history, etc.) lives in the user-management * is held here.
* service. Only what every service needs is held here. *
* * @param id
* @param id unique identifier * unique identifier
* @param displayName human-readable name shown in the UI * @param displayName
*/ * human-readable name shown in the UI
*/
final case class PlayerInfo( final case class PlayerInfo(
id: PlayerId, id: PlayerId,
displayName: String displayName: String,
) )
@@ -1,13 +1,12 @@
package de.nowchess.api.response package de.nowchess.api.response
/** /** A standardised envelope for every API response.
* A standardised envelope for every API response. *
* * Success and failure are modelled as subtypes so that callers can pattern-match exhaustively.
* Success and failure are modelled as subtypes so that callers *
* can pattern-match exhaustively. * @tparam A
* * the payload type for a successful response
* @tparam A the payload type for a successful response */
*/
sealed trait ApiResponse[+A] sealed trait ApiResponse[+A]
object ApiResponse: object ApiResponse:
@@ -20,43 +19,49 @@ object ApiResponse:
/** Convenience constructor for a single-error failure. */ /** Convenience constructor for a single-error failure. */
def error(err: ApiError): Failure = Failure(List(err)) def error(err: ApiError): Failure = Failure(List(err))
/** /** A structured error descriptor.
* A structured error descriptor. *
* * @param code
* @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND") * machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND")
* @param message human-readable explanation * @param message
* @param field optional field name when the error relates to a specific input * human-readable explanation
*/ * @param field
* optional field name when the error relates to a specific input
*/
final case class ApiError( final case class ApiError(
code: String, code: String,
message: String, message: String,
field: Option[String] = None field: Option[String] = None,
) )
/** /** Pagination metadata for list responses.
* Pagination metadata for list responses. *
* * @param page
* @param page current 0-based page index * current 0-based page index
* @param pageSize number of items per page * @param pageSize
* @param totalItems total number of items across all pages * number of items per page
*/ * @param totalItems
* total number of items across all pages
*/
final case class Pagination( final case class Pagination(
page: Int, page: Int,
pageSize: Int, pageSize: Int,
totalItems: Long totalItems: Long,
): ):
def totalPages: Int = def totalPages: Int =
if pageSize <= 0 then 0 if pageSize <= 0 then 0
else Math.ceil(totalItems.toDouble / pageSize).toInt else Math.ceil(totalItems.toDouble / pageSize).toInt
/** /** A paginated list response envelope.
* A paginated list response envelope. *
* * @param items
* @param items the items on the current page * the items on the current page
* @param pagination pagination metadata * @param pagination
* @tparam A the item type * pagination metadata
*/ * @tparam A
* the item type
*/
final case class PagedResponse[A]( final case class PagedResponse[A](
items: List[A], items: List[A],
pagination: Pagination pagination: Pagination,
) )
@@ -22,9 +22,9 @@ class BoardTest extends AnyFunSuite with Matchers:
} }
test("withMove returns captured piece when destination is occupied") { test("withMove returns captured piece when destination is occupied") {
val from = Square(File.A, Rank.R1) val from = Square(File.A, Rank.R1)
val to = Square(File.A, Rank.R8) val to = Square(File.A, Rank.R8)
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook)) val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
val (board, captured) = b.withMove(from, to) val (board, captured) = b.withMove(from, to)
captured shouldBe Some(Piece.BlackRook) captured shouldBe Some(Piece.BlackRook)
board.pieceAt(to) shouldBe Some(Piece.WhiteRook) board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
@@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers:
test("initial board white back rank") { test("initial board white back rank") {
val expectedBackRank = Vector( val expectedBackRank = Vector(
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, PieceType.Rook,
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook PieceType.Knight,
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
File.values.zipWithIndex.foreach { (file, i) => File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
@@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers:
test("initial board black back rank") { test("initial board black back rank") {
val expectedBackRank = Vector( val expectedBackRank = Vector(
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, PieceType.Rook,
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook PieceType.Knight,
PieceType.Bishop,
PieceType.Queen,
PieceType.King,
PieceType.Bishop,
PieceType.Knight,
PieceType.Rook,
) )
File.values.zipWithIndex.foreach { (file, i) => File.values.zipWithIndex.foreach { (file, i) =>
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
@@ -76,12 +88,11 @@ class BoardTest extends AnyFunSuite with Matchers:
for for
rank <- emptyRanks rank <- emptyRanks
file <- File.values file <- File.values
do do Board.initial.pieceAt(Square(file, rank)) shouldBe None
Board.initial.pieceAt(Square(file, rank)) shouldBe None
} }
test("updated adds and replaces piece at squares") { test("updated adds and replaces piece at squares") {
val b = Board(Map(e2 -> Piece.WhitePawn)) val b = Board(Map(e2 -> Piece.WhitePawn))
val added = b.updated(e4, Piece.WhiteKnight) val added = b.updated(e4, Piece.WhiteKnight)
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn) added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
@@ -91,7 +102,7 @@ class BoardTest extends AnyFunSuite with Matchers:
} }
test("removed deletes piece from board") { test("removed deletes piece from board") {
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight)) val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
val removed = b.removed(e2) val removed = b.removed(e2)
removed.pieceAt(e2) shouldBe None removed.pieceAt(e2) shouldBe None
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
@@ -105,4 +116,3 @@ class BoardTest extends AnyFunSuite with Matchers:
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn) moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
moved.pieceAt(e2) shouldBe None moved.pieceAt(e2) shouldBe None
} }
@@ -10,7 +10,7 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true blackQueenSide = true,
) )
rights.hasAnyRights shouldBe true rights.hasAnyRights shouldBe true
@@ -54,4 +54,3 @@ class CastlingRightsTest extends AnyFunSuite with Matchers:
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black) val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
blackQueenSideRevoked.blackKingSide shouldBe true blackQueenSideRevoked.blackKingSide shouldBe true
blackQueenSideRevoked.blackQueenSide shouldBe false blackQueenSideRevoked.blackQueenSide shouldBe false
@@ -8,7 +8,7 @@ class ColorTest extends AnyFunSuite with Matchers:
test("Color values expose opposite and label consistently"): test("Color values expose opposite and label consistently"):
val cases = List( val cases = List(
(Color.White, Color.Black, "White"), (Color.White, Color.Black, "White"),
(Color.Black, Color.White, "Black") (Color.Black, Color.White, "Black"),
) )
cases.foreach { (color, opposite, label) => cases.foreach { (color, opposite, label) =>
@@ -7,24 +7,24 @@ class PieceTest extends AnyFunSuite with Matchers:
test("Piece holds color and pieceType") { test("Piece holds color and pieceType") {
val p = Piece(Color.White, PieceType.Queen) val p = Piece(Color.White, PieceType.Queen)
p.color shouldBe Color.White p.color shouldBe Color.White
p.pieceType shouldBe PieceType.Queen p.pieceType shouldBe PieceType.Queen
} }
test("all convenience constants map to expected color and piece type") { test("all convenience constants map to expected color and piece type") {
val expected = List( val expected = List(
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn), Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight), Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop), Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook), Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen), Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
Piece.WhiteKing -> Piece(Color.White, PieceType.King), Piece.WhiteKing -> Piece(Color.White, PieceType.King),
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn), Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight), Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop), Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook), Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen), Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
Piece.BlackKing -> Piece(Color.Black, PieceType.King) Piece.BlackKing -> Piece(Color.Black, PieceType.King),
) )
expected.foreach { case (actual, wanted) => expected.foreach { case (actual, wanted) =>
@@ -7,12 +7,12 @@ class PieceTypeTest extends AnyFunSuite with Matchers:
test("PieceType values expose the expected labels"): test("PieceType values expose the expected labels"):
val expectedLabels = List( val expectedLabels = List(
PieceType.Pawn -> "Pawn", PieceType.Pawn -> "Pawn",
PieceType.Knight -> "Knight", PieceType.Knight -> "Knight",
PieceType.Bishop -> "Bishop", PieceType.Bishop -> "Bishop",
PieceType.Rook -> "Rook", PieceType.Rook -> "Rook",
PieceType.Queen -> "Queen", PieceType.Queen -> "Queen",
PieceType.King -> "King" PieceType.King -> "King",
) )
expectedLabels.foreach { (pieceType, expectedLabel) => expectedLabels.foreach { (pieceType, expectedLabel) =>
@@ -16,7 +16,7 @@ class SquareTest extends AnyFunSuite with Matchers:
"a1" -> Square(File.A, Rank.R1), "a1" -> Square(File.A, Rank.R1),
"e4" -> Square(File.E, Rank.R4), "e4" -> Square(File.E, Rank.R4),
"h8" -> Square(File.H, Rank.R8), "h8" -> Square(File.H, Rank.R8),
"E4" -> Square(File.E, Rank.R4) "E4" -> Square(File.E, Rank.R4),
) )
expected.foreach { case (raw, sq) => expected.foreach { case (raw, sq) =>
Square.fromAlgebraic(raw) shouldBe Some(sq) Square.fromAlgebraic(raw) shouldBe Some(sq)
@@ -34,4 +34,3 @@ class SquareTest extends AnyFunSuite with Matchers:
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
Square(File.H, Rank.R8).offset(0, 1) shouldBe None Square(File.H, Rank.R8).offset(0, 1) shouldBe None
} }
@@ -18,9 +18,9 @@ class GameContextTest extends AnyFunSuite with Matchers:
initial.moves shouldBe List.empty initial.moves shouldBe List.empty
test("withBoard updates only board"): test("withBoard updates only board"):
val square = Square(File.E, Rank.R4) val square = Square(File.E, Rank.R4)
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen) val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
val updated = GameContext.initial.withBoard(updatedBoard) val updated = GameContext.initial.withBoard(updatedBoard)
updated.board shouldBe updatedBoard updated.board shouldBe updatedBoard
updated.turn shouldBe GameContext.initial.turn updated.turn shouldBe GameContext.initial.turn
updated.castlingRights shouldBe GameContext.initial.castlingRights updated.castlingRights shouldBe GameContext.initial.castlingRights
@@ -34,13 +34,13 @@ class GameContextTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true blackQueenSide = true,
) )
val square = Some(Square(File.E, Rank.R3)) val square = Some(Square(File.E, Rank.R3))
val updatedTurn = initial.withTurn(Color.Black) val updatedTurn = initial.withTurn(Color.Black)
val updatedRights = initial.withCastlingRights(rights) val updatedRights = initial.withCastlingRights(rights)
val updatedEp = initial.withEnPassantSquare(square) val updatedEp = initial.withEnPassantSquare(square)
val updatedClock = initial.withHalfMoveClock(17) val updatedClock = initial.withHalfMoveClock(17)
updatedTurn.turn shouldBe Color.Black updatedTurn.turn shouldBe Color.Black
updatedTurn.board shouldBe initial.board updatedTurn.board shouldBe initial.board
@@ -57,4 +57,3 @@ class GameContextTest extends AnyFunSuite with Matchers:
test("withMove appends move to history"): test("withMove appends move to history"):
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
GameContext.initial.withMove(move).moves shouldBe List(move) GameContext.initial.withMove(move).moves shouldBe List(move)
@@ -25,7 +25,7 @@ class MoveTest extends AnyFunSuite with Matchers:
MoveType.Promotion(PromotionPiece.Queen), MoveType.Promotion(PromotionPiece.Queen),
MoveType.Promotion(PromotionPiece.Rook), MoveType.Promotion(PromotionPiece.Rook),
MoveType.Promotion(PromotionPiece.Bishop), MoveType.Promotion(PromotionPiece.Bishop),
MoveType.Promotion(PromotionPiece.Knight) MoveType.Promotion(PromotionPiece.Knight),
) )
moveTypes.foreach { moveType => moveTypes.foreach { moveType =>
@@ -7,12 +7,12 @@ class PlayerInfoTest extends AnyFunSuite with Matchers:
test("PlayerId and PlayerInfo preserve constructor values") { test("PlayerId and PlayerInfo preserve constructor values") {
val raw = "player-123" val raw = "player-123"
val id = PlayerId(raw) val id = PlayerId(raw)
id.value shouldBe raw id.value shouldBe raw
val playerId = PlayerId("p1") val playerId = PlayerId("p1")
val info = PlayerInfo(playerId, "Magnus") val info = PlayerInfo(playerId, "Magnus")
info.id.value shouldBe "p1" info.id.value shouldBe "p1"
info.displayName shouldBe "Magnus" info.displayName shouldBe "Magnus"
} }
@@ -14,9 +14,9 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err)) ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
val e = ApiError("CODE", "message") val e = ApiError("CODE", "message")
e.code shouldBe "CODE" e.code shouldBe "CODE"
e.message shouldBe "message" e.message shouldBe "message"
e.field shouldBe None e.field shouldBe None
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email") ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
} }
@@ -31,6 +31,6 @@ class ApiResponseTest extends AnyFunSuite with Matchers:
test("PagedResponse holds items and pagination") { test("PagedResponse holds items and pagination") {
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20) val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
val pr = PagedResponse(List("a", "b"), pagination) val pr = PagedResponse(List("a", "b"), pagination)
pr.items shouldBe List("a", "b") pr.items shouldBe List("a", "b")
pr.pagination shouldBe pagination pr.pagination shouldBe pagination
} }
@@ -1,11 +1,11 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Piece} import de.nowchess.api.board.{Piece, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone. /** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
* Commands encapsulate user actions and game state transitions. * transitions.
*/ */
trait Command: trait Command:
/** Execute the command and return true if successful, false otherwise. */ /** Execute the command and return true if successful, false otherwise. */
def execute(): Boolean def execute(): Boolean
@@ -16,15 +16,14 @@ trait Command:
/** A human-readable description of this command. */ /** A human-readable description of this command. */
def description: String def description: String
/** Command to move a piece from one square to another. /** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
* Stores the move result so undo can restore previous state. */
*/
case class MoveCommand( case class MoveCommand(
from: Square, from: Square,
to: Square, to: Square,
moveResult: Option[MoveResult] = None, moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None, previousContext: Option[GameContext] = None,
notation: String = "" notation: String = "",
) extends Command: ) extends Command:
override def execute(): Boolean = override def execute(): Boolean =
@@ -39,18 +38,18 @@ case class MoveCommand(
sealed trait MoveResult sealed trait MoveResult
object MoveResult: object MoveResult:
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult case object InvalidMove extends MoveResult
/** Command to quit the game. */ /** Command to quit the game. */
case class QuitCommand() extends Command: case class QuitCommand() extends Command:
override def execute(): Boolean = true override def execute(): Boolean = true
override def undo(): Boolean = false override def undo(): Boolean = false
override def description: String = "Quit game" override def description: String = "Quit game"
/** Command to reset the board to initial position. */ /** Command to reset the board to initial position. */
case class ResetCommand( case class ResetCommand(
previousContext: Option[GameContext] = None previousContext: Option[GameContext] = None,
) extends Command: ) extends Command:
override def execute(): Boolean = true override def execute(): Boolean = true
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */ /** Manages command execution and history for undo/redo support. */
class CommandInvoker: class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]() private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentIndex = -1 private var currentIndex = -1
/** Execute a command and add it to history. /** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
* Discards any redo history if not at the end of the stack. */
*/
def execute(command: Command): Boolean = synchronized { def execute(command: Command): Boolean = synchronized {
if command.execute() then if command.execute() then
// Remove any commands after current index (redo stack is discarded) // Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
executedCommands.remove(executedCommands.size - 1)
executedCommands += command executedCommands += command
currentIndex += 1 currentIndex += 1
true true
else else false
false
} }
/** Undo the last executed command if possible. */ /** Undo the last executed command if possible. */
@@ -27,10 +25,8 @@ class CommandInvoker:
if command.undo() then if command.undo() then
currentIndex -= 1 currentIndex -= 1
true true
else else false
false else false
else
false
} }
/** Redo the next command in history if available. */ /** Redo the next command in history if available. */
@@ -40,10 +36,8 @@ class CommandInvoker:
if command.execute() then if command.execute() then
currentIndex += 1 currentIndex += 1
true true
else else false
false else false
else
false
} }
/** Get the history of all executed commands. */ /** Get the history of all executed commands. */
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
object Parser: object Parser:
/** Parses coordinate notation such as "e2e4" or "g1f3". /** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
* Returns None for any input that does not match the expected format. * format.
*/ */
def parseMove(input: String): Option[(Square, Square)] = def parseMove(input: String): Option[(Square, Square)] =
val trimmed = input.trim.toLowerCase val trimmed = input.trim.toLowerCase
Option.when(trimmed.length == 4)(trimmed).flatMap: s => Option
for .when(trimmed.length == 4)(trimmed)
from <- parseSquare(s.substring(0, 2)) .flatMap: s =>
to <- parseSquare(s.substring(2, 4)) for
yield (from, to) from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
private def parseSquare(s: String): Option[Square] = private def parseSquare(s: String): Option[Square] =
Option.when(s.length == 2)(s).flatMap: sq => Option
val fileIdx = sq(0) - 'a' .when(s.length == 2)(s)
val rankIdx = sq(1) - '1' .flatMap: sq =>
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( val fileIdx = sq(0) - 'a'
Square(File.values(fileIdx), Rank.values(rankIdx)) val rankIdx = sq(1) - '1'
) Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx)),
)
@@ -6,45 +6,46 @@ import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextImport, GameContextExport} import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes. /** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* All rule queries delegate to the injected RuleSet. * injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
* All user interactions go through Commands; state changes are broadcast via GameEvents. */
*/
class GameEngine( class GameEngine(
val initialContext: GameContext = GameContext.initial, val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules val ruleSet: RuleSet = DefaultRules,
) extends Observable: ) extends Observable:
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = initialContext private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker() private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext) private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingPromotion: Option[PendingPromotion] = None private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */ /** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
// Synchronized accessors for current state // Synchronized accessors for current state
def board: Board = synchronized { currentContext.board } def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized { currentContext.turn } def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized { currentContext } def context: GameContext = synchronized(currentContext)
/** Check if undo is available. */ /** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo } def canUndo: Boolean = synchronized(invoker.canUndo)
/** Check if redo is available. */ /** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo } def canRedo: Boolean = synchronized(invoker.canRedo)
/** Get the command history for inspection (testing/debugging). */ /** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history } def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
/** Process a raw move input string and update game state if valid. /** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
* Notifies all observers of the outcome via GameEvent. * GameEvent.
*/ */
def processUserInput(rawInput: String): Unit = synchronized { def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase val trimmed = rawInput.trim.toLowerCase
trimmed match trimmed match
@@ -62,10 +63,12 @@ class GameEngine(
invoker.clear() invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext)) notifyObservers(DrawClaimedEvent(currentContext))
else else
notifyObservers(InvalidMoveEvent( notifyObservers(
currentContext, InvalidMoveEvent(
"Draw cannot be claimed: the 50-move rule has not been triggered." currentContext,
)) "Draw cannot be claimed: the 50-move rule has not been triggered.",
),
)
case "" => case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command.")) notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
@@ -73,10 +76,12 @@ class GameEngine(
case moveInput => case moveInput =>
Parser.parseMove(moveInput) match Parser.parseMove(moveInput) match
case None => case None =>
notifyObservers(InvalidMoveEvent( notifyObservers(
currentContext, InvalidMoveEvent(
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." currentContext,
)) s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
),
)
case Some((from, to)) => case Some((from, to)) =>
handleParsedMove(from, to) handleParsedMove(from, to)
} }
@@ -108,9 +113,8 @@ class GameEngine(
to.rank.ordinal == promoRank to.rank.ordinal == promoRank
} }
/** Apply a player's promotion piece choice. /** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true.
* Must only be called when isPendingPromotion is true. */
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized { def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match pendingPromotion match
case None => case None =>
@@ -120,23 +124,19 @@ class GameEngine(
val move = Move(pending.from, pending.to, MoveType.Promotion(piece)) val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal // Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from) val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then if legal.contains(move) then executeMove(move)
executeMove(move) else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
} }
/** Undo the last move. */ /** Undo the last move. */
def undo(): Unit = synchronized { performUndo() } def undo(): Unit = synchronized(performUndo())
/** Redo the last undone move. */ /** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() } def redo(): Unit = synchronized(performRedo())
/** Load a game using the provided importer. /** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* If the imported context has moves, they are replayed through the command system. * system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
* Otherwise, the position is set directly. */
* Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized { def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match importer.importGameContext(input) match
case Left(err) => Left(err) case Left(err) => Left(err)
@@ -155,29 +155,24 @@ class GameEngine(
if ctx.moves.isEmpty then if ctx.moves.isEmpty then
currentContext = ctx currentContext = ctx
Right(()) Right(())
else else replayMoves(ctx.moves, savedContext)
replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] = private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
moves.foreach: move => acc.flatMap(_ => applyReplayMove(move))
if error.isEmpty then }
handleParsedMove(move.from, move.to) result.left.foreach(_ => currentContext = savedContext)
result
move.moveType match { private def applyReplayMove(move: Move): Either[String, Unit] =
case MoveType.Promotion(pp) => handleParsedMove(move.from, move.to)
if pendingPromotion.isDefined then move.moveType match
completePromotion(pp) case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
else completePromotion(pp)
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
}
error match
case Some(err) =>
currentContext = savedContext
Left(err)
case None =>
Right(()) Right(())
case MoveType.Promotion(_) =>
Left(s"Promotion required for move ${move.from}${move.to}")
case _ => Right(())
/** Export the current game context using the provided exporter. */ /** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized { def exportGame(exporter: GameContextExport): String = synchronized {
@@ -203,25 +198,27 @@ class GameEngine(
private def executeMove(move: Move): Unit = private def executeMove(move: Move): Unit =
val contextBefore = currentContext val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move) val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move) val captured = computeCaptured(currentContext, move)
val cmd = MoveCommand( val cmd = MoveCommand(
from = move.from, from = move.from,
to = move.to, to = move.to,
moveResult = Some(MoveResult.Successful(nextContext, captured)), moveResult = Some(MoveResult.Successful(nextContext, captured)),
previousContext = Some(contextBefore), previousContext = Some(contextBefore),
notation = translateMoveToNotation(move, contextBefore.board) notation = translateMoveToNotation(move, contextBefore.board),
) )
invoker.execute(cmd) invoker.execute(cmd)
currentContext = nextContext currentContext = nextContext
notifyObservers(MoveExecutedEvent( notifyObservers(
currentContext, MoveExecutedEvent(
move.from.toString, currentContext,
move.to.toString, move.from.toString,
captured.map(c => s"${c.color.label} ${c.pieceType.label}") move.to.toString,
)) captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
),
)
if ruleSet.isCheckmate(currentContext) then if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite val winner = currentContext.turn.opposite
@@ -232,18 +229,16 @@ class GameEngine(
notifyObservers(StalemateEvent(currentContext)) notifyObservers(StalemateEvent(currentContext))
invoker.clear() invoker.clear()
currentContext = GameContext.initial currentContext = GameContext.initial
else if ruleSet.isCheck(currentContext) then else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match move.moveType match
case MoveType.CastleKingside => "O-O" case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O" case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move) case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp) case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture) case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
private def enPassantNotation(move: Move): String = private def enPassantNotation(move: Move): String =
@@ -295,8 +290,7 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _) moveCmd.previousContext.foreach(currentContext = _)
invoker.undo() invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation)) notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit = private def performRedo(): Unit =
if invoker.canRedo then if invoker.canRedo then
@@ -307,12 +301,13 @@ class GameEngine(
currentContext = nextCtx currentContext = nextCtx
invoker.redo() invoker.redo()
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}") val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveRedoneEvent( notifyObservers(
currentContext, MoveRedoneEvent(
moveCmd.notation, currentContext,
moveCmd.from.toString, moveCmd.notation,
moveCmd.to.toString, moveCmd.from.toString,
capturedDesc moveCmd.to.toString,
)) capturedDesc,
else ),
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo.")) )
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -3,82 +3,81 @@ package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square} import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
/** Base trait for all game state events. /** Base trait for all game state events. Events are immutable snapshots of game state changes.
* Events are immutable snapshots of game state changes. */
*/
sealed trait GameEvent: sealed trait GameEvent:
def context: GameContext def context: GameContext
/** Fired when a move is successfully executed. */ /** Fired when a move is successfully executed. */
case class MoveExecutedEvent( case class MoveExecutedEvent(
context: GameContext, context: GameContext,
fromSquare: String, fromSquare: String,
toSquare: String, toSquare: String,
capturedPiece: Option[String] capturedPiece: Option[String],
) extends GameEvent ) extends GameEvent
/** Fired when the current player is in check. */ /** Fired when the current player is in check. */
case class CheckDetectedEvent( case class CheckDetectedEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired when the game reaches checkmate. */ /** Fired when the game reaches checkmate. */
case class CheckmateEvent( case class CheckmateEvent(
context: GameContext, context: GameContext,
winner: Color winner: Color,
) extends GameEvent ) extends GameEvent
/** Fired when the game reaches stalemate. */ /** Fired when the game reaches stalemate. */
case class StalemateEvent( case class StalemateEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired when a move is invalid. */ /** Fired when a move is invalid. */
case class InvalidMoveEvent( case class InvalidMoveEvent(
context: GameContext, context: GameContext,
reason: String reason: String,
) extends GameEvent ) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ /** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent( case class PromotionRequiredEvent(
context: GameContext, context: GameContext,
from: Square, from: Square,
to: Square to: Square,
) extends GameEvent ) extends GameEvent
/** Fired when the board is reset. */ /** Fired when the board is reset. */
case class BoardResetEvent( case class BoardResetEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */ /** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent( case class FiftyMoveRuleAvailableEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */ /** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent( case class DrawClaimedEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */ /** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent( case class MoveUndoneEvent(
context: GameContext, context: GameContext,
pgnNotation: String pgnNotation: String,
) extends GameEvent ) extends GameEvent
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */ /** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
case class MoveRedoneEvent( case class MoveRedoneEvent(
context: GameContext, context: GameContext,
pgnNotation: String, pgnNotation: String,
fromSquare: String, fromSquare: String,
toSquare: String, toSquare: String,
capturedPiece: Option[String] capturedPiece: Option[String],
) extends GameEvent ) extends GameEvent
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */ /** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent( case class PgnLoadedEvent(
context: GameContext context: GameContext,
) extends GameEvent ) extends GameEvent
/** Observer trait: implement to receive game state updates. */ /** Observer trait: implement to receive game state updates. */
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank} import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -10,13 +10,16 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
private case class FailingCommand() extends Command: private case class FailingCommand() extends Command:
override def execute(): Boolean = false override def execute(): Boolean = false
override def undo(): Boolean = false override def undo(): Boolean = false
override def description: String = "Failing command" override def description: String = "Failing command"
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command: private case class ConditionalFailCommand(
override def execute(): Boolean = !shouldFailOnExecute var shouldFailOnUndo: Boolean = false,
override def undo(): Boolean = !shouldFailOnUndo var shouldFailOnExecute: Boolean = false,
) extends Command:
override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo
override def description: String = "Conditional fail" override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
@@ -24,12 +27,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
from = from, from = from,
to = to, to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None, moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial),
) )
test("execute rejects failing commands and keeps history unchanged"): test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = FailingCommand() val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
@@ -52,8 +55,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(cmd2) invoker.execute(cmd2)
invoker.undo() invoker.undo()
@@ -62,7 +65,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
} }
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true) val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true invoker.canUndo shouldBe true
@@ -71,7 +74,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
} }
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val successUndoCmd = ConditionalFailCommand() val successUndoCmd = ConditionalFailCommand()
invoker.execute(successUndoCmd) shouldBe true invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true invoker.undo() shouldBe true
@@ -85,15 +88,15 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) invoker.execute(cmd)
invoker.canRedo shouldBe false invoker.canRedo shouldBe false
invoker.redo() shouldBe false invoker.redo() shouldBe false
} }
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand() val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(redoFailCmd) invoker.execute(redoFailCmd)
@@ -106,7 +109,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true invoker.execute(cmd) shouldBe true
invoker.undo() shouldBe true invoker.undo() shouldBe true
invoker.redo() shouldBe true invoker.redo() shouldBe true
@@ -115,9 +118,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(cmd2) invoker.execute(cmd2)
invoker.undo() invoker.undo()
@@ -130,10 +133,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{ {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(cmd2) invoker.execute(cmd2)
invoker.execute(cmd3) invoker.execute(cmd3)
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank} import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
from = from, from = from,
to = to, to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial),
) )
test("execute appends commands and updates index"): test("execute appends commands and updates index"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1 invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
@@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("undo and redo update index and availability flags"): test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false invoker.canUndo shouldBe false
invoker.execute(cmd) invoker.execute(cmd)
invoker.canUndo shouldBe true invoker.canUndo shouldBe true
@@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("clear removes full history and resets index"): test("clear removes full history and resets index"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) invoker.execute(cmd)
invoker.clear() invoker.clear()
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
@@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("execute after undo discards redo history"): test("execute after undo discards redo history"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(cmd2) invoker.execute(cmd2)
invoker.undo() invoker.undo()
@@ -1,6 +1,6 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank} import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val executable = MoveCommand( val executable = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)) moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
) )
executable.execute() shouldBe true executable.execute() shouldBe true
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial),
) )
undoable.undo() shouldBe true undoable.undo() shouldBe true
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val result = MoveResult.Successful(GameContext.initial, None) val result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy( val cmd2 = cmd1.copy(
moveResult = Some(result), moveResult = Some(result),
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial),
) )
cmd1.moveResult shouldBe None cmd1.moveResult shouldBe None
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None previousContext = None,
) )
val eq2 = MoveCommand( val eq2 = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None previousContext = None,
) )
eq1 shouldBe eq2 eq1 shouldBe eq2
@@ -27,7 +27,7 @@ object EngineTestHelpers:
private val _events = mutable.ListBuffer[GameEvent]() private val _events = mutable.ListBuffer[GameEvent]()
def events: mutable.ListBuffer[GameEvent] = _events def events: mutable.ListBuffer[GameEvent] = _events
def eventCount: Int = _events.length def eventCount: Int = _events.length
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean = def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
_events.exists(ct.runtimeClass.isInstance(_)) _events.exists(ct.runtimeClass.isInstance(_))
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] = def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent} import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -10,82 +10,91 @@ import org.scalatest.matchers.should.Matchers
class GameEngineGameEndingTest extends AnyFunSuite with Matchers: class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"): test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine() val engine = new GameEngine()
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// Play Fool's mate // Play Fool's mate
engine.processUserInput("f2f3") engine.processUserInput("f2f3")
engine.processUserInput("e7e5") engine.processUserInput("e7e5")
engine.processUserInput("g2g4") engine.processUserInput("g2g4")
observer.events.clear() observer.events.clear()
engine.processUserInput("d8h4") engine.processUserInput("d8h4")
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) // Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent] observer.events.last shouldBe a[CheckmateEvent]
val event = observer.events.last.asInstanceOf[CheckmateEvent] val event = observer.events.last.asInstanceOf[CheckmateEvent]
event.winner shouldBe Color.Black event.winner shouldBe Color.Black
// Board should be reset after checkmate // Board should be reset after checkmate
engine.board shouldBe Board.initial engine.board shouldBe Board.initial
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
test("GameEngine handles check detection"): test("GameEngine handles check detection"):
val engine = new GameEngine() val engine = new GameEngine()
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// Play a simple check // Play a simple check
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.processUserInput("e7e5") engine.processUserInput("e7e5")
engine.processUserInput("f1c4") engine.processUserInput("f1c4")
engine.processUserInput("g8f6") engine.processUserInput("g8f6")
observer.events.clear() observer.events.clear()
engine.processUserInput("c4f7") // Check! engine.processUserInput("c4f7") // Check!
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e } val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
checkEvents.size shouldBe 1 checkEvents.size shouldBe 1
checkEvents.head.context.turn shouldBe Color.Black // Black is now in check checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
// Shortest known stalemate is 19 moves. Here is a faster one: // Shortest known stalemate is 19 moves. Here is a faster one:
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6 // e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
// Wait, let's just use Sam Loyd's 10-move stalemate: // Wait, let's just use Sam Loyd's 10-move stalemate:
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6 // 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
test("GameEngine handles Stalemate via 10-move known sequence"): test("GameEngine handles Stalemate via 10-move known sequence"):
val engine = new GameEngine() val engine = new GameEngine()
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "a7a5", "e2e3",
"d1h5", "a8a6", "a7a5",
"h5a5", "h7h5", "d1h5",
"h2h4", "a6h6", "a8a6",
"a5c7", "f7f6", "h5a5",
"c7d7", "e8f7", "h7h5",
"d7b7", "d8d3", "h2h4",
"b7b8", "d3h7", "a6h6",
"b8c8", "f7g6", "a5c7",
"c8e6" "f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
"c8e6",
) )
moves.dropRight(1).foreach(engine.processUserInput) moves.dropRight(1).foreach(engine.processUserInput)
observer.events.clear() observer.events.clear()
engine.processUserInput(moves.last) engine.processUserInput(moves.last)
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e } val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
stalemateEvents.size shouldBe 1 stalemateEvents.size shouldBe 1
// Board should be reset after stalemate // Board should be reset after stalemate
engine.board shouldBe Board.initial engine.board shouldBe Board.initial
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer: private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]() val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit = override def onGameEvent(event: GameEvent): Unit =
events += event events += event
@@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square) def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
def legalMoves(context: GameContext)(square: Square): List[Move] = def legalMoves(context: GameContext)(square: Square): List[Move] =
if square == sq("e2") then List(promotionMove) else List.empty if square == sq("e2") then List(promotionMove) else List.empty
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove) def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
def isCheck(context: GameContext): Boolean = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move) def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
val engine = new GameEngine(ruleSet = permissiveRules) val engine = new GameEngine(ruleSet = permissiveRules)
@@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val noLegalMoves = new RuleSet: val noLegalMoves = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
def allLegalMoves(context: GameContext): List[Move] = List.empty def allLegalMoves(context: GameContext): List[Move] = List.empty
def isCheck(context: GameContext): Boolean = false def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves) val engine = new GameEngine(ruleSet = noLegalMoves)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
@@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
test("loadGame replay executes non-promotion moves through default replay branch"): test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal()) val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine() val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(()) engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove) engine.context.moves.lastOption shouldBe Some(normalMove)
test("replayMoves skips later moves after the first move triggers an error"): test("replayMoves skips later moves after the first move triggers an error"):
val engine = new GameEngine() val engine = new GameEngine()
val saved = engine.context val saved = engine.context
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4")) val trailingMove = Move(sq("e2"), sq("e4"))
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1") engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
engine.context shouldBe saved engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"): test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine() val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false) val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.observerCount shouldBe 1 engine.observerCount shouldBe 1
engine.unsubscribe(observer) engine.unsubscribe(observer)
engine.observerCount shouldBe 0 engine.observerCount shouldBe 0
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent} import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
import de.nowchess.io.pgn.PgnParser import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.io.pgn.PgnExporter import de.nowchess.io.pgn.PgnExporter
@@ -15,7 +15,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
val engine = new GameEngine() val engine = new GameEngine()
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n" val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
val result = engine.loadGame(PgnParser, pgn) val result = engine.loadGame(PgnParser, pgn)
result shouldBe Right(()) result shouldBe Right(())
engine.context.moves.size shouldBe 2 engine.context.moves.size shouldBe 2
@@ -23,7 +23,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with FenParser: loads position without replaying moves"): test("loadGame with FenParser: loads position without replaying moves"):
val engine = new GameEngine() val engine = new GameEngine()
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
val result = engine.loadGame(FenParser, fen) val result = engine.loadGame(FenParser, fen)
result shouldBe Right(()) result shouldBe Right(())
engine.context.moves.isEmpty shouldBe true engine.context.moves.isEmpty shouldBe true
@@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
/** Tests that exercise moveToPgn branches not covered by other test files: /** Tests that exercise moveToPgn branches not covered by other test files:
* - CastleQueenside (line 223) * - CastleQueenside (line 223)
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255) * - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
* - Promotion(Bishop) notation (line 230) * - Promotion(Bishop) notation (line 230)
* - King normal move notation (line 246) * - King normal move notation (line 246)
*/ */
class GameEngineNotationTest extends AnyFunSuite with Matchers: class GameEngineNotationTest extends AnyFunSuite with Matchers:
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
@@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
// Castling rights: white queen-side only (no king-side rook present) // Castling rights: white queen-side only (no king-side rook present)
val castlingRights = de.nowchess.api.board.CastlingRights( val castlingRights = de.nowchess.api.board.CastlingRights(
whiteKingSide = false, whiteKingSide = false,
whiteQueenSide = true, whiteQueenSide = true,
blackKingSide = false, blackKingSide = false,
blackQueenSide = false blackQueenSide = false,
) )
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White castles queenside: e1c1 // White castles queenside: e1c1
engine.processUserInput("e1c1") engine.processUserInput("e1c1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.clear() events.clear()
engine.undo() engine.undo()
@@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"): test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6 // White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
val epSquare = Square.fromAlgebraic("d6") val epSquare = Square.fromAlgebraic("d6")
val ctx = GameContext.initial val ctx = GameContext.initial
.withBoard(board) .withBoard(board)
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White pawn on e5 captures en passant to d6 // White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6") engine.processUserInput("e5d6")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
// Verify the captured pawn was found (computeCaptured EnPassant branch) // Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
moveEvt.capturedPiece shouldBe defined moveEvt.capturedPiece shouldBe defined
moveEvt.capturedPiece.get should include ("Black") moveEvt.capturedPiece.get should include("Black")
events.clear() events.clear()
engine.undo() engine.undo()
@@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// King moves e1 -> f1 // King moves e1 -> f1
engine.processUserInput("e1f1") engine.processUserInput("e1f1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.clear() events.clear()
engine.undo() engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should startWith ("K") evt.pgnNotation should startWith("K")
evt.pgnNotation should include ("f1") evt.pgnNotation should include("f1")
@@ -10,7 +10,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Checkmate ─────────────────────────────────────────────────── // ── Checkmate ───────────────────────────────────────────────────
test("checkmate ends game with CheckmateEvent"): test("checkmate ends game with CheckmateEvent"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -24,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckmateEvent] shouldBe true observer.hasEvent[CheckmateEvent] shouldBe true
test("checkmate with white winner"): test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -45,20 +45,29 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Stalemate ─────────────────────────────────────────────────── // ── Stalemate ───────────────────────────────────────────────────
test("stalemate ends game with StalemateEvent"): test("stalemate ends game with StalemateEvent"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "a7a5", "e2e3",
"d1h5", "a8a6", "a7a5",
"h5a5", "h7h5", "d1h5",
"h2h4", "a6h6", "a8a6",
"a5c7", "f7f6", "h5a5",
"c7d7", "e8f7", "h7h5",
"d7b7", "d8d3", "h2h4",
"b7b8", "d3h7", "a6h6",
"b8c8", "f7g6" "a5c7",
"f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
) )
moves.foreach(engine.processUserInput) moves.foreach(engine.processUserInput)
observer.clear() observer.clear()
@@ -68,21 +77,30 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[StalemateEvent] shouldBe true observer.hasEvent[StalemateEvent] shouldBe true
test("stalemate when king has no moves and no pieces"): test("stalemate when king has no moves and no pieces"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
val moves = List( val moves = List(
"e2e3", "a7a5", "e2e3",
"d1h5", "a8a6", "a7a5",
"h5a5", "h7h5", "d1h5",
"h2h4", "a6h6", "a8a6",
"a5c7", "f7f6", "h5a5",
"c7d7", "e8f7", "h7h5",
"d7b7", "d8d3", "h2h4",
"b7b8", "d3h7", "a6h6",
"b8c8", "f7g6", "a5c7",
"c8e6" "f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
"c8e6",
) )
moves.foreach(engine.processUserInput) moves.foreach(engine.processUserInput)
@@ -93,7 +111,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Check detection ──────────────────────────────────────────── // ── Check detection ────────────────────────────────────────────
test("check detected after move puts king in check"): test("check detected after move puts king in check"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -108,7 +126,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckDetectedEvent] shouldBe true observer.hasEvent[CheckDetectedEvent] shouldBe true
test("check by knight"): test("check by knight"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -122,7 +140,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Fifty-move rule ──────────────────────────────────────────── // ── Fifty-move rule ────────────────────────────────────────────
test("fifty-move rule triggers when half-move clock reaches 100"): test("fifty-move rule triggers when half-move clock reaches 100"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -155,7 +173,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Draw claim ──────────────────────────────────────────────── // ── Draw claim ────────────────────────────────────────────────
test("draw can be claimed when fifty-move rule is available"): test("draw can be claimed when fifty-move rule is available"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -167,7 +185,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[DrawClaimedEvent] shouldBe true observer.hasEvent[DrawClaimedEvent] shouldBe true
test("draw cannot be claimed when not available"): test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = engineWith(promotionBoard) val engine = engineWith(promotionBoard)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true) events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true)
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7)) events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
} }
test("isPendingPromotion is true after PromotionRequired input") { test("isPendingPromotion is true after PromotionRequired input") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = engineWith(promotionBoard) val engine = engineWith(promotionBoard)
captureEvents(engine) captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true) engine.isPendingPromotion should be(true)
} }
test("isPendingPromotion is false before any promotion input") { test("isPendingPromotion is false before any promotion input") {
val engine = new GameEngine() val engine = new GameEngine()
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
} }
test("completePromotion fires MoveExecutedEvent with promoted piece") { test("completePromotion fires MoveExecutedEvent with promoted piece") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = engineWith(promotionBoard) val engine = engineWith(promotionBoard)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None) engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
} }
test("completePromotion with rook underpromotion") { test("completePromotion with rook underpromotion") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = engineWith(promotionBoard) val engine = engineWith(promotionBoard)
captureEvents(engine) captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Rook) engine.completePromotion(PromotionPiece.Rook)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
} }
test("completePromotion with no pending promotion fires InvalidMoveEvent") { test("completePromotion with no pending promotion fires InvalidMoveEvent") {
@@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
} }
test("completePromotion fires CheckDetectedEvent when promotion gives check") { test("completePromotion fires CheckDetectedEvent when promotion gives check") {
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
val engine = engineWith(promotionBoard) val engine = engineWith(promotionBoard)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true)
} }
test("completePromotion results in Moved when promotion doesn't give check") { test("completePromotion results in Moved when promotion doesn't give check") {
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
val engine = engineWith(board) val engine = engineWith(board)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
} }
test("completePromotion results in Checkmate when promotion delivers checkmate") { test("completePromotion results in Checkmate when promotion delivers checkmate") {
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val engine = engineWith(board) val engine = engineWith(board)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("h7h8") engine.processUserInput("h7h8")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true) events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
} }
test("completePromotion results in Stalemate when promotion creates stalemate") { test("completePromotion results in Stalemate when promotion creates stalemate") {
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val engine = engineWith(board) val engine = engineWith(board)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("b7b8") engine.processUserInput("b7b8")
engine.completePromotion(PromotionPiece.Knight) engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[StalemateEvent]) should be (true) events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
} }
test("completePromotion with black pawn promotion results in Moved") { test("completePromotion with black pawn promotion results in Moved") {
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
val engine = engineWith(board, Color.Black) val engine = engineWith(board, Color.Black)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e2e1") engine.processUserInput("e2e1")
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
} }
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
@@ -177,21 +177,21 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
DefaultRules.applyMove(context)(move) DefaultRules.applyMove(context)(move)
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
val engine = new GameEngine(initialCtx, delegatingRuleSet) val engine = new GameEngine(initialCtx, delegatingRuleSet)
val events = captureEvents(engine) val events = captureEvents(engine)
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8, // isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion // and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
engine.processUserInput("e7e8") engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true) engine.isPendingPromotion should be(true)
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves, // completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
// but only Normal moves exist → fires InvalidMoveEvent // but only Normal moves exist → fires InvalidMoveEvent
engine.completePromotion(PromotionPiece.Queen) engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false) engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include ("Error completing promotion") invalidEvt.reason should include("Error completing promotion")
} }
@@ -1,6 +1,6 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.api.board.{Color, File, Rank, Square, Piece} import de.nowchess.api.board.{Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
@@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Observer wiring ──────────────────────────────────────────── // ── Observer wiring ────────────────────────────────────────────
test("observer subscribe and unsubscribe behavior"): test("observer subscribe and unsubscribe behavior"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
@@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Invalid moves (minimal) ──────────────────────────────────── // ── Invalid moves (minimal) ────────────────────────────────────
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"): test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
engine.processUserInput("h3h4") engine.processUserInput("h3h4")
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
engine.turn shouldBe Color.White // turn unchanged engine.turn shouldBe Color.White // turn unchanged
engine.processUserInput("e7e5") // try to move black pawn on white's turn engine.processUserInput("e7e5") // try to move black pawn on white's turn
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.processUserInput("e5e4") // pawn backward engine.processUserInput("e5e4") // pawn backward
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
// ── Undo/Redo ──────────────────────────────────────────────── // ── Undo/Redo ────────────────────────────────────────────────
test("undo redo success and empty-history failures"): test("undo redo success and empty-history failures"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Fifty-move rule ──────────────────────────────────────────── // ── Fifty-move rule ────────────────────────────────────────────
test("fifty-move event and draw claim success/failure"): test("fifty-move event and draw claim success/failure"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── Castling ──────────────────────────────────────────────────── // ── Castling ────────────────────────────────────────────────────
test("kingside castling executes successfully"): test("kingside castling executes successfully"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black engine.turn shouldBe Color.Black
test("queenside castling executes successfully"): test("queenside castling executes successfully"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black engine.turn shouldBe Color.Black
test("undo castling emits PGN notation"): test("undo castling emits PGN notation"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── En passant ────────────────────────────────────────────────── // ── En passant ──────────────────────────────────────────────────
test("en passant capture executes successfully"): test("en passant capture executes successfully"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[MoveExecutedEvent] shouldBe true observer.hasEvent[MoveExecutedEvent] shouldBe true
val moveEvt = observer.getEvent[MoveExecutedEvent] val moveEvt = observer.getEvent[MoveExecutedEvent]
moveEvt.get.capturedPiece shouldBe defined // pawn was captured moveEvt.get.capturedPiece shouldBe defined // pawn was captured
test("undo en passant emits file-x-destination notation"): test("undo en passant emits file-x-destination notation"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── Pawn promotion ───────────────────────────────────────────── // ── Pawn promotion ─────────────────────────────────────────────
test("pawn reaching back rank requires promotion"): test("pawn reaching back rank requires promotion"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black engine.turn shouldBe Color.Black
test("promotion to Queen with discovered check emits CheckDetectedEvent"): test("promotion to Queen with discovered check emits CheckDetectedEvent"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckDetectedEvent] shouldBe true observer.hasEvent[CheckDetectedEvent] shouldBe true
test("promotion to Queen with checkmate emits CheckmateEvent"): test("promotion to Queen with checkmate emits CheckmateEvent"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -171,7 +171,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckmateEvent] shouldBe true observer.hasEvent[CheckmateEvent] shouldBe true
test("undo promotion emits notation with piece suffix"): test("undo promotion emits notation with piece suffix"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -15,28 +15,22 @@ object FenExporter extends GameContextExport:
/** Build the FEN representation for a single rank. */ /** Build the FEN representation for a single rank. */
private def buildRankString(board: Board, rank: Rank): String = private def buildRankString(board: Board, rank: Rank): String =
val rankSquares = File.values.map(file => Square(file, rank)) val rankSquares = File.values.map(file => Square(file, rank))
val rankChars = scala.collection.mutable.ListBuffer[Char]() val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
var emptyCount = 0 case ((acc, empty), square) =>
board.pieceAt(square) match
for square <- rankSquares do case Some(piece) =>
board.pieceAt(square) match val flushed = if empty > 0 then acc + empty.toString else acc
case Some(piece) => (flushed + pieceToFenChar(piece), 0)
if emptyCount > 0 then case None =>
rankChars += emptyCount.toString.charAt(0) (acc, empty + 1)
emptyCount = 0 if emptyCount > 0 then result + emptyCount.toString else result
rankChars += pieceToFenChar(piece)
case None =>
emptyCount += 1
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
/** Convert a GameContext to a complete FEN string. */ /** Convert a GameContext to a complete FEN string. */
def gameContextToFen(context: GameContext): String = def gameContextToFen(context: GameContext): String =
val piecePlacement = boardToFen(context.board) val piecePlacement = boardToFen(context.board)
val activeColor = if context.turn == Color.White then "w" else "b" val activeColor = if context.turn == Color.White then "w" else "b"
val castling = castlingString(context.castlingRights) val castling = castlingString(context.castlingRights)
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-") val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
val fullMoveNumber = 1 + (context.moves.length / 2) val fullMoveNumber = 1 + (context.moves.length / 2)
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
/** Convert castling rights to FEN notation. */ /** Convert castling rights to FEN notation. */
private def castlingString(rights: CastlingRights): String = private def castlingString(rights: CastlingRights): String =
val wk = if rights.whiteKingSide then "K" else "" val wk = if rights.whiteKingSide then "K" else ""
val wq = if rights.whiteQueenSide then "Q" else "" val wq = if rights.whiteQueenSide then "Q" else ""
val bk = if rights.blackKingSide then "k" else "" val bk = if rights.blackKingSide then "k" else ""
val bq = if rights.blackQueenSide then "q" else "" val bq = if rights.blackQueenSide then "q" else ""
val result = s"$wk$wq$bk$bq" val result = s"$wk$wq$bk$bq"
if result.isEmpty then "-" else result if result.isEmpty then "-" else result
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
case PieceType.Queen => 'q' case PieceType.Queen => 'q'
case PieceType.King => 'k' case PieceType.King => 'k'
if piece.color == Color.White then base.toUpper else base if piece.color == Color.White then base.toUpper else base
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
object FenParser extends GameContextImport: object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext. /** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
* Returns Left with error message if the format is invalid. */ */
def parseFen(fen: String): Either[String, GameContext] = def parseFen(fen: String): Either[String, GameContext] =
val parts = fen.trim.split("\\s+") val parts = fen.trim.split("\\s+")
if parts.length != 6 then if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
else else
for for
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position") board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')") activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights") castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square") enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)") halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)") fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts") _ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
yield GameContext( yield GameContext(
board = board, board = board,
turn = activeColor, turn = activeColor,
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassant, enPassantSquare = enPassant,
halfMoveClock = halfMoveClock, halfMoveClock = halfMoveClock,
moves = List.empty moves = List.empty,
) )
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */ /** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
private def parseCastling(s: String): Option[CastlingRights] = private def parseCastling(s: String): Option[CastlingRights] =
if s == "-" then if s == "-" then Some(CastlingRights.None)
Some(CastlingRights.None)
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
Some(CastlingRights( Some(
whiteKingSide = s.contains('K'), CastlingRights(
whiteQueenSide = s.contains('Q'), whiteKingSide = s.contains('K'),
blackKingSide = s.contains('k'), whiteQueenSide = s.contains('Q'),
blackQueenSide = s.contains('q') blackKingSide = s.contains('k'),
)) blackQueenSide = s.contains('q'),
else ),
None )
else None
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */ /** Parse en passant target square ("-" for none, or algebraic like "e3"). */
private def parseEnPassant(s: String): Option[Option[Square]] = private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None) if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_)) else Square.fromAlgebraic(s).map(Some(_))
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. /** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format
* Returns None if the format is invalid. */ * is invalid.
*/
def parseBoard(fen: String): Option[Board] = def parseBoard(fen: String): Option[Board] =
val rankStrings = fen.split("/", -1) val rankStrings = fen.split("/", -1)
if rankStrings.length != 8 then None if rankStrings.length != 8 then None
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
parsePieceRank(rankStr, rank).map(squares => acc :+ squares) parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
parsedRanks.map(ranks => Board(ranks.flatten.toMap)) parsedRanks.map(ranks => Board(ranks.flatten.toMap))
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. /** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the
* Returns None if the rank string contains invalid characters or the wrong number of files. */ * rank string contains invalid characters or the wrong number of files.
*/
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] = private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
var fileIdx = 0 val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]() case ((idx, true, acc), _) => (idx, true, acc)
var failed = false case ((idx, false, acc), c) =>
if idx > 7 then (idx, true, acc)
for c <- rankStr if !failed do else if c.isDigit then (idx + c.asDigit, false, acc)
if fileIdx > 7 then else
failed = true charToPiece(c) match
else if c.isDigit then case None => (idx, true, acc)
fileIdx += c.asDigit case Some(piece) =>
else (idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
charToPiece(c) match
case None => failed = true
case Some(piece) =>
val file = File.values(fileIdx)
squares += (Square(file, rank) -> piece)
fileIdx += 1
if failed || fileIdx != 8 then None if failed || fileIdx != 8 then None
else Some(squares.toList) else Some(squares)
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */ /** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
private def charToPiece(c: Char): Option[Piece] = private def charToPiece(c: Char): Option[Piece] =
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
case 'k' => Some(PieceType.King) case 'k' => Some(PieceType.King)
case _ => None case _ => None
pieceTypeOpt.map(pt => Piece(color, pt)) pieceTypeOpt.map(pt => Piece(color, pt))
@@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def pieceChar: Parser[Piece] = private def pieceChar: Parser[Piece] =
"[prnbqkPRNBQK]".r ^^ { s => "[prnbqkPRNBQK]".r ^^ { s =>
val c = s.head val c = s.head
val color = if c.isUpper then Color.White else Color.Black val color = if c.isUpper then Color.White else Color.Black
Piece(color, charToPieceType(c.toLower)) Piece(color, charToPieceType(c.toLower))
} }
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken) private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. /** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
* Fails if total file count != 8 or any piece placement exceeds board bounds. */ * placement exceeds board bounds.
*/
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] = private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
rankTokens >> { tokens => rankTokens >> { tokens =>
buildSquares(rank, tokens) match buildSquares(rank, tokens) match
@@ -45,16 +46,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */ /** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
private def boardParser: Parser[Board] = private def boardParser: Parser[Board] =
rankParser(Rank.R8) ~ rankParser(Rank.R8) ~
(rankSep ~> rankParser(Rank.R7)) ~ (rankSep ~> rankParser(Rank.R7)) ~
(rankSep ~> rankParser(Rank.R6)) ~ (rankSep ~> rankParser(Rank.R6)) ~
(rankSep ~> rankParser(Rank.R5)) ~ (rankSep ~> rankParser(Rank.R5)) ~
(rankSep ~> rankParser(Rank.R4)) ~ (rankSep ~> rankParser(Rank.R4)) ~
(rankSep ~> rankParser(Rank.R3)) ~ (rankSep ~> rankParser(Rank.R3)) ~
(rankSep ~> rankParser(Rank.R2)) ~ (rankSep ~> rankParser(Rank.R2)) ~
(rankSep ~> rankParser(Rank.R1)) ^^ { (rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
} }
// ── Color parser ───────────────────────────────────────────────────────── // ── Color parser ─────────────────────────────────────────────────────────
@@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def castlingParser: Parser[CastlingRights] = private def castlingParser: Parser[CastlingRights] =
"-" ^^^ CastlingRights.None | "-" ^^^ CastlingRights.None |
"[KQkq]{1,4}".r ^^ { s => "[KQkq]{1,4}".r ^^ { s =>
CastlingRights( CastlingRights(
whiteKingSide = s.contains('K'), whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'), whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'), blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q') blackQueenSide = s.contains('q'),
) )
} }
// ── En passant parser ──────────────────────────────────────────────────── // ── En passant parser ────────────────────────────────────────────────────
private def enPassantParser: Parser[Option[Square]] = private def enPassantParser: Parser[Option[Square]] =
"-" ^^^ Option.empty[Square] | "-" ^^^ Option.empty[Square] |
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) } "[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
// ── Clock parser ───────────────────────────────────────────────────────── // ── Clock parser ─────────────────────────────────────────────────────────
@@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def fenParser: Parser[GameContext] = private def fenParser: Parser[GameContext] =
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~ boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ { (" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
case board ~ color ~ castling ~ ep ~ halfMove ~ _ => case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
GameContext( GameContext(
board = board, board = board,
turn = color, turn = color,
castlingRights = castling, castlingRights = castling,
enPassantSquare = ep, enPassantSquare = ep,
halfMoveClock = halfMove, halfMoveClock = halfMove,
moves = List.empty moves = List.empty,
) )
} }
// ── Public API ─────────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────────
@@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport:
private def pieceChar(using P[Any]): P[Piece] = private def pieceChar(using P[Any]): P[Piece] =
CharIn("prnbqkPRNBQK").!.map { s => CharIn("prnbqkPRNBQK").!.map { s =>
val c = s.head val c = s.head
val color = if c.isUpper then Color.White else Color.Black val color = if c.isUpper then Color.White else Color.Black
Piece(color, charToPieceType(c.toLower)) Piece(color, charToPieceType(c.toLower))
} }
@@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport:
private def boardParser(using P[Any]): P[Board] = private def boardParser(using P[Any]): P[Board] =
(rankParser(Rank.R8) ~ sep ~ (rankParser(Rank.R8) ~ sep ~
rankParser(Rank.R7) ~ sep ~ rankParser(Rank.R7) ~ sep ~
rankParser(Rank.R6) ~ sep ~ rankParser(Rank.R6) ~ sep ~
rankParser(Rank.R5) ~ sep ~ rankParser(Rank.R5) ~ sep ~
rankParser(Rank.R4) ~ sep ~ rankParser(Rank.R4) ~ sep ~
rankParser(Rank.R3) ~ sep ~ rankParser(Rank.R3) ~ sep ~
rankParser(Rank.R2) ~ sep ~ rankParser(Rank.R2) ~ sep ~
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) => rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
} }
@@ -61,20 +61,20 @@ object FenParserFastParse extends GameContextImport:
private def castlingParser(using P[Any]): P[CastlingRights] = private def castlingParser(using P[Any]): P[CastlingRights] =
LiteralStr("-").map(_ => CastlingRights.None) | LiteralStr("-").map(_ => CastlingRights.None) |
CharsWhileIn("KQkq").!.map { s => CharsWhileIn("KQkq").!.map { s =>
CastlingRights( CastlingRights(
whiteKingSide = s.contains('K'), whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'), whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'), blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q') blackQueenSide = s.contains('q'),
) )
} }
// ── En passant parser ──────────────────────────────────────────────────── // ── En passant parser ────────────────────────────────────────────────────
private def enPassantParser(using P[Any]): P[Option[Square]] = private def enPassantParser(using P[Any]): P[Option[Square]] =
LiteralStr("-").map(_ => Option.empty[Square]) | LiteralStr("-").map(_ => Option.empty[Square]) |
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s)) (CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
// ── Clock parser ───────────────────────────────────────────────────────── // ── Clock parser ─────────────────────────────────────────────────────────
@@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport:
private def fenParser(using P[Any]): P[GameContext] = private def fenParser(using P[Any]): P[GameContext] =
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~ (boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map { enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
case (board, color, castling, ep, halfMove, _) => case (board, color, castling, ep, halfMove, _) =>
GameContext( GameContext(
board = board, board = board,
turn = color, turn = color,
castlingRights = castling, castlingRights = castling,
enPassantSquare = ep, enPassantSquare = ep,
halfMoveClock = halfMove, halfMoveClock = halfMove,
moves = List.empty moves = List.empty,
) )
} }
@@ -14,19 +14,20 @@ private[fen] object FenParserSupport:
'n' -> PieceType.Knight, 'n' -> PieceType.Knight,
'b' -> PieceType.Bishop, 'b' -> PieceType.Bishop,
'q' -> PieceType.Queen, 'q' -> PieceType.Queen,
'k' -> PieceType.King 'k' -> PieceType.King,
) )
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] = def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))): tokens
case (None, _) => None .foldLeft(Option((List.empty[(Square, Piece)], 0))):
case (Some((acc, fileIdx)), PieceToken(piece)) => case (None, _) => None
if fileIdx > 7 then None case (Some((acc, fileIdx)), PieceToken(piece)) =>
else if fileIdx > 7 then None
val sq = Square(File.values(fileIdx), rank) else
Some((acc :+ (sq -> piece), fileIdx + 1)) val sq = Square(File.values(fileIdx), rank)
case (Some((acc, fileIdx)), EmptyToken(n)) => Some((acc :+ (sq -> piece), fileIdx + 1))
val next = fileIdx + n case (Some((acc, fileIdx)), EmptyToken(n)) =>
if next > 8 then None val next = fileIdx + n
else Some((acc, next)) if next > 8 then None
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None } else Some((acc, next))
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
/** Export a GameContext to PGN format. */ /** Export a GameContext to PGN format. */
def exportGameContext(context: GameContext): String = def exportGameContext(context: GameContext): String =
val headers = Map( val headers = Map(
"Event" -> "?", "Event" -> "?",
"White" -> "?", "White" -> "?",
"Black" -> "?", "Black" -> "?",
"Result" -> "*" "Result" -> "*",
) )
exportGame(headers, context.moves) exportGame(headers, context.moves)
/** Export a game with headers and moves to PGN format. */ /** Export a game with headers and moves to PGN format. */
def exportGame(headers: Map[String, String], moves: List[Move]): String = def exportGame(headers: Map[String, String], moves: List[Move]): String =
val headerLines = headers.map { case (key, value) => val headerLines = headers
s"""[$key "$value"]""" .map { case (key, value) =>
}.mkString("\n") s"""[$key "$value"]"""
val moveText = if moves.isEmpty then ""
else
var ctx = GameContext.initial
val sanMoves = moves.map { move =>
val algebraic = moveToAlgebraic(move, ctx.board)
ctx = DefaultRules.applyMove(ctx)(move)
algebraic
} }
.mkString("\n")
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2) val moveText =
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield if moves.isEmpty then ""
val moveNum = moveNumber + 1 else
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val termination = headers.getOrElse("Result", "*") val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
moveLines.mkString(" ") + s" $termination" val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines else if moveText.isEmpty then headerLines
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
case MoveType.CastleKingside => "O-O" case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O" case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}" case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
case MoveType.Promotion(pp) => case MoveType.Promotion(pp) =>
val promSuffix = pp match val promSuffix = pp match
case PromotionPiece.Queen => "=Q" case PromotionPiece.Queen => "=Q"
case PromotionPiece.Rook => "=R" case PromotionPiece.Rook => "=R"
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
case PieceType.Rook => s"R$capStr$dest" case PieceType.Rook => s"R$capStr$dest"
case PieceType.Queen => s"Q$capStr$dest" case PieceType.Queen => s"Q$capStr$dest"
case PieceType.King => s"K$capStr$dest" case PieceType.King => s"K$capStr$dest"
@@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules
/** A parsed PGN game containing headers and the resolved move list. */ /** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame( case class PgnGame(
headers: Map[String, String], headers: Map[String, String],
moves: List[Move] moves: List[Move],
) )
object PgnParser extends GameContextImport: object PgnParser extends GameContextImport:
/** Strictly validate a PGN text. /** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
* Returns Right(PgnGame) if every move token is a legal move in the evolving position. * Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */ */
def validatePgn(pgn: String): Either[String, PgnGame] = def validatePgn(pgn: String): Either[String, PgnGame] =
val lines = pgn.split("\n").map(_.trim) val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("[")) val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines) val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ") val moveText = rest.mkString(" ")
validateMovesText(moveText).map(moves => PgnGame(headers, moves)) validateMovesText(moveText).map(moves => PgnGame(headers, moves))
/** Import a PGN text into a GameContext by validating and replaying all moves. /** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
* Returns Right(GameContext) with all moves applied and .moves populated. * moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
* Returns Left(error message) if validation fails or move replay encounters an issue. */ * issue.
*/
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
validatePgn(input).flatMap { game => validatePgn(input).flatMap { game =>
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))) Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
} }
/** Parse a complete PGN text into a PgnGame with headers and moves. /** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
* Always succeeds (returns Some); malformed tokens are silently skipped. */ * are silently skipped.
*/
def parsePgn(pgn: String): Option[PgnGame] = def parsePgn(pgn: String): Option[PgnGame] =
val lines = pgn.split("\n").map(_.trim) val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("[")) val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines) val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ") val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText) val moves = parseMovesText(moveText)
Some(PgnGame(headers, moves)) Some(PgnGame(headers, moves))
/** Parse PGN header lines of the form [Key "Value"]. */ /** Parse PGN header lines of the form [Key "Value"]. */
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
private def parseMovesText(moveText: String): List[Move] = private def parseMovesText(moveText: String): List[Move] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty) val tokens = moveText.split("\\s+").filter(_.nonEmpty)
val (_, _, moves) = tokens.foldLeft( val (_, _, moves) = tokens.foldLeft(
(GameContext.initial, Color.White, List.empty[Move]) (GameContext.initial, Color.White, List.empty[Move]),
): ):
case (state @ (ctx, color, acc), token) => case (state @ (ctx, color, acc), token) =>
if isMoveNumberOrResult(token) then state if isMoveNumberOrResult(token) then state
else else
parseAlgebraicMove(token, ctx, color) match parseAlgebraicMove(token, ctx, color) match
case None => state case None => state
case Some(move) => case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move) val nextCtx = DefaultRules.applyMove(ctx)(move)
(nextCtx, color.opposite, acc :+ move) (nextCtx, color.opposite, acc :+ move)
moves moves
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */ /** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean = private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") || token.matches("""\d+\.""") ||
token == "*" || token == "*" ||
token == "1-0" || token == "1-0" ||
token == "0-1" || token == "0-1" ||
token == "1/2-1/2" token == "1/2-1/2"
/** Parse a single algebraic notation token into a Move, given the current game context. */ /** Parse a single algebraic notation token into a Move, given the current game context. */
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] = def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
if clean.length < 2 then None if clean.length < 2 then None
else else
val destStr = clean.takeRight(2) val destStr = clean.takeRight(2)
Square.fromAlgebraic(destStr).flatMap: toSquare => Square
val disambig = clean.dropRight(2) .fromAlgebraic(destStr)
.flatMap: toSquare =>
val disambig = clean.dropRight(2)
val requiredPieceType: Option[PieceType] = val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head) if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head) else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn) else Some(PieceType.Pawn)
val hint = val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig else disambig
val promotion = extractPromotion(notation) val promotion = extractPromotion(notation)
// Get all legal moves for this color that reach toSquare // Get all legal moves for this color that reach toSquare
val allLegal = DefaultRules.allLegalMoves(ctx) val allLegal = DefaultRules.allLegalMoves(ctx)
val candidates = allLegal.filter { move => val candidates = allLegal.filter { move =>
move.to == toSquare && move.to == toSquare &&
ctx.board.pieceAt(move.from).exists(p => ctx.board
p.color == color && .pieceAt(move.from)
requiredPieceType.forall(_ == p.pieceType) .exists(p =>
) && p.color == color &&
(hint.isEmpty || matchesHint(move.from, hint)) && requiredPieceType.forall(_ == p.pieceType),
promotionMatches(move, promotion) ) &&
} (hint.isEmpty || matchesHint(move.from, hint)) &&
promotionMatches(move, promotion)
}
candidates.headOption candidates.headOption
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */ /** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean = private def matchesHint(sq: Square, hint: String): Boolean =
hint.forall(c => hint.forall(c =>
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true else true,
) )
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean = private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
promotion match promotion match
case None => move.moveType match case None =>
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true move.moveType match
case _ => false case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
case _ => false
case Some(pp) => move.moveType == MoveType.Promotion(pp) case Some(pp) => move.moveType == MoveType.Promotion(pp)
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */ /** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
private def validateMovesText(moveText: String): Either[String, List[Move]] = private def validateMovesText(moveText: String): Either[String, List[Move]] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty) val tokens = moveText.split("\\s+").filter(_.nonEmpty)
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) { tokens
case (acc, token) => .foldLeft(
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
) { case (acc, token) =>
acc.flatMap { case (ctx, color, moves) => acc.flatMap { case (ctx, color, moves) =>
if isMoveNumberOrResult(token) then Right((ctx, color, moves)) if isMoveNumberOrResult(token) then Right((ctx, color, moves))
else else
parseAlgebraicMove(token, ctx, color) match parseAlgebraicMove(token, ctx, color) match
case None => Left(s"Illegal or impossible move: '$token'") case None => Left(s"Illegal or impossible move: '$token'")
case Some(move) => case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move) val nextCtx = DefaultRules.applyMove(ctx)(move)
Right((nextCtx, color.opposite, moves :+ move)) Right((nextCtx, color.opposite, moves :+ move))
} }
}.map(_._3) }
.map(_._3)
@@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers
class FenExporterTest extends AnyFunSuite with Matchers: class FenExporterTest extends AnyFunSuite with Matchers:
private def context( private def context(
piecePlacement: String, piecePlacement: String,
turn: Color, turn: Color,
castlingRights: CastlingRights, castlingRights: CastlingRights,
enPassantSquare: Option[Square], enPassantSquare: Option[Square],
halfMoveClock: Int, halfMoveClock: Int,
moveCount: Int moveCount: Int,
): GameContext = ): GameContext =
val board = FenParser.parseBoard(piecePlacement).getOrElse( val board = FenParser
fail(s"Invalid test board FEN: $piecePlacement") .parseBoard(piecePlacement)
) .getOrElse(
fail(s"Invalid test board FEN: $piecePlacement"),
)
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3)) val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
GameContext( GameContext(
board = board, board = board,
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassantSquare, enPassantSquare = enPassantSquare,
halfMoveClock = halfMoveClock, halfMoveClock = halfMoveClock,
moves = List.fill(moveCount)(dummyMove) moves = List.fill(moveCount)(dummyMove),
) )
test("exportGameContextToFen handles initial and typical developed position"): test("exportGameContextToFen handles initial and typical developed position"):
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.E, Rank.R3)), enPassantSquare = Some(Square(File.E, Rank.R3)),
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0 moveCount = 0,
) )
FenExporter.gameContextToFen(gameContext) shouldBe FenExporter.gameContextToFen(gameContext) shouldBe
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
@@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.None, castlingRights = CastlingRights.None,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0 moveCount = 0,
) )
FenExporter.gameContextToFen(noCastling) shouldBe FenExporter.gameContextToFen(noCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
whiteKingSide = true, whiteKingSide = true,
whiteQueenSide = false, whiteQueenSide = false,
blackKingSide = false, blackKingSide = false,
blackQueenSide = true blackQueenSide = true,
), ),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 5, halfMoveClock = 5,
moveCount = 4 moveCount = 4,
) )
FenExporter.gameContextToFen(partialCastling) shouldBe FenExporter.gameContextToFen(partialCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
@@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.C, Rank.R6)), enPassantSquare = Some(Square(File.C, Rank.R6)),
halfMoveClock = 2, halfMoveClock = 2,
moveCount = 4 moveCount = 4,
) )
FenExporter.gameContextToFen(withEnPassant) shouldBe FenExporter.gameContextToFen(withEnPassant) shouldBe
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
@@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 42, halfMoveClock = 42,
moves = List.empty moves = List.empty,
) )
val fen = FenExporter.gameContextToFen(gameContext) val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match FenParser.parseFen(fen) match
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
val ctx = GameContext.initial val ctx = GameContext.initial
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx) FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
@@ -8,34 +8,52 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"): test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8" val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8"
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing)) Some(Piece.WhitePawn),
)
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(
Some(Piece.BlackKing),
)
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0) FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing)) FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(
Some(Piece.BlackKing),
)
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial) FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => FenParserCombinators
ctx.turn shouldBe Color.White .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
ctx.castlingRights.whiteKingSide shouldBe true .fold(
ctx.enPassantSquare shouldBe None _ => fail(),
ctx.halfMoveClock shouldBe 0 ctx =>
) ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => FenParserCombinators
ctx.turn shouldBe Color.Black .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) .fold(
) _ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => FenParserCombinators
ctx.castlingRights.whiteKingSide shouldBe false .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
ctx.castlingRights.blackQueenSide shouldBe false .fold(
) _ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -8,7 +8,7 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"): test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8" val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8"
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
@@ -20,22 +20,34 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => FenParserFastParse
ctx.turn shouldBe Color.White .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
ctx.castlingRights.whiteKingSide shouldBe true .fold(
ctx.enPassantSquare shouldBe None _ => fail(),
ctx.halfMoveClock shouldBe 0 ctx =>
) ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => FenParserFastParse
ctx.turn shouldBe Color.Black .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) .fold(
) _ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => FenParserFastParse
ctx.castlingRights.whiteKingSide shouldBe false .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
ctx.castlingRights.blackQueenSide shouldBe false .fold(
) _ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -8,7 +8,7 @@ class FenParserTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"): test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8" val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8"
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
@@ -20,22 +20,34 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"): test("parseFen parses full state for common valid inputs"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => FenParser
ctx.turn shouldBe Color.White .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
ctx.castlingRights.whiteKingSide shouldBe true .fold(
ctx.enPassantSquare shouldBe None _ => fail(),
ctx.halfMoveClock shouldBe 0 ctx =>
) ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => FenParser
ctx.turn shouldBe Color.Black .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) .fold(
) _ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => FenParser
ctx.castlingRights.whiteKingSide shouldBe false .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
ctx.castlingRights.blackQueenSide shouldBe false .fold(
) _ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"): test("parseFen rejects invalid color and castling tokens"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
class PgnExporterTest extends AnyFunSuite with Matchers: class PgnExporterTest extends AnyFunSuite with Matchers:
test("exportGame renders headers and basic move text"): test("exportGame renders headers and basic move text"):
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val emptyPgn = PgnExporter.exportGame(headers, List.empty) val emptyPgn = PgnExporter.exportGame(headers, List.empty)
emptyPgn.contains("[Event \"Test\"]") shouldBe true emptyPgn.contains("[Event \"Test\"]") shouldBe true
emptyPgn.contains("[White \"A\"]") shouldBe true emptyPgn.contains("[White \"A\"]") shouldBe true
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
test("exportGame renders castling grouping and result markers"): test("exportGame renders castling grouping and result markers"):
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O") PgnExporter.exportGame(
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O") Map("Event" -> "Test"),
List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)),
) should include("O-O")
PgnExporter.exportGame(
Map("Event" -> "Test"),
List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)),
) should include("O-O-O")
val seq = List( val seq = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()), Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()) Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
) )
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq) val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
grouped should include("1. e4 c5") grouped should include("1. e4 c5")
@@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
test("exportGame handles promotion suffixes and normal move formatting"): test("exportGame handles promotion suffixes and normal move formatting"):
List( List(
PromotionPiece.Queen -> "=Q", PromotionPiece.Queen -> "=Q",
PromotionPiece.Rook -> "=R", PromotionPiece.Rook -> "=R",
PromotionPiece.Bishop -> "=B", PromotionPiece.Bishop -> "=B",
PromotionPiece.Knight -> "=N" PromotionPiece.Knight -> "=N",
).foreach { (piece, suffix) => ).foreach { (piece, suffix) =>
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece)) val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix") PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
} }
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))) val normal =
PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
normal should include("e4") normal should include("e4")
normal should not include "=" normal should not include "="
test("exportGameContext preserves moves and default headers"): test("exportGameContext preserves moves and default headers"):
val moves = List( val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()) Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
) )
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves)) val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
withMoves.contains("e4") shouldBe true withMoves.contains("e4") shouldBe true
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
Move(sq("c7"), sq("c6")), Move(sq("c7"), sq("c6")),
Move(sq("d1"), sq("d7"), MoveType.Normal(true)), Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
Move(sq("d8"), sq("d7"), MoveType.Normal(true)), Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
Move(sq("e1"), sq("e2"), MoveType.Normal(true)) Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
) )
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves) val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
@@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include("Kxe2") pgn should include("Kxe2")
test("exportGame emits en-passant and promotion capture notation"): test("exportGame emits en-passant and promotion capture notation"):
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant) val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen)) val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true)) val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
val promotionQuietSetup = Move(sq("e8"), sq("e7")) val promotionQuietSetup = Move(sq("e8"), sq("e7"))
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture)) val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture)) val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet)) val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
pgn should include("exd3") pgn should include("exd3")
pgn should include("exf8=Q") pgn should include("exf8=Q")
pawnCapturePgn should include("exd3") pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q") quietPromotionPgn should include("e8=Q")
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
class PgnParserTest extends AnyFunSuite with Matchers: class PgnParserTest extends AnyFunSuite with Matchers:
test("parsePgn handles headers standard sequences captures castling and skipped tokens"): test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
val headerOnly = """[Event "Test Game"] val headerOnly = """[Event "Test Game"]
[White "Alice"] [White "Alice"]
[Black "Bob"] [Black "Bob"]
[Result "1-0"]""" [Result "1-0"]"""
@@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers:
capture.map(_.moves.length) shouldBe Some(3) capture.map(_.moves.length) shouldBe Some(3)
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5) capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
val whiteKs = PgnParser.parsePgn("""[Event "Test"] val whiteKs = PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""")
.get
.moves
.last
whiteKs.moveType shouldBe MoveType.CastleKingside whiteKs.moveType shouldBe MoveType.CastleKingside
whiteKs.from shouldBe Square(File.E, Rank.R1) whiteKs.from shouldBe Square(File.E, Rank.R1)
whiteKs.to shouldBe Square(File.G, Rank.R1) whiteKs.to shouldBe Square(File.G, Rank.R1)
val whiteQs = PgnParser.parsePgn("""[Event "Test"] val whiteQs = PgnParser
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""")
.get
.moves
.last
whiteQs.moveType shouldBe MoveType.CastleQueenside whiteQs.moveType shouldBe MoveType.CastleQueenside
whiteQs.from shouldBe Square(File.E, Rank.R1) whiteQs.from shouldBe Square(File.E, Rank.R1)
whiteQs.to shouldBe Square(File.C, Rank.R1) whiteQs.to shouldBe Square(File.C, Rank.R1)
val blackKs = PgnParser.parsePgn("""[Event "Test"] val blackKs = PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last 1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""")
.get
.moves
.last
blackKs.moveType shouldBe MoveType.CastleKingside blackKs.moveType shouldBe MoveType.CastleKingside
blackKs.from shouldBe Square(File.E, Rank.R8) blackKs.from shouldBe Square(File.E, Rank.R8)
val blackQs = PgnParser.parsePgn("""[Event "Test"] val blackQs = PgnParser
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""")
.get
.moves
.last
blackQs.moveType shouldBe MoveType.CastleQueenside blackQs.moveType shouldBe MoveType.CastleQueenside
blackQs.from shouldBe Square(File.E, Rank.R8) blackQs.from shouldBe Square(File.E, Rank.R8)
blackQs.to shouldBe Square(File.C, Rank.R8) blackQs.to shouldBe Square(File.C, Rank.R8)
PgnParser.parsePgn("""[Event "Test"] PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2) 1. e4 e5 1-0""")
PgnParser.parsePgn("""[Event "Test"] .map(_.moves.length) shouldBe Some(2)
PgnParser
.parsePgn("""[Event "Test"]
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2) 1. e4 INVALID e5""")
.map(_.moves.length) shouldBe Some(2)
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"): test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
val board = Board.initial val board = Board.initial
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4) PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3) File.E,
Rank.R4,
)
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
File.F,
Rank.R3,
)
val rookPieces: Map[Square, Piece] = Map( val rookPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
) )
val rankPieces: Map[Square, Piece] = Map( val rankPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
) )
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1) PgnParser
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1) .parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White)
.get
.from shouldBe Square(File.A, Rank.R1)
PgnParser
.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White)
.get
.from shouldBe Square(File.A, Rank.R1)
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White) val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
king.isDefined shouldBe true king.isDefined shouldBe true
king.get.from shouldBe Square(File.E, Rank.R1) king.get.from shouldBe Square(File.E, Rank.R1)
king.get.to shouldBe Square(File.E, Rank.R2) king.get.to shouldBe Square(File.E, Rank.R2)
test("parseAlgebraicMove handles all promotion targets"): test("parseAlgebraicMove handles all promotion targets"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) PgnParser
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) .parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) .get
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) .moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
PgnParser
.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
PgnParser
.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
PgnParser
.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
test("importGameContext accepts valid and empty PGN"): test("importGameContext accepts valid and empty PGN"):
val pgn = """[Event "Test"] val pgn = """[Event "Test"]
@@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
test("parseAlgebraicMove rejects notation with invalid promotion piece"): test("parseAlgebraicMove rejects notation with invalid promotion piece"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected")) val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
val context = GameContext.initial.withBoard(board) val context = GameContext.initial.withBoard(board)
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val parsed = PgnParser.parsePgn("1. e4 ??? e5") val parsed = PgnParser.parsePgn("1. e4 ??? e5")
parsed.map(_.moves.size) shouldBe Some(2) parsed.map(_.moves.size) shouldBe Some(2)
@@ -37,17 +37,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside) qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
test("validatePgn rejects impossible illegal and garbage tokens"): test("validatePgn rejects impossible illegal and garbage tokens"):
PgnParser.validatePgn("""[Event "Test"] PgnParser
.validatePgn("""[Event "Test"]
1. Qd4 1. Qd4
""").isLeft shouldBe true """).isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"] PgnParser
.validatePgn("""[Event "Test"]
1. O-O 1. O-O
""").isLeft shouldBe true """).isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"] PgnParser
.validatePgn("""[Event "Test"]
1. e4 GARBAGE e5 1. e4 GARBAGE e5
""").isLeft shouldBe true """).isLeft shouldBe true
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
test("validatePgn accepts empty move text and minimal valid header"): test("validatePgn accepts empty move text and minimal valid header"):
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty) PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true
@@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.board.Square import de.nowchess.api.board.Square
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
/** Extension point for chess rule variants (standard, Chess960, etc.). /** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
* All rule queries are stateless: given a GameContext, return the answer. * GameContext, return the answer.
*/ */
trait RuleSet: trait RuleSet:
/** All pseudo-legal moves for the piece on `square` (ignores check). */ /** All pseudo-legal moves for the piece on `square` (ignores check). */
def candidateMoves(context: GameContext)(square: Square): List[Move] def candidateMoves(context: GameContext)(square: Square): List[Move]
@@ -32,8 +32,7 @@ trait RuleSet:
/** True if halfMoveClock >= 100 (50-move rule). */ /** True if halfMoveClock >= 100 (50-move rule). */
def isFiftyMoveRule(context: GameContext): Boolean def isFiftyMoveRule(context: GameContext): Boolean
/** Apply a legal move to produce the next game context. /** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant,
* Handles all special move types: castling, en passant, promotion. * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
* Updates castling rights, en passant square, half-move clock, turn, and move history. */
*/
def applyMove(context: GameContext)(move: Move): GameContext def applyMove(context: GameContext)(move: Move): GameContext
@@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet
import scala.annotation.tailrec import scala.annotation.tailrec
/** Standard chess rules implementation. /** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
* Handles move generation, validation, check/checkmate/stalemate detection. */
*/
object DefaultRules extends RuleSet: object DefaultRules extends RuleSet:
// ── Direction vectors ────────────────────────────────────────────── // ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1)) private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1)) private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
private val KnightJumps: List[(Int, Int)] = private val KnightJumps: List[(Int, Int)] =
List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)) List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
// ── Pawn configuration helpers ───────────────────────────────────── // ── Pawn configuration helpers ─────────────────────────────────────
private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1 private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6 private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0 private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
@@ -29,13 +28,14 @@ object DefaultRules extends RuleSet:
override def candidateMoves(context: GameContext)(square: Square): List[Move] = override def candidateMoves(context: GameContext)(square: Square): List[Move] =
context.board.pieceAt(square).fold(List.empty[Move]) { piece => context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
if piece.color != context.turn then List.empty[Move] if piece.color != context.turn then List.empty[Move]
else piece.pieceType match else
case PieceType.Pawn => pawnCandidates(context, square, piece.color) piece.pieceType match
case PieceType.Knight => knightCandidates(context, square, piece.color) case PieceType.Pawn => pawnCandidates(context, square, piece.color)
case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) case PieceType.Knight => knightCandidates(context, square, piece.color)
case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs) case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs) case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
case PieceType.King => kingCandidates(context, square, piece.color) case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
case PieceType.King => kingCandidates(context, square, piece.color)
} }
override def legalMoves(context: GameContext)(square: Square): List[Move] = override def legalMoves(context: GameContext)(square: Square): List[Move] =
@@ -65,18 +65,18 @@ object DefaultRules extends RuleSet:
// ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
private def slidingMoves( private def slidingMoves(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color, color: Color,
dirs: List[(Int, Int)] dirs: List[(Int, Int)],
): List[Move] = ): List[Move] =
dirs.flatMap(dir => castRay(context.board, from, color, dir)) dirs.flatMap(dir => castRay(context.board, from, color, dir))
private def castRay( private def castRay(
board: Board, board: Board,
from: Square, from: Square,
color: Color, color: Color,
dir: (Int, Int) dir: (Int, Int),
): List[Move] = ): List[Move] =
@tailrec @tailrec
def loop(sq: Square, acc: List[Move]): List[Move] = def loop(sq: Square, acc: List[Move]): List[Move] =
@@ -84,40 +84,40 @@ object DefaultRules extends RuleSet:
case None => acc case None => acc
case Some(next) => case Some(next) =>
board.pieceAt(next) match board.pieceAt(next) match
case None => loop(next, Move(from, next) :: acc) case None => loop(next, Move(from, next) :: acc)
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
case Some(_) => acc case Some(_) => acc
loop(from, Nil).reverse loop(from, Nil).reverse
// ── Knight ───────────────────────────────────────────────────────── // ── Knight ─────────────────────────────────────────────────────────
private def knightCandidates( private def knightCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
KnightJumps.flatMap { (df, dr) => KnightJumps.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match context.board.pieceAt(to) match
case Some(p) if p.color == color => None case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to)) case None => Some(Move(from, to))
} }
} }
// ── King ─────────────────────────────────────────────────────────── // ── King ───────────────────────────────────────────────────────────
private def kingCandidates( private def kingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
val steps = QueenDirs.flatMap { (df, dr) => val steps = QueenDirs.flatMap { (df, dr) =>
from.offset(df, dr).flatMap { to => from.offset(df, dr).flatMap { to =>
context.board.pieceAt(to) match context.board.pieceAt(to) match
case Some(p) if p.color == color => None case Some(p) if p.color == color => None
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
case None => Some(Move(from, to)) case None => Some(Move(from, to))
} }
} }
steps ++ castlingCandidates(context, from, color) steps ++ castlingCandidates(context, from, color)
@@ -125,17 +125,17 @@ object DefaultRules extends RuleSet:
// ── Castling ─────────────────────────────────────────────────────── // ── Castling ───────────────────────────────────────────────────────
private case class CastlingMove( private case class CastlingMove(
kingFromAlg: String, kingFromAlg: String,
kingToAlg: String, kingToAlg: String,
middleAlg: String, middleAlg: String,
rookFromAlg: String, rookFromAlg: String,
moveType: MoveType moveType: MoveType,
) )
private def castlingCandidates( private def castlingCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
color match color match
case Color.White => whiteCastles(context, from) case Color.White => whiteCastles(context, from)
@@ -146,10 +146,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.whiteKingSide, addCastleMove(
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside)) context,
addCastleMove(context, moves, context.castlingRights.whiteQueenSide, moves,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside)) context.castlingRights.whiteKingSide,
CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.whiteQueenSide,
CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
)
moves.toList moves.toList
private def blackCastles(context: GameContext, from: Square): List[Move] = private def blackCastles(context: GameContext, from: Square): List[Move] =
@@ -157,10 +165,18 @@ object DefaultRules extends RuleSet:
if from != expected then List.empty if from != expected then List.empty
else else
val moves = scala.collection.mutable.ListBuffer[Move]() val moves = scala.collection.mutable.ListBuffer[Move]()
addCastleMove(context, moves, context.castlingRights.blackKingSide, addCastleMove(
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside)) context,
addCastleMove(context, moves, context.castlingRights.blackQueenSide, moves,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside)) context.castlingRights.blackKingSide,
CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
)
addCastleMove(
context,
moves,
context.castlingRights.blackQueenSide,
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
)
moves.toList moves.toList
private def queensideBSquare(kingToAlg: String): List[String] = private def queensideBSquare(kingToAlg: String): List[String] =
@@ -170,10 +186,10 @@ object DefaultRules extends RuleSet:
case _ => List.empty case _ => List.empty
private def addCastleMove( private def addCastleMove(
context: GameContext, context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move], moves: scala.collection.mutable.ListBuffer[Move],
castlingRight: Boolean, castlingRight: Boolean,
castlingMove: CastlingMove castlingMove: CastlingMove,
): Unit = ): Unit =
if castlingRight then if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg)) val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
@@ -185,16 +201,15 @@ object DefaultRules extends RuleSet:
kt <- Square.fromAlgebraic(castlingMove.kingToAlg) kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
rf <- Square.fromAlgebraic(castlingMove.rookFromAlg) rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
do do
val color = context.turn val color = context.turn
val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King) val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook) val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
val squaresSafe = val squaresSafe =
!isAttackedBy(context.board, kf, color.opposite) && !isAttackedBy(context.board, kf, color.opposite) &&
!isAttackedBy(context.board, km, color.opposite) && !isAttackedBy(context.board, km, color.opposite) &&
!isAttackedBy(context.board, kt, color.opposite) !isAttackedBy(context.board, kt, color.opposite)
if kingPresent && rookPresent && squaresSafe then if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
moves += Move(kf, kt, castlingMove.moveType)
private def squaresEmpty(board: Board, squares: List[Square]): Boolean = private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
squares.forall(sq => board.pieceAt(sq).isEmpty) squares.forall(sq => board.pieceAt(sq).isEmpty)
@@ -202,22 +217,26 @@ object DefaultRules extends RuleSet:
// ── Pawn ─────────────────────────────────────────────────────────── // ── Pawn ───────────────────────────────────────────────────────────
private def pawnCandidates( private def pawnCandidates(
context: GameContext, context: GameContext,
from: Square, from: Square,
color: Color color: Color,
): List[Move] = ): List[Move] =
val fwd = pawnForward(color) val fwd = pawnForward(color)
val startRank = pawnStartRank(color) val startRank = pawnStartRank(color)
val promoRank = pawnPromoRank(color) val promoRank = pawnPromoRank(color)
val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty) val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
val double = Option.when(from.rank.ordinal == startRank) { val double = Option
from.offset(0, fwd).flatMap { mid => .when(from.rank.ordinal == startRank) {
Option.when(context.board.pieceAt(mid).isEmpty) { from.offset(0, fwd).flatMap { mid =>
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) Option
}.flatten .when(context.board.pieceAt(mid).isEmpty) {
from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
}
.flatten
}
} }
}.flatten .flatten
val diagonalCaptures = List(-1, 1).flatMap { df => val diagonalCaptures = List(-1, 1).flatMap { df =>
from.offset(df, fwd).flatMap { to => from.offset(df, fwd).flatMap { to =>
@@ -236,22 +255,22 @@ object DefaultRules extends RuleSet:
def toMoves(dest: Square, isCapture: Boolean): List[Move] = def toMoves(dest: Square, isCapture: Boolean): List[Move] =
if dest.rank.ordinal == promoRank then if dest.rank.ordinal == promoRank then
List( List(
PromotionPiece.Queen, PromotionPiece.Rook, PromotionPiece.Queen,
PromotionPiece.Bishop, PromotionPiece.Knight PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight,
).map(pt => Move(from, dest, MoveType.Promotion(pt))) ).map(pt => Move(from, dest, MoveType.Promotion(pt)))
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture))) else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
val stepSquares = single.toList ++ double.toList val stepSquares = single.toList ++ double.toList
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false)) val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true)) val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
stepMoves ++ captureMoves ++ epCaptures stepMoves ++ captureMoves ++ epCaptures
// ── Check detection ──────────────────────────────────────────────── // ── Check detection ────────────────────────────────────────────────
private def kingSquare(board: Board, color: Color): Option[Square] = private def kingSquare(board: Board, color: Color): Option[Square] =
Square.all.find(sq => Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)
)
private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean = private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
Square.all.exists { sq => Square.all.exists { sq =>
@@ -266,26 +285,26 @@ object DefaultRules extends RuleSet:
case PieceType.Pawn => case PieceType.Pawn =>
from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target) from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
case PieceType.Knight => case PieceType.Knight =>
KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) } KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
case PieceType.Bishop => rayReaches(board, from, BishopDirs, target) case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
case PieceType.Rook => rayReaches(board, from, RookDirs, target) case PieceType.Rook => rayReaches(board, from, RookDirs, target)
case PieceType.Queen => rayReaches(board, from, QueenDirs, target) case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
case PieceType.King => case PieceType.King =>
QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) } QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean = private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
dirs.exists { dir => dirs.exists { dir =>
@tailrec @tailrec
def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
case None => false case None => false
case Some(next) if next == target => true case Some(next) if next == target => true
case Some(next) if board.pieceAt(next).isEmpty => loop(next) case Some(next) if board.pieceAt(next).isEmpty => loop(next)
case Some(_) => false case Some(_) => false
loop(from) loop(from)
} }
private def leavesKingInCheck(context: GameContext, move: Move): Boolean = private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
val nextBoard = context.board.applyMove(move) val nextBoard = context.board.applyMove(move)
val nextContext = context.withBoard(nextBoard) val nextContext = context.withBoard(nextBoard)
isCheck(nextContext) isCheck(nextContext)
@@ -293,7 +312,7 @@ object DefaultRules extends RuleSet:
override def applyMove(context: GameContext)(move: Move): GameContext = override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn val color = context.turn
val board = context.board val board = context.board
val newBoard = move.moveType match val newBoard = move.moveType match
case MoveType.CastleKingside => applyCastle(board, color, kingside = true) case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
@@ -302,14 +321,14 @@ object DefaultRules extends RuleSet:
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp) case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
case MoveType.Normal(_) => board.applyMove(move) case MoveType.Normal(_) => board.applyMove(move)
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
val newEnPassantSquare = computeEnPassantSquare(board, move) val newEnPassantSquare = computeEnPassantSquare(board, move)
val isCapture = move.moveType match val isCapture = move.moveType match
case MoveType.Normal(capture) => capture case MoveType.Normal(capture) => capture
case MoveType.EnPassant => true case MoveType.EnPassant => true
case _ => board.pieceAt(move.to).isDefined case _ => board.pieceAt(move.to).isDefined
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
context context
.withBoard(newBoard) .withBoard(newBoard)
@@ -322,19 +341,18 @@ object DefaultRules extends RuleSet:
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board = private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) = val (kingFrom, kingTo, rookFrom, rookTo) =
if kingside then if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
(Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
else
(Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King)) val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook)) val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
board board
.removed(kingFrom).removed(rookFrom) .removed(kingFrom)
.removed(rookFrom)
.updated(kingTo, king) .updated(kingTo, king)
.updated(rookTo, rook) .updated(rookTo, rook)
private def applyEnPassant(board: Board, move: Move): Board = private def applyEnPassant(board: Board, move: Move): Board =
val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
val capturedSquare = Square(move.to.file, capturedRank) val capturedSquare = Square(move.to.file, capturedRank)
board.applyMove(move).removed(capturedSquare) board.applyMove(move).removed(capturedSquare)
@@ -347,7 +365,7 @@ object DefaultRules extends RuleSet:
board.removed(move.from).updated(move.to, Piece(color, promotedType)) board.removed(move.from).updated(move.to, Piece(color, promotedType))
private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights = private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights =
val piece = board.pieceAt(move.from) val piece = board.pieceAt(move.from)
val isKingMove = piece.exists(_.pieceType == PieceType.King) val isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook) val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
@@ -360,14 +378,14 @@ object DefaultRules extends RuleSet:
var r = rights var r = rights
if isKingMove then r = r.revokeColor(color) if isKingMove then r = r.revokeColor(color)
else if isRookMove then else if isRookMove then
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White) if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black) if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
// Also revoke if a rook is captured // Also revoke if a rook is captured
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White) if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black) if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
r r
@@ -386,9 +404,10 @@ object DefaultRules extends RuleSet:
private def insufficientMaterial(board: Board): Boolean = private def insufficientMaterial(board: Board): Boolean =
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
pieces match pieces match
case Nil => true case Nil => true
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case List(p1, p2) case List(p1, p2)
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
&& p1.color != p2.color => true && p1.color != p2.color =>
true
case _ => false case _ => false
@@ -65,7 +65,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove clears en passant square for non double pawn push"): test("applyMove clears en passant square for non double pawn push"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1") val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
val move = Move(sq("e2"), sq("e3")) val move = Move(sq("e2"), sq("e3"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -73,7 +73,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove resets halfMoveClock on pawn move"): test("applyMove resets halfMoveClock on pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1") val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
val move = Move(sq("e2"), sq("e4")) val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -81,7 +81,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove increments halfMoveClock on quiet non pawn move"): test("applyMove increments halfMoveClock on quiet non pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1") val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
val move = Move(sq("g1"), sq("f3")) val move = Move(sq("g1"), sq("f3"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -89,7 +89,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove resets halfMoveClock on capture"): test("applyMove resets halfMoveClock on capture"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1") val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -98,7 +98,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove updates castling rights after king move"): test("applyMove updates castling rights after king move"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("e2")) val move = Move(sq("e1"), sq("e2"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -109,7 +109,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove updates castling rights after rook move from h1"): test("applyMove updates castling rights after rook move from h1"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
val move = Move(sq("h1"), sq("h2")) val move = Move(sq("h1"), sq("h2"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -118,7 +118,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes opponent castling right when rook on starting square is captured"): test("applyMove revokes opponent castling right when rook on starting square is captured"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1") val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -126,7 +126,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes kingside castling and repositions king and rook"): test("applyMove executes kingside castling and repositions king and rook"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1") val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -137,7 +137,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes queenside castling and repositions king and rook"): test("applyMove executes queenside castling and repositions king and rook"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1") val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside) val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -148,7 +148,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes en passant and removes captured pawn"): test("applyMove executes en passant and removes captured pawn"):
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1") val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant) val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -158,7 +158,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes promotion with selected piece type"): test("applyMove executes promotion with selected piece type"):
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight)) val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -179,7 +179,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove preserves black castling rights after white kingside castling"): test("applyMove preserves black castling rights after white kingside castling"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove can revoke both white castling rights when both rooks are captured"): test("applyMove can revoke both white castling rights when both rooks are captured"):
val context = GameContext( val context = GameContext(
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)), board =
contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
turn = Color.Black, turn = Color.Black,
castlingRights = CastlingRights(true, true, false, false), castlingRights = CastlingRights(true, true, false, false),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty moves = List.empty,
) )
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true))) val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true))) val afterH1Capture =
DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
afterH1Capture.castlingRights.whiteKingSide shouldBe false afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false afterH1Capture.castlingRights.whiteQueenSide shouldBe false
@@ -233,7 +235,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove executes black kingside castling and repositions pieces on rank 8"): test("applyMove executes black kingside castling and repositions pieces on rank 8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside) val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -244,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black castling rights when black rook moves from h8"): test("applyMove revokes black castling rights when black rook moves from h8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("h8"), sq("h7")) val move = Move(sq("h8"), sq("h7"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -253,7 +255,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black queenside castling right when black rook moves from a8"): test("applyMove revokes black queenside castling right when black rook moves from a8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("a8"), sq("a7")) val move = Move(sq("a8"), sq("a7"))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -262,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove revokes black kingside castling right when rook on h8 is captured"): test("applyMove revokes black kingside castling right when rook on h8 is captured"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1") val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true)) val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move) val next = DefaultRules.applyMove(context)(move)
@@ -270,31 +272,25 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("candidateMoves creates all promotion move variants for black pawn"): test("candidateMoves creates all promotion move variants for black pawn"):
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1") val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
val to = sq("a1") val to = sq("a1")
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2")) val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece } val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
promotions.toSet shouldBe Set( promotions.toSet shouldBe Set(
PromotionPiece.Queen, PromotionPiece.Queen,
PromotionPiece.Rook, PromotionPiece.Rook,
PromotionPiece.Bishop, PromotionPiece.Bishop,
PromotionPiece.Knight PromotionPiece.Knight,
) )
test("applyMove promotion supports queen rook and bishop targets"): test("applyMove promotion supports queen rook and bishop targets"):
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen))) val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook))) val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop))) val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen)) queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook)) rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop)) bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
@@ -1,6 +1,6 @@
package de.nowchess.rule package de.nowchess.rule
import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights} import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType} import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
@@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// ── Pawn moves ────────────────────────────────────────────────── // ── Pawn moves ──────────────────────────────────────────────────
test("pawn can move forward one square"): test("pawn can move forward one square"):
val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1" val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2)) val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2))
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true
test("pawn can move forward two squares from starting position"): test("pawn can move forward two squares from starting position"):
val context = GameContext.initial val context = GameContext.initial
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2)) val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2))
e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true
test("pawn can capture diagonally"): test("pawn can capture diagonally"):
// FEN: white pawn e4, black pawn d5 // FEN: white pawn e4, black pawn d5
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal]) val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
test("pawn cannot move backward"): test("pawn cannot move backward"):
// FEN: white pawn on e4 // FEN: white pawn on e4
val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1" val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4)) val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4))
pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false
@@ -47,18 +47,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("moving king out of check removes it from legal moves if king stays in check"): test("moving king out of check removes it from legal moves if king stays in check"):
// FEN: white king e1, black rook e8, white tries to move away // FEN: white king e1, black rook e8, white tries to move away
val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1" val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file // King must move; e2 should be valid but d1 might be blocked by rook if still on same file
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
test("king cannot move to square attacked by opponent"): test("king cannot move to square attacked by opponent"):
// FEN: white king e1, black rook e2 defended by black king e3 // FEN: white king e1, black rook e2 defended by black king e3
val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1" val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
// King cannot move to e2 (occupied and attacked) // King cannot move to e2 (occupied and attacked)
val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2)) val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2))
@@ -67,64 +67,67 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
// ── Castling legality ──────────────────────────────────────────── // ── Castling legality ────────────────────────────────────────────
test("castling kingside is legal when king and rook unmoved and path clear"): test("castling kingside is legal when king and rook unmoved and path clear"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1" val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.nonEmpty shouldBe true castles.nonEmpty shouldBe true
test("castling queenside is legal when king and rook unmoved and path clear"): test("castling queenside is legal when king and rook unmoved and path clear"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1" val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside) val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
castles.nonEmpty shouldBe true castles.nonEmpty shouldBe true
test("castling is illegal when castling rights are false"): test("castling is illegal when castling rights are false"):
// FEN: king and rook in position, but castling rights disabled // FEN: king and rook in position, but castling rights disabled
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1" val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.isEmpty shouldBe true castles.isEmpty shouldBe true
test("castling is illegal when king is in check"): test("castling is illegal when king is in check"):
// FEN: white king e1 in check from black rook e8 // FEN: white king e1 in check from black rook e8
val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1" val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside)
castles.isEmpty shouldBe true castles.isEmpty shouldBe true
test("castling is illegal when path has piece in the way"): test("castling is illegal when path has piece in the way"):
// FEN: white king e1, white rook h1, white bishop f1 (blocks f-file) // FEN: white king e1, white rook h1, white bishop f1 (blocks f-file)
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1" val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.isEmpty shouldBe true castles.isEmpty shouldBe true
test("castling queenside is illegal when knight blocks on b8"): test("castling queenside is illegal when knight blocks on b8"):
// Black king e8, black rook a8, black knight b8 (blocks queenside path) // Black king e8, black rook a8, black knight b8 (blocks queenside path)
val board = Board(Map( val board = Board(
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook), Map(
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight), Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King) Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
)) Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
),
)
val context = GameContext( val context = GameContext(
board = board, board = board,
turn = Color.Black, turn = Color.Black,
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true), castlingRights =
CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty moves = List.empty,
) )
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
@@ -135,18 +138,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("en passant is legal when en passant square is set"): test("en passant is legal when en passant square is set"):
// FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6 // FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6
val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1" val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant) val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true
test("en passant is illegal when en passant square is none"): test("en passant is illegal when en passant square is none"):
// FEN: white pawn e5, black pawn d5, but no en passant square // FEN: white pawn e5, black pawn d5, but no en passant square
val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1" val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant) val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant)
epMoves.isEmpty shouldBe true epMoves.isEmpty shouldBe true
@@ -155,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("pinned piece cannot move and expose king to check"): test("pinned piece cannot move and expose king to check"):
// FEN: white king e1, white bishop d2 (pinned), black rook a2 // FEN: white king e1, white bishop d2 (pinned), black rook a2
val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1" val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
// Bishop on d2 is pinned by rook on a2; it cannot move // Bishop on d2 is pinned by rook on a2; it cannot move
val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2)) val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2))
@@ -166,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
test("piece blocking a check is legal"): test("piece blocking a check is legal"):
// FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2 // FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2
// Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2 // Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2
val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1" val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity) val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context) val moves = rules.allLegalMoves(context)
// White is in check; only moves that block or move the king are legal // White is in check; only moves that block or move the king are legal
moves.nonEmpty shouldBe true moves.nonEmpty shouldBe true
@@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher import de.nowchess.ui.gui.ChessGUILauncher
/** Application entry point - starts both GUI and Terminal UI for the chess game. /** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
* Both views subscribe to the same GameEngine via Observer pattern. * GameEngine via Observer pattern.
*/ */
object Main: object Main:
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
// Create the core game engine (single source of truth) // Create the core game engine (single source of truth)
@@ -18,4 +18,3 @@ object Main:
// Create and start the terminal UI (blocks on main thread) // Create and start the terminal UI (blocks on main thread)
val tui = new TerminalUI(engine) val tui = new TerminalUI(engine)
tui.start() tui.start()
@@ -18,32 +18,31 @@ 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.{GameContextExport, GameContextImport} import de.nowchess.io.{GameContextExport, GameContextImport}
/** ScalaFX chess board view that displays the game state. /** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
* Uses chess sprites and color palette. * interactions (clicks) and sends moves to GameEngine.
* Handles user interactions (clicks) and sends moves to GameEngine. */
*/
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane: class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
private val squareSize = 70.0 private val squareSize = 70.0
private val comicSansFontFamily = "Comic Sans MS" private val comicSansFontFamily = "Comic Sans MS"
private val boardGrid = new GridPane() private val boardGrid = new GridPane()
private val messageLabel = new Label { private val messageLabel = new Label {
text = "Welcome!" text = "Welcome!"
font = Font.font(comicSansFontFamily, 16) font = Font.font(comicSansFontFamily, 16)
padding = Insets(10) padding = Insets(10)
} }
private var currentBoard: Board = engine.board private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn private var currentTurn: Color = engine.turn
private var selectedSquare: Option[Square] = None private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized private var redoButton: Button = uninitialized
// Initialize UI // Initialize UI
initializeBoard() initializeBoard()
top = new VBox { top = new VBox {
padding = Insets(10) padding = Insets(10)
spacing = 5 spacing = 5
@@ -54,17 +53,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 24) font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;" style = "-fx-font-weight: bold;"
}, },
messageLabel messageLabel,
) )
} }
center = new VBox { center = new VBox {
padding = Insets(20) padding = Insets(20)
alignment = Pos.Center alignment = Pos.Center
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};" style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
children = boardGrid children = boardGrid
} }
bottom = new VBox { bottom = new VBox {
padding = Insets(10) padding = Insets(10)
spacing = 8 spacing = 8
@@ -82,8 +81,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()
@@ -96,7 +94,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 {
@@ -122,17 +120,17 @@ 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;"
} },
) )
} },
) )
} }
private def initializeBoard(): Unit = private def initializeBoard(): Unit =
boardGrid.padding = Insets(5) boardGrid.padding = Insets(5)
boardGrid.hgap = 0 boardGrid.hgap = 0
boardGrid.vgap = 0 boardGrid.vgap = 0
// Create 8x8 board with rank/file labels // Create 8x8 board with rank/file labels
for for
rank <- 0 until 8 rank <- 0 until 8
@@ -141,13 +139,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val square = createSquare(rank, file) val square = createSquare(rank, file)
squareViews((rank, file)) = square squareViews((rank, file)) = square
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
updateBoard(currentBoard, currentTurn) updateBoard(currentBoard, currentTurn)
private def createSquare(rank: Int, file: Int): StackPane = private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0 val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle { val bgRect = new Rectangle {
width = squareSize width = squareSize
height = squareSize height = squareSize
@@ -155,21 +153,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8 arcWidth = 8
arcHeight = 8 arcHeight = 8
} }
val square = new StackPane { val square = new StackPane {
children = Seq(bgRect) children = Seq(bgRect)
onMouseClicked = _ => handleSquareClick(rank, file) onMouseClicked = _ => handleSquareClick(rank, file)
style = "-fx-cursor: hand;" style = "-fx-cursor: hand;"
} }
square square
private def handleSquareClick(rank: Int, file: Int): Unit = private def handleSquareClick(rank: Int, file: Int): Unit =
if engine.isPendingPromotion then if engine.isPendingPromotion then return // Don't allow moves during promotion
return // Don't allow moves during promotion
val clickedSquare = Square(File.values(file), Rank.values(rank)) val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare match selectedSquare match
case None => case None =>
// First click - select piece if it belongs to current player // First click - select piece if it belongs to current player
@@ -178,13 +175,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
selectedSquare = Some(clickedSquare) selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected) highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare) val legalDests = engine.ruleSet
.collect { case move if move.from == clickedSquare => move.to } .legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq => legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
} }
} }
case Some(fromSquare) => case Some(fromSquare) =>
// Second click - attempt move // Second click - attempt move
if clickedSquare == fromSquare then if clickedSquare == fromSquare then
@@ -193,24 +191,24 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateBoard(currentBoard, currentTurn) updateBoard(currentBoard, currentTurn)
else else
// Try to move // Try to move
val moveStr = s"${fromSquare}$clickedSquare" val moveStr = s"$fromSquare$clickedSquare"
engine.processUserInput(moveStr) engine.processUserInput(moveStr)
selectedSquare = None selectedSquare = None
def updateBoard(board: Board, turn: Color): Unit = def updateBoard(board: Board, turn: Color): Unit =
currentBoard = board currentBoard = board
currentTurn = turn currentTurn = turn
selectedSquare = None selectedSquare = None
// Update all squares // Update all squares
for for
rank <- 0 until 8 rank <- 0 until 8
file <- 0 until 8 file <- 0 until 8
do do
squareViews.get((rank, file)).foreach { stackPane => squareViews.get((rank, file)).foreach { stackPane =>
val isWhite = (rank + file) % 2 == 0 val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle { val bgRect = new Rectangle {
width = squareSize width = squareSize
height = squareSize height = squareSize
@@ -218,16 +216,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8 arcWidth = 8
arcHeight = 8 arcHeight = 8
} }
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square) val pieceOption = board.pieceAt(square)
val children = pieceOption match val children = pieceOption match
case Some(piece) => case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8)) Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None => case None =>
Seq(bgRect) Seq(bgRect)
stackPane.children = children stackPane.children = children
} }
@@ -246,20 +244,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcWidth = 8 arcWidth = 8
arcHeight = 8 arcHeight = 8
} }
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.pieceAt(square) val pieceOption = currentBoard.pieceAt(square)
stackPane.children = pieceOption match stackPane.children = pieceOption match
case Some(piece) => case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8)) Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
case None => case None =>
Seq(bgRect) Seq(bgRect)
} }
def showMessage(msg: String): Unit = def showMessage(msg: String): Unit =
messageLabel.text = msg messageLabel.text = msg
def showPromotionDialog(from: Square, to: Square): Unit = def showPromotionDialog(from: Square, to: Square): Unit =
val choices = Seq("Queen", "Rook", "Bishop", "Knight") val choices = Seq("Queen", "Rook", "Bishop", "Knight")
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) { val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
@@ -268,14 +266,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
headerText = "Choose promotion piece" headerText = "Choose promotion piece"
contentText = "Promote to:" contentText = "Promote to:"
} }
val result = dialog.showAndWait() val result = dialog.showAndWait()
result match result match
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen) case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook) case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop) case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight) case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
case _ => engine.completePromotion(PromotionPiece.Queen) // Default case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit = private def doFenExport(): Unit =
doExport(FenExporter, "FEN") doExport(FenExporter, "FEN")
@@ -294,7 +292,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) =>
@@ -303,7 +301,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)
@@ -327,7 +324,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
@@ -335,4 +332,3 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate) dialog.initOwner(stage.delegate)
val result = dialog.showAndWait() val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
@@ -1,63 +1,58 @@
package de.nowchess.ui.gui package de.nowchess.ui.gui
import javafx.application.{Application => JFXApplication, Platform => JFXPlatform} import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
import javafx.stage.Stage as JFXStage import javafx.stage.Stage as JFXStage
import scalafx.application.Platform import scalafx.application.Platform
import scalafx.scene.Scene import scalafx.scene.Scene
import scalafx.stage.Stage import scalafx.stage.Stage
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
/** ScalaFX GUI Application for Chess. /** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
* This is launched from Main alongside the TUI. * GameEngine via Observer pattern.
* Both subscribe to the same GameEngine via Observer pattern. */
*/
class ChessGUIApp extends JFXApplication: class ChessGUIApp extends JFXApplication:
override def start(primaryStage: JFXStage): Unit = override def start(primaryStage: JFXStage): Unit =
val engine = ChessGUILauncher.getEngine val engine = ChessGUILauncher.getEngine
val stage = new Stage(primaryStage) val stage = new Stage(primaryStage)
stage.title = "Chess" stage.title = "Chess"
stage.width = 700 stage.width = 700
stage.height = 1000 stage.height = 1000
stage.resizable = false stage.resizable = false
val boardView = new ChessBoardView(stage, engine) val boardView = new ChessBoardView(stage, engine)
val guiObserver = new GUIObserver(boardView) val guiObserver = new GUIObserver(boardView)
// Subscribe GUI observer to engine // Subscribe GUI observer to engine
engine.subscribe(guiObserver) engine.subscribe(guiObserver)
stage.scene = new Scene { stage.scene = new Scene {
root = boardView root = boardView
// Load CSS if available // Load CSS if available
try { try {
val cssUrl = getClass.getResource("/styles.css") val cssUrl = getClass.getResource("/styles.css")
if cssUrl != null then if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
stylesheets.add(cssUrl.toExternalForm)
} catch { } catch {
case _: Exception => // CSS is optional case _: Exception => // CSS is optional
} }
} }
stage.onCloseRequest = _ => { stage.onCloseRequest = _ =>
// Unsubscribe when window closes // Unsubscribe when window closes
engine.unsubscribe(guiObserver) engine.unsubscribe(guiObserver)
}
stage.show() stage.show()
/** Launcher object that holds the engine reference and launches GUI in separate thread. */ /** Launcher object that holds the engine reference and launches GUI in separate thread. */
object ChessGUILauncher: object ChessGUILauncher:
@volatile private var engine: GameEngine = scala.compiletime.uninitialized @volatile private var engine: GameEngine = scala.compiletime.uninitialized
def getEngine: GameEngine = engine def getEngine: GameEngine = engine
def launch(eng: GameEngine): Unit = def launch(eng: GameEngine): Unit =
engine = eng engine = eng
val guiThread = new Thread(() => { val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
JFXApplication.launch(classOf[ChessGUIApp])
})
guiThread.setDaemon(false) guiThread.setDaemon(false)
guiThread.setName("ScalaFX-GUI-Thread") guiThread.setName("ScalaFX-GUI-Thread")
guiThread.start() guiThread.start()
@@ -3,13 +3,12 @@ package de.nowchess.ui.gui
import scalafx.application.Platform import scalafx.application.Platform
import scalafx.scene.control.Alert import scalafx.scene.control.Alert
import scalafx.scene.control.Alert.AlertType import scalafx.scene.control.Alert.AlertType
import de.nowchess.chess.observer.{Observer, GameEvent, *} import de.nowchess.chess.observer.{GameEvent, Observer, *}
import de.nowchess.api.board.Board import de.nowchess.api.board.Board
/** GUI Observer that implements the Observer pattern. /** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
* Receives game events from GameEngine and updates the ScalaFX UI. * All UI updates must be done on the JavaFX Application Thread.
* All UI updates must be done on the JavaFX Application Thread. */
*/
class GUIObserver(private val boardView: ChessBoardView) extends Observer: class GUIObserver(private val boardView: ChessBoardView) extends Observer:
override def onGameEvent(event: GameEvent): Unit = override def onGameEvent(event: GameEvent): Unit =
@@ -60,8 +59,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn) boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}") boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
else else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons() boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent => case e: PgnLoadedEvent =>
@@ -1,38 +1,36 @@
package de.nowchess.ui.gui package de.nowchess.ui.gui
import scalafx.scene.image.{Image, ImageView} import scalafx.scene.image.{Image, ImageView}
import de.nowchess.api.board.{Piece, PieceType, Color} import de.nowchess.api.board.{Color, Piece, PieceType}
/** Utility object for loading chess piece sprites. */ /** Utility object for loading chess piece sprites. */
object PieceSprites: object PieceSprites:
private val spriteCache = scala.collection.mutable.Map[String, Image]() private val spriteCache = scala.collection.mutable.Map[String, Image]()
/** Load a piece sprite image from resources. /** Load a piece sprite image from resources. Sprites are cached for performance.
* Sprites are cached for performance. */
*/
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView = def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
val image = spriteCache.getOrElseUpdate(key, loadImage(key)) val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) { new ImageView(image) {
fitWidth = size fitWidth = size
fitHeight = size fitHeight = size
preserveRatio = true preserveRatio = true
smooth = true smooth = true
} }
private def loadImage(key: String): Image = private def loadImage(key: String): Image =
val path = s"/sprites/pieces/$key.png" val path = s"/sprites/pieces/$key.png"
val stream = getClass.getResourceAsStream(path) val stream = getClass.getResourceAsStream(path)
if stream == null then if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
throw new RuntimeException(s"Could not load sprite: $path")
new Image(stream) new Image(stream)
/** Get square colors for the board using theme. */ /** Get square colors for the board using theme. */
object SquareColors: object SquareColors:
val White = "#F3C8A0" // Warm light beige val White = "#F3C8A0" // Warm light beige
val Black = "#BA6D4B" // Warm terracotta val Black = "#BA6D4B" // Warm terracotta
val Selected = "#C19EF5" // Purple highlight val Selected = "#C19EF5" // Purple highlight
val ValidMove = "#E1EAA9" // Light yellow-green val ValidMove = "#E1EAA9" // Light yellow-green
val Border = "#5A2C28" // Dark brown border val Border = "#5A2C28" // Dark brown border
@@ -6,12 +6,11 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern. /** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
* Subscribes to GameEngine and receives state change events. * I/O and user interaction in the terminal.
* Handles all I/O and user interaction in the terminal. */
*/
class TerminalUI(engine: GameEngine) extends Observer: class TerminalUI(engine: GameEngine) extends Observer:
private var running = true private var running = true
private var awaitingPromotion = false private var awaitingPromotion = false
/** Called by GameEngine whenever a game event occurs. */ /** Called by GameEngine whenever a game event occurs. */
@@ -5,24 +5,26 @@ import de.nowchess.api.board.*
object Renderer: object Renderer:
private val AnsiReset = "\u001b[0m" private val AnsiReset = "\u001b[0m"
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String = def render(board: Board): String =
val rows = (0 until 8).reverse.map { rank => val rows = (0 until 8).reverse
val cells = (0 until 8).map { file => .map { rank =>
val sq = Square(File.values(file), Rank.values(rank)) val cells = (0 until 8).map { file =>
val isLightSq = (file + rank) % 2 != 0 val sq = Square(File.values(file), Rank.values(rank))
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare val isLightSq = (file + rank) % 2 != 0
board.pieceAt(sq) match val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
case Some(piece) => board.pieceAt(sq) match
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece case Some(piece) =>
s"$bgColor$fgColor ${piece.unicode} $AnsiReset" val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
case None => s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
s"$bgColor $AnsiReset" case None =>
}.mkString s"$bgColor $AnsiReset"
s"${rank + 1} $cells ${rank + 1}" }.mkString
}.mkString("\n") s"${rank + 1} $cells ${rank + 1}"
}
.mkString("\n")
s" a b c d e f g h\n$rows\n a b c d e f g h\n" s" a b c d e f g h\n$rows\n a b c d e f g h\n"
@@ -19,16 +19,16 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
(Piece(Color.Black, PieceType.Rook), "\u265C"), (Piece(Color.Black, PieceType.Rook), "\u265C"),
(Piece(Color.Black, PieceType.Bishop), "\u265D"), (Piece(Color.Black, PieceType.Bishop), "\u265D"),
(Piece(Color.Black, PieceType.Knight), "\u265E"), (Piece(Color.Black, PieceType.Knight), "\u265E"),
(Piece(Color.Black, PieceType.Pawn), "\u265F") (Piece(Color.Black, PieceType.Pawn), "\u265F"),
) )
pieces.foreach { (piece, expected) => pieces.foreach { (piece, expected) =>
piece.unicode shouldBe expected piece.unicode shouldBe expected
} }
test("render outputs coordinates ranks ansi escapes and piece glyphs"): test("render outputs coordinates ranks ansi escapes and piece glyphs"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen))) val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val rendered = Renderer.render(Board(Map.empty)) val rendered = Renderer.render(Board(Map.empty))
val lines = rendered.trim.split("\\n").toList.map(_.trim) val lines = rendered.trim.split("\\n").toList.map(_.trim)
lines.head shouldBe "a b c d e f g h" lines.head shouldBe "a b c d e f g h"
lines.last shouldBe "a b c d e f g h" lines.last shouldBe "a b c d e f g h"
@@ -38,9 +38,7 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
Renderer.render(board) should include("\u001b[") Renderer.render(board) should include("\u001b[")
test("render applies black piece color for black pieces"): test("render applies black piece color for black pieces"):
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King))) val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val rendered = Renderer.render(board) val rendered = Renderer.render(board)
rendered should include("\u265A") // Black king unicode rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color rendered should include("\u001b[30m") // ANSI black text color