feat: NCS-25 Add linters to keep quality up #27

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