From fd4e67d4f782a7e955822d90cb909d0a81676fb2 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 12 Apr 2026 20:58:39 +0200 Subject: [PATCH] feat: NCS-25 Add linters to keep quality up (#27) Reviewed-on: https://git.janis-eccarius.de/NowChess/NowChessSystems/pulls/27 Reviewed-by: Leon Hermann Co-authored-by: Janis Co-committed-by: Janis --- .gitattributes | 41 ++++ .scalafix.conf | 15 ++ .scalafmt.conf | 8 + ARABIAN CHESS/license.txt | 12 +- CLAUDE.md | 5 + build.gradle.kts | 26 +++ .../scala/de/nowchess/api/board/Board.scala | 18 +- .../nowchess/api/board/CastlingRights.scala | 58 +++-- .../scala/de/nowchess/api/board/Piece.scala | 16 +- .../scala/de/nowchess/api/board/Square.scala | 42 ++-- .../de/nowchess/api/game/GameContext.scala | 21 +- .../scala/de/nowchess/api/move/Move.scala | 26 ++- .../de/nowchess/api/player/PlayerInfo.scala | 35 ++- .../nowchess/api/response/ApiResponse.scala | 79 ++++--- .../de/nowchess/api/board/BoardTest.scala | 34 ++- .../api/board/CastlingRightsTest.scala | 3 +- .../de/nowchess/api/board/ColorTest.scala | 2 +- .../de/nowchess/api/board/PieceTest.scala | 18 +- .../de/nowchess/api/board/PieceTypeTest.scala | 8 +- .../de/nowchess/api/board/SquareTest.scala | 3 +- .../nowchess/api/game/GameContextTest.scala | 15 +- .../scala/de/nowchess/api/move/MoveTest.scala | 2 +- .../nowchess/api/player/PlayerInfoTest.scala | 8 +- .../api/response/ApiResponseTest.scala | 6 +- .../de/nowchess/chess/command/Command.scala | 33 ++- .../chess/command/CommandInvoker.scala | 24 +- .../de/nowchess/chess/controller/Parser.scala | 32 +-- .../de/nowchess/chess/engine/GameEngine.scala | 169 +++++++------- .../de/nowchess/chess/observer/Observer.scala | 53 +++-- .../command/CommandInvokerBranchTest.scala | 49 ++-- .../chess/command/CommandInvokerTest.scala | 16 +- .../chess/command/MoveCommandTest.scala | 12 +- .../chess/engine/EngineTestHelpers.scala | 2 +- .../engine/GameEngineGameEndingTest.scala | 67 +++--- .../engine/GameEngineIntegrationTest.scala | 39 ++-- .../chess/engine/GameEngineLoadGameTest.scala | 6 +- .../chess/engine/GameEngineNotationTest.scala | 30 +-- .../chess/engine/GameEngineOutcomesTest.scala | 74 +++--- .../engine/GameEnginePromotionTest.scala | 82 +++---- .../chess/engine/GameEngineScenarioTest.scala | 16 +- .../engine/GameEngineSpecialMovesTest.scala | 20 +- .../de/nowchess/io/GameFileService.scala | 11 +- .../de/nowchess/io/fen/FenExporter.scala | 39 ++-- .../scala/de/nowchess/io/fen/FenParser.scala | 79 +++---- .../io/fen/FenParserCombinators.scala | 64 ++--- .../nowchess/io/fen/FenParserFastParse.scala | 46 ++-- .../de/nowchess/io/fen/FenParserSupport.scala | 27 +-- .../de/nowchess/io/json/JsonExporter.scala | 52 ++--- .../scala/de/nowchess/io/json/JsonModel.scala | 60 ++--- .../de/nowchess/io/json/JsonParser.scala | 68 +++--- .../de/nowchess/io/pgn/PgnExporter.scala | 51 ++-- .../scala/de/nowchess/io/pgn/PgnParser.scala | 122 +++++----- .../de/nowchess/io/GameFileServiceSuite.scala | 57 ++--- .../de/nowchess/io/fen/FenExporterTest.scala | 35 +-- .../io/fen/FenParserCombinatorsTest.scala | 54 +++-- .../io/fen/FenParserFastParseTest.scala | 42 ++-- .../de/nowchess/io/fen/FenParserTest.scala | 43 ++-- .../JsonExporterBranchCoverageSuite.scala | 44 ++-- .../nowchess/io/json/JsonExporterSuite.scala | 60 ++--- .../io/json/JsonModelExtraTestSuite.scala | 4 +- .../io/json/JsonParserEdgeCasesSuite.scala | 25 +- .../json/JsonParserErrorHandlingSuite.scala | 10 +- .../io/json/JsonParserMoveTypeSuite.scala | 18 +- .../de/nowchess/io/json/JsonParserSuite.scala | 91 ++++---- .../de/nowchess/io/pgn/PgnExporterTest.scala | 40 ++-- .../de/nowchess/io/pgn/PgnParserTest.scala | 95 +++++--- .../de/nowchess/io/pgn/PgnValidatorTest.scala | 10 +- .../scala/de/nowchess/rules/RuleSet.scala | 13 +- .../de/nowchess/rules/sets/DefaultRules.scala | 219 ++++++++++-------- .../DefaultRulesStateTransitionsTest.scala | 56 +++-- .../de/nowchess/rule/DefaultRulesTest.scala | 87 +++---- .../src/main/scala/de/nowchess/ui/Main.scala | 7 +- .../de/nowchess/ui/gui/ChessBoardView.scala | 128 +++++----- .../scala/de/nowchess/ui/gui/ChessGUI.scala | 41 ++-- .../de/nowchess/ui/gui/GUIObserver.scala | 12 +- .../de/nowchess/ui/gui/PieceSprites.scala | 32 ++- .../de/nowchess/ui/terminal/TerminalUI.scala | 9 +- .../scala/de/nowchess/ui/utils/Renderer.scala | 38 +-- .../ui/utils/RendererAndUnicodeTest.scala | 14 +- 79 files changed, 1671 insertions(+), 1457 deletions(-) create mode 100644 .gitattributes create mode 100644 .scalafix.conf create mode 100644 .scalafmt.conf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..81cb91a --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..0601181 --- /dev/null +++ b/.scalafix.conf @@ -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 diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..3329944 --- /dev/null +++ b/.scalafmt.conf @@ -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 \ No newline at end of file diff --git a/ARABIAN CHESS/license.txt b/ARABIAN CHESS/license.txt index b55a749..f1ef68d 100644 --- a/ARABIAN CHESS/license.txt +++ b/ARABIAN CHESS/license.txt @@ -1,7 +1,7 @@ -YOU CAN: -- Edit and use the asset in any commercial or non commercial project -- Use the asset in any commercial or non commercial project - -YOU CAN'T: -- Resell or distribute the asset to others +YOU CAN: +- Edit and use the asset in any commercial or non commercial project +- Use the asset in any commercial or non commercial project + +YOU CAN'T: +- Resell or distribute the asset to others - Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c2f8ac7..7760345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/build.gradle.kts b/build.gradle.kts index b15d464..7837350 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { + scala { + scalafmt().configFile(rootProject.file(".scalafmt.conf")) + } + } + + apply(plugin = "io.github.cosmicsilence.scalafix") + configure { + 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 + } + } +} + diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Board.scala b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala index ec1f27e..992cb71 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Board.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Board.scala @@ -7,11 +7,11 @@ object Board: def apply(pieces: Map[Square, Piece]): Board = pieces extension (b: Board) - def pieceAt(sq: Square): Option[Piece] = b.get(sq) + def pieceAt(sq: Square): Option[Piece] = b.get(sq) def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece) - def removed(sq: Square): Board = b.removed(sq) + def removed(sq: Square): Board = b.removed(sq) def withMove(from: Square, to: Square): (Board, Option[Piece]) = - val captured = b.get(to) + val captured = b.get(to) val updatedBoard = b.removed(from).updated(to, b(from)) (updatedBoard, captured) def applyMove(move: de.nowchess.api.move.Move): Board = @@ -21,8 +21,14 @@ object Board: val initial: Board = val backRank: Vector[PieceType] = Vector( - PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, - PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + PieceType.Rook, + PieceType.Knight, + PieceType.Bishop, + PieceType.Queen, + PieceType.King, + PieceType.Bishop, + PieceType.Knight, + PieceType.Rook, ) val entries = for fileIdx <- 0 until 8 @@ -30,7 +36,7 @@ object Board: (Color.White, Rank.R1, backRank(fileIdx)), (Color.White, Rank.R2, PieceType.Pawn), (Color.Black, Rank.R8, backRank(fileIdx)), - (Color.Black, Rank.R7, PieceType.Pawn) + (Color.Black, Rank.R7, PieceType.Pawn), ) yield Square(File.values(fileIdx), rank) -> Piece(color, pieceType) Board(entries.toMap) diff --git a/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala b/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala index ec3baec..698c00b 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala @@ -1,50 +1,48 @@ package de.nowchess.api.board -/** - * Unified castling rights tracker for all four sides. - * Tracks whether castling is still available for each side and direction. - * - * @param whiteKingSide White's king-side castling (0-0) still legally available - * @param whiteQueenSide White's queen-side castling (0-0-0) still legally available - * @param blackKingSide Black's king-side castling (0-0) still legally available - * @param blackQueenSide Black's queen-side castling (0-0-0) still legally available - */ +/** Unified castling rights tracker for all four sides. Tracks whether castling is still available for each side and + * direction. + * + * @param whiteKingSide + * White's king-side castling (0-0) still legally available + * @param whiteQueenSide + * White's queen-side castling (0-0-0) still legally available + * @param blackKingSide + * Black's king-side castling (0-0) still legally available + * @param blackQueenSide + * Black's queen-side castling (0-0-0) still legally available + */ final case class CastlingRights( - whiteKingSide: Boolean, - whiteQueenSide: Boolean, - blackKingSide: Boolean, - blackQueenSide: Boolean + whiteKingSide: Boolean, + whiteQueenSide: Boolean, + blackKingSide: Boolean, + blackQueenSide: Boolean, ): - /** - * Check if either side has any castling rights remaining. - */ + /** Check if either side has any castling rights remaining. + */ def hasAnyRights: Boolean = whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide - /** - * Check if a specific color has any castling rights remaining. - */ + /** Check if a specific color has any castling rights remaining. + */ def hasRights(color: Color): Boolean = color match case Color.White => whiteKingSide || whiteQueenSide case Color.Black => blackKingSide || blackQueenSide - /** - * Revoke all castling rights for a specific color. - */ + /** Revoke all castling rights for a specific color. + */ def revokeColor(color: Color): CastlingRights = color match case Color.White => copy(whiteKingSide = false, whiteQueenSide = false) case Color.Black => copy(blackKingSide = false, blackQueenSide = false) - /** - * Revoke a specific castling right. - */ + /** Revoke a specific castling right. + */ def revokeKingSide(color: Color): CastlingRights = color match case Color.White => copy(whiteKingSide = false) case Color.Black => copy(blackKingSide = false) - /** - * Revoke a specific castling right. - */ + /** Revoke a specific castling right. + */ def revokeQueenSide(color: Color): CastlingRights = color match case Color.White => copy(whiteQueenSide = false) case Color.Black => copy(blackQueenSide = false) @@ -55,7 +53,7 @@ object CastlingRights: whiteKingSide = false, whiteQueenSide = false, blackKingSide = false, - blackQueenSide = false + blackQueenSide = false, ) /** All castling rights available. */ @@ -63,7 +61,7 @@ object CastlingRights: whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, - blackQueenSide = true + blackQueenSide = true, ) /** Standard starting position castling rights (both sides can castle both ways). */ diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala b/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala index 07f467e..f8eeb95 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Piece.scala @@ -5,16 +5,16 @@ final case class Piece(color: Color, pieceType: PieceType) object Piece: // Convenience constructors - val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn) + val WhitePawn: Piece = Piece(Color.White, PieceType.Pawn) val WhiteKnight: Piece = Piece(Color.White, PieceType.Knight) val WhiteBishop: Piece = Piece(Color.White, PieceType.Bishop) - val WhiteRook: Piece = Piece(Color.White, PieceType.Rook) - val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen) - val WhiteKing: Piece = Piece(Color.White, PieceType.King) + val WhiteRook: Piece = Piece(Color.White, PieceType.Rook) + val WhiteQueen: Piece = Piece(Color.White, PieceType.Queen) + val WhiteKing: Piece = Piece(Color.White, PieceType.King) - val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn) + val BlackPawn: Piece = Piece(Color.Black, PieceType.Pawn) val BlackKnight: Piece = Piece(Color.Black, PieceType.Knight) val BlackBishop: Piece = Piece(Color.Black, PieceType.Bishop) - val BlackRook: Piece = Piece(Color.Black, PieceType.Rook) - val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen) - val BlackKing: Piece = Piece(Color.Black, PieceType.King) + val BlackRook: Piece = Piece(Color.Black, PieceType.Rook) + val BlackQueen: Piece = Piece(Color.Black, PieceType.Queen) + val BlackKing: Piece = Piece(Color.Black, PieceType.King) diff --git a/modules/api/src/main/scala/de/nowchess/api/board/Square.scala b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala index 44c3263..c691a33 100644 --- a/modules/api/src/main/scala/de/nowchess/api/board/Square.scala +++ b/modules/api/src/main/scala/de/nowchess/api/board/Square.scala @@ -1,43 +1,38 @@ package de.nowchess.api.board -/** - * A file (column) on the chess board, a–h. - * Ordinal values 0–7 correspond to a–h. - */ +/** A file (column) on the chess board, a–h. Ordinal values 0–7 correspond to a–h. + */ enum File: case A, B, C, D, E, F, G, H -/** - * A rank (row) on the chess board, 1–8. - * Ordinal values 0–7 correspond to ranks 1–8. - */ +/** A rank (row) on the chess board, 1–8. Ordinal values 0–7 correspond to ranks 1–8. + */ enum Rank: case R1, R2, R3, R4, R5, R6, R7, R8 -/** - * A unique square on the board, identified by its file and rank. - * - * @param file the column, a–h - * @param rank the row, 1–8 - */ +/** A unique square on the board, identified by its file and rank. + * + * @param file + * the column, a–h + * @param rank + * the row, 1–8 + */ final case class Square(file: File, rank: Rank): /** Algebraic notation string, e.g. "e4". */ override def toString: String = s"${file.toString.toLowerCase}${rank.ordinal + 1}" object Square: - /** Parse a square from algebraic notation (e.g. "e4"). - * Returns None if the input is not a valid square name. */ + /** Parse a square from algebraic notation (e.g. "e4"). Returns None if the input is not a valid square name. + */ def fromAlgebraic(s: String): Option[Square] = if s.length != 2 then None else val fileChar = s.charAt(0) val rankChar = s.charAt(1) - val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString)) + val fileOpt = File.values.find(_.toString.equalsIgnoreCase(fileChar.toString)) val rankOpt = - rankChar.toString.toIntOption.flatMap(n => - if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None - ) + rankChar.toString.toIntOption.flatMap(n => if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None) for f <- fileOpt; r <- rankOpt yield Square(f, r) val all: IndexedSeq[Square] = @@ -46,12 +41,13 @@ 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 val newRankOrd = sq.rank.ordinal + rankDelta if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then Some(Square(File.values(newFileOrd), Rank.values(newRankOrd))) - else None \ No newline at end of file + else None diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala index 3312548..1ede764 100644 --- a/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala +++ b/modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala @@ -1,18 +1,17 @@ package de.nowchess.api.game -import de.nowchess.api.board.{Board, Color, Square, CastlingRights} +import de.nowchess.api.board.{Board, CastlingRights, Color, Square} import de.nowchess.api.move.Move -/** Immutable bundle of complete game state. - * All state changes produce new GameContext instances. - */ +/** Immutable bundle of complete game state. All state changes produce new GameContext instances. + */ case class GameContext( - board: Board, - turn: Color, - castlingRights: CastlingRights, - enPassantSquare: Option[Square], - halfMoveClock: Int, - moves: List[Move] + board: Board, + turn: Color, + castlingRights: CastlingRights, + enPassantSquare: Option[Square], + halfMoveClock: Int, + moves: List[Move], ): /** Create new context with updated board. */ def withBoard(newBoard: Board): GameContext = copy(board = newBoard) @@ -40,5 +39,5 @@ object GameContext: castlingRights = CastlingRights.Initial, enPassantSquare = None, halfMoveClock = 0, - moves = List.empty + moves = List.empty, ) diff --git a/modules/api/src/main/scala/de/nowchess/api/move/Move.scala b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala index 1485c93..e88334c 100644 --- a/modules/api/src/main/scala/de/nowchess/api/move/Move.scala +++ b/modules/api/src/main/scala/de/nowchess/api/move/Move.scala @@ -10,24 +10,30 @@ enum PromotionPiece: enum MoveType: /** A normal move or capture with no special rule. */ case Normal(isCapture: Boolean = false) + /** Kingside castling (O-O). */ case CastleKingside + /** Queenside castling (O-O-O). */ case CastleQueenside + /** En-passant pawn capture. */ case EnPassant + /** Pawn promotion; carries the chosen promotion piece. */ case Promotion(piece: PromotionPiece) -/** - * A half-move (ply) in a chess game. - * - * @param from origin square - * @param to destination square - * @param moveType special semantics; defaults to Normal - */ +/** A half-move (ply) in a chess game. + * + * @param from + * origin square + * @param to + * destination square + * @param moveType + * special semantics; defaults to Normal + */ final case class Move( - from: Square, - to: Square, - moveType: MoveType = MoveType.Normal() + from: Square, + to: Square, + moveType: MoveType = MoveType.Normal(), ) diff --git a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala index c655546..c6b8053 100644 --- a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala +++ b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala @@ -1,27 +1,26 @@ package de.nowchess.api.player -/** - * An opaque player identifier. - * - * Wraps a plain String so that IDs are not accidentally interchanged with - * other String values at compile time. - */ +/** An opaque player identifier. + * + * Wraps a plain String so that IDs are not accidentally interchanged with other String values at compile time. + */ opaque type PlayerId = String object PlayerId: - def apply(value: String): PlayerId = value + def apply(value: String): PlayerId = value extension (id: PlayerId) def value: String = id -/** - * The minimal cross-service identity stub for a player. - * - * Full profile data (email, rating history, etc.) lives in the user-management - * service. Only what every service needs is held here. - * - * @param id unique identifier - * @param displayName human-readable name shown in the UI - */ +/** The minimal cross-service identity stub for a player. + * + * Full profile data (email, rating history, etc.) lives in the user-management service. Only what every service needs + * is held here. + * + * @param id + * unique identifier + * @param displayName + * human-readable name shown in the UI + */ final case class PlayerInfo( - id: PlayerId, - displayName: String + id: PlayerId, + displayName: String, ) diff --git a/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala b/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala index a87deb6..bc16887 100644 --- a/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala +++ b/modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala @@ -1,13 +1,12 @@ package de.nowchess.api.response -/** - * A standardised envelope for every API response. - * - * Success and failure are modelled as subtypes so that callers - * can pattern-match exhaustively. - * - * @tparam A the payload type for a successful response - */ +/** A standardised envelope for every API response. + * + * Success and failure are modelled as subtypes so that callers can pattern-match exhaustively. + * + * @tparam A + * the payload type for a successful response + */ sealed trait ApiResponse[+A] object ApiResponse: @@ -20,43 +19,49 @@ object ApiResponse: /** Convenience constructor for a single-error failure. */ def error(err: ApiError): Failure = Failure(List(err)) -/** - * A structured error descriptor. - * - * @param code machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND") - * @param message human-readable explanation - * @param field optional field name when the error relates to a specific input - */ +/** A structured error descriptor. + * + * @param code + * machine-readable error code (e.g. "INVALID_MOVE", "NOT_FOUND") + * @param message + * human-readable explanation + * @param field + * optional field name when the error relates to a specific input + */ final case class ApiError( - code: String, - message: String, - field: Option[String] = None + code: String, + message: String, + field: Option[String] = None, ) -/** - * Pagination metadata for list responses. - * - * @param page current 0-based page index - * @param pageSize number of items per page - * @param totalItems total number of items across all pages - */ +/** Pagination metadata for list responses. + * + * @param page + * current 0-based page index + * @param pageSize + * number of items per page + * @param totalItems + * total number of items across all pages + */ final case class Pagination( - page: Int, - pageSize: Int, - totalItems: Long + page: Int, + pageSize: Int, + totalItems: Long, ): def totalPages: Int = if pageSize <= 0 then 0 else Math.ceil(totalItems.toDouble / pageSize).toInt -/** - * A paginated list response envelope. - * - * @param items the items on the current page - * @param pagination pagination metadata - * @tparam A the item type - */ +/** A paginated list response envelope. + * + * @param items + * the items on the current page + * @param pagination + * pagination metadata + * @tparam A + * the item type + */ final case class PagedResponse[A]( - items: List[A], - pagination: Pagination + items: List[A], + pagination: Pagination, ) diff --git a/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala index ae3b4d8..02c915f 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala @@ -22,9 +22,9 @@ class BoardTest extends AnyFunSuite with Matchers: } test("withMove returns captured piece when destination is occupied") { - val from = Square(File.A, Rank.R1) - val to = Square(File.A, Rank.R8) - val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook)) + val from = Square(File.A, Rank.R1) + val to = Square(File.A, Rank.R8) + val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook)) val (board, captured) = b.withMove(from, to) captured shouldBe Some(Piece.BlackRook) board.pieceAt(to) shouldBe Some(Piece.WhiteRook) @@ -51,8 +51,14 @@ class BoardTest extends AnyFunSuite with Matchers: test("initial board white back rank") { val expectedBackRank = Vector( - PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, - PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + PieceType.Rook, + PieceType.Knight, + PieceType.Bishop, + PieceType.Queen, + PieceType.King, + PieceType.Bishop, + PieceType.Knight, + PieceType.Rook, ) File.values.zipWithIndex.foreach { (file, i) => Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe @@ -62,8 +68,14 @@ class BoardTest extends AnyFunSuite with Matchers: test("initial board black back rank") { val expectedBackRank = Vector( - PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, - PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + PieceType.Rook, + PieceType.Knight, + PieceType.Bishop, + PieceType.Queen, + PieceType.King, + PieceType.Bishop, + PieceType.Knight, + PieceType.Rook, ) File.values.zipWithIndex.foreach { (file, i) => Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe @@ -76,12 +88,11 @@ class BoardTest extends AnyFunSuite with Matchers: for rank <- emptyRanks file <- File.values - do - Board.initial.pieceAt(Square(file, rank)) shouldBe None + do Board.initial.pieceAt(Square(file, rank)) shouldBe None } test("updated adds and replaces piece at squares") { - val b = Board(Map(e2 -> Piece.WhitePawn)) + val b = Board(Map(e2 -> Piece.WhitePawn)) val added = b.updated(e4, Piece.WhiteKnight) added.pieceAt(e2) shouldBe Some(Piece.WhitePawn) added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) @@ -91,7 +102,7 @@ class BoardTest extends AnyFunSuite with Matchers: } test("removed deletes piece from board") { - val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight)) + val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight)) val removed = b.removed(e2) removed.pieceAt(e2) shouldBe None removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) @@ -105,4 +116,3 @@ class BoardTest extends AnyFunSuite with Matchers: moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn) moved.pieceAt(e2) shouldBe None } - diff --git a/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala index 5dde137..be9375b 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala @@ -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 - diff --git a/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala index 9e62c17..eea47ab 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala @@ -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) => diff --git a/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala index 9628b01..88c0ce7 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala @@ -7,24 +7,24 @@ class PieceTest extends AnyFunSuite with Matchers: test("Piece holds color and pieceType") { val p = Piece(Color.White, PieceType.Queen) - p.color shouldBe Color.White + p.color shouldBe Color.White p.pieceType shouldBe PieceType.Queen } test("all convenience constants map to expected color and piece type") { val expected = List( - Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn), + Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn), Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight), Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop), - Piece.WhiteRook -> Piece(Color.White, PieceType.Rook), - Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen), - Piece.WhiteKing -> Piece(Color.White, PieceType.King), - Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn), + Piece.WhiteRook -> Piece(Color.White, PieceType.Rook), + Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen), + Piece.WhiteKing -> Piece(Color.White, PieceType.King), + Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn), Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight), Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop), - Piece.BlackRook -> Piece(Color.Black, PieceType.Rook), - Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen), - Piece.BlackKing -> Piece(Color.Black, PieceType.King) + Piece.BlackRook -> Piece(Color.Black, PieceType.Rook), + Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen), + Piece.BlackKing -> Piece(Color.Black, PieceType.King), ) expected.foreach { case (actual, wanted) => diff --git a/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala index a10e2d4..094bb9d 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala @@ -7,12 +7,12 @@ class PieceTypeTest extends AnyFunSuite with Matchers: test("PieceType values expose the expected labels"): val expectedLabels = List( - PieceType.Pawn -> "Pawn", + PieceType.Pawn -> "Pawn", PieceType.Knight -> "Knight", PieceType.Bishop -> "Bishop", - PieceType.Rook -> "Rook", - PieceType.Queen -> "Queen", - PieceType.King -> "King" + PieceType.Rook -> "Rook", + PieceType.Queen -> "Queen", + PieceType.King -> "King", ) expectedLabels.foreach { (pieceType, expectedLabel) => diff --git a/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala index c294f0f..ad8b499 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala @@ -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 } - diff --git a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala index 3ad4f34..d5d6759 100644 --- a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala @@ -18,9 +18,9 @@ class GameContextTest extends AnyFunSuite with Matchers: initial.moves shouldBe List.empty test("withBoard updates only board"): - val square = Square(File.E, Rank.R4) + val square = Square(File.E, Rank.R4) val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen) - val updated = GameContext.initial.withBoard(updatedBoard) + val updated = GameContext.initial.withBoard(updatedBoard) updated.board shouldBe updatedBoard updated.turn shouldBe GameContext.initial.turn updated.castlingRights shouldBe GameContext.initial.castlingRights @@ -34,13 +34,13 @@ class GameContextTest extends AnyFunSuite with Matchers: whiteKingSide = true, whiteQueenSide = false, blackKingSide = false, - blackQueenSide = true + blackQueenSide = true, ) - val square = Some(Square(File.E, Rank.R3)) - val updatedTurn = initial.withTurn(Color.Black) + val square = Some(Square(File.E, Rank.R3)) + val updatedTurn = initial.withTurn(Color.Black) val updatedRights = initial.withCastlingRights(rights) - val updatedEp = initial.withEnPassantSquare(square) - val updatedClock = initial.withHalfMoveClock(17) + val updatedEp = initial.withEnPassantSquare(square) + val updatedClock = initial.withHalfMoveClock(17) updatedTurn.turn shouldBe Color.Black updatedTurn.board shouldBe initial.board @@ -57,4 +57,3 @@ class GameContextTest extends AnyFunSuite with Matchers: test("withMove appends move to history"): val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) GameContext.initial.withMove(move).moves shouldBe List(move) - diff --git a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala index f331a82..7ef5e1f 100644 --- a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala @@ -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 => diff --git a/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala b/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala index 8bbb54b..be5c2ea 100644 --- a/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala @@ -7,12 +7,12 @@ class PlayerInfoTest extends AnyFunSuite with Matchers: test("PlayerId and PlayerInfo preserve constructor values") { val raw = "player-123" - val id = PlayerId(raw) + val id = PlayerId(raw) id.value shouldBe raw val playerId = PlayerId("p1") - val info = PlayerInfo(playerId, "Magnus") - info.id.value shouldBe "p1" - info.displayName shouldBe "Magnus" + val info = PlayerInfo(playerId, "Magnus") + info.id.value shouldBe "p1" + info.displayName shouldBe "Magnus" } diff --git a/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala b/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala index 4f52147..cef66ef 100644 --- a/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala @@ -14,9 +14,9 @@ class ApiResponseTest extends AnyFunSuite with Matchers: ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err)) val e = ApiError("CODE", "message") - e.code shouldBe "CODE" + e.code shouldBe "CODE" e.message shouldBe "message" - e.field shouldBe None + e.field shouldBe None ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email") } @@ -31,6 +31,6 @@ class ApiResponseTest extends AnyFunSuite with Matchers: test("PagedResponse holds items and pagination") { val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20) val pr = PagedResponse(List("a", "b"), pagination) - pr.items shouldBe List("a", "b") + pr.items shouldBe List("a", "b") pr.pagination shouldBe pagination } diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala index a9aaf94..68c3c9b 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala @@ -1,11 +1,11 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, Piece} +import de.nowchess.api.board.{Piece, Square} import de.nowchess.api.game.GameContext -/** Marker trait for all commands that can be executed and undone. - * Commands encapsulate user actions and game state transitions. - */ +/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state + * transitions. + */ trait Command: /** Execute the command and return true if successful, false otherwise. */ def execute(): Boolean @@ -16,15 +16,14 @@ trait Command: /** A human-readable description of this command. */ def description: String -/** Command to move a piece from one square to another. - * Stores the move result so undo can restore previous state. - */ +/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state. + */ case class MoveCommand( - from: Square, - to: Square, - moveResult: Option[MoveResult] = None, - previousContext: Option[GameContext] = None, - notation: String = "" + from: Square, + to: Square, + moveResult: Option[MoveResult] = None, + previousContext: Option[GameContext] = None, + notation: String = "", ) extends Command: override def execute(): Boolean = @@ -39,18 +38,18 @@ case class MoveCommand( sealed trait MoveResult object MoveResult: case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult - case object InvalidFormat extends MoveResult - case object InvalidMove extends MoveResult + case object InvalidFormat extends MoveResult + case object InvalidMove extends MoveResult /** Command to quit the game. */ case class QuitCommand() extends Command: - override def execute(): Boolean = true - override def undo(): Boolean = false + override def execute(): Boolean = true + override def undo(): Boolean = false override def description: String = "Quit game" /** Command to reset the board to initial position. */ case class ResetCommand( - previousContext: Option[GameContext] = None + previousContext: Option[GameContext] = None, ) extends Command: override def execute(): Boolean = true diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala index 7913ba6..78760ef 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala @@ -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. */ diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala index 3e95cb5..a517053 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala @@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square} object Parser: - /** Parses coordinate notation such as "e2e4" or "g1f3". - * Returns None for any input that does not match the expected format. - */ + /** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected + * format. + */ def parseMove(input: String): Option[(Square, Square)] = val trimmed = input.trim.toLowerCase - Option.when(trimmed.length == 4)(trimmed).flatMap: s => - for - from <- parseSquare(s.substring(0, 2)) - to <- parseSquare(s.substring(2, 4)) - yield (from, to) + Option + .when(trimmed.length == 4)(trimmed) + .flatMap: s => + for + from <- parseSquare(s.substring(0, 2)) + to <- parseSquare(s.substring(2, 4)) + yield (from, to) private def parseSquare(s: String): Option[Square] = - Option.when(s.length == 2)(s).flatMap: sq => - val fileIdx = sq(0) - 'a' - val rankIdx = sq(1) - '1' - Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( - Square(File.values(fileIdx), Rank.values(rankIdx)) - ) + Option + .when(s.length == 2)(s) + .flatMap: sq => + val fileIdx = sq(0) - 'a' + val rankIdx = sq(1) - '1' + Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)( + Square(File.values(fileIdx), Rank.values(rankIdx)), + ) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 50d3772..952f29a 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -6,45 +6,46 @@ import de.nowchess.api.game.GameContext import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} -import de.nowchess.io.{GameContextImport, GameContextExport} +import de.nowchess.io.{GameContextExport, GameContextImport} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules -/** Pure game engine that manages game state and notifies observers of state changes. - * All rule queries delegate to the injected RuleSet. - * All user interactions go through Commands; state changes are broadcast via GameEvents. - */ +/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the + * injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents. + */ class GameEngine( - val initialContext: GameContext = GameContext.initial, - val ruleSet: RuleSet = DefaultRules + val initialContext: GameContext = GameContext.initial, + val ruleSet: RuleSet = DefaultRules, ) extends Observable: + @SuppressWarnings(Array("DisableSyntax.var")) private var currentContext: GameContext = initialContext - private val invoker = new CommandInvoker() + private val invoker = new CommandInvoker() /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext) + @SuppressWarnings(Array("DisableSyntax.var")) private var pendingPromotion: Option[PendingPromotion] = None /** True if a pawn promotion move is pending and needs a piece choice. */ - def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } + def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined) // Synchronized accessors for current state - def board: Board = synchronized { currentContext.board } - def turn: Color = synchronized { currentContext.turn } - def context: GameContext = synchronized { currentContext } + def board: Board = synchronized(currentContext.board) + def turn: Color = synchronized(currentContext.turn) + def context: GameContext = synchronized(currentContext) /** Check if undo is available. */ - def canUndo: Boolean = synchronized { invoker.canUndo } + def canUndo: Boolean = synchronized(invoker.canUndo) /** Check if redo is available. */ - def canRedo: Boolean = synchronized { invoker.canRedo } + def canRedo: Boolean = synchronized(invoker.canRedo) /** Get the command history for inspection (testing/debugging). */ - def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history } + def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history) - /** Process a raw move input string and update game state if valid. - * Notifies all observers of the outcome via GameEvent. - */ + /** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via + * GameEvent. + */ def processUserInput(rawInput: String): Unit = synchronized { val trimmed = rawInput.trim.toLowerCase trimmed match @@ -62,10 +63,12 @@ class GameEngine( invoker.clear() notifyObservers(DrawClaimedEvent(currentContext)) else - notifyObservers(InvalidMoveEvent( - currentContext, - "Draw cannot be claimed: the 50-move rule has not been triggered." - )) + notifyObservers( + InvalidMoveEvent( + currentContext, + "Draw cannot be claimed: the 50-move rule has not been triggered.", + ), + ) case "" => notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command.")) @@ -73,10 +76,12 @@ class GameEngine( case moveInput => Parser.parseMove(moveInput) match case None => - notifyObservers(InvalidMoveEvent( - currentContext, - s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." - )) + notifyObservers( + InvalidMoveEvent( + currentContext, + s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.", + ), + ) case Some((from, to)) => handleParsedMove(from, to) } @@ -108,9 +113,8 @@ class GameEngine( to.rank.ordinal == promoRank } - /** Apply a player's promotion piece choice. - * Must only be called when isPendingPromotion is true. - */ + /** Apply a player's promotion piece choice. Must only be called when isPendingPromotion is true. + */ def completePromotion(piece: PromotionPiece): Unit = synchronized { pendingPromotion match case None => @@ -120,23 +124,19 @@ class GameEngine( val move = Move(pending.from, pending.to, MoveType.Promotion(piece)) // Verify it's actually legal val legal = ruleSet.legalMoves(currentContext)(pending.from) - if legal.contains(move) then - executeMove(move) - else - notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion.")) + if legal.contains(move) then executeMove(move) + else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion.")) } /** Undo the last move. */ - def undo(): Unit = synchronized { performUndo() } + def undo(): Unit = synchronized(performUndo()) /** Redo the last undone move. */ - def redo(): Unit = synchronized { performRedo() } + def redo(): Unit = synchronized(performRedo()) - /** Load a game using the provided importer. - * If the imported context has moves, they are replayed through the command system. - * Otherwise, the position is set directly. - * Notifies observers with PgnLoadedEvent on success. - */ + /** Load a game using the provided importer. If the imported context has moves, they are replayed through the command + * system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success. + */ def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized { importer.importGameContext(input) match case Left(err) => Left(err) @@ -155,29 +155,24 @@ class GameEngine( if ctx.moves.isEmpty then currentContext = ctx Right(()) - else - replayMoves(ctx.moves, savedContext) + else replayMoves(ctx.moves, savedContext) private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] = - var error: Option[String] = None - moves.foreach: move => - if error.isEmpty then - handleParsedMove(move.from, move.to) + val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) => + acc.flatMap(_ => applyReplayMove(move)) + } + result.left.foreach(_ => currentContext = savedContext) + result - move.moveType match { - case MoveType.Promotion(pp) => - if pendingPromotion.isDefined then - completePromotion(pp) - else - error = Some(s"Promotion required for move ${move.from}${move.to}") - case _ => () - } - error match - case Some(err) => - currentContext = savedContext - Left(err) - case None => + private def applyReplayMove(move: Move): Either[String, Unit] = + handleParsedMove(move.from, move.to) + move.moveType match + case MoveType.Promotion(pp) if pendingPromotion.isDefined => + completePromotion(pp) Right(()) + case MoveType.Promotion(_) => + Left(s"Promotion required for move ${move.from}${move.to}") + case _ => Right(()) /** Export the current game context using the provided exporter. */ def exportGame(exporter: GameContextExport): String = synchronized { @@ -203,25 +198,27 @@ class GameEngine( private def executeMove(move: Move): Unit = val contextBefore = currentContext - val nextContext = ruleSet.applyMove(currentContext)(move) - val captured = computeCaptured(currentContext, move) + val nextContext = ruleSet.applyMove(currentContext)(move) + val captured = computeCaptured(currentContext, move) val cmd = MoveCommand( from = move.from, to = move.to, moveResult = Some(MoveResult.Successful(nextContext, captured)), previousContext = Some(contextBefore), - notation = translateMoveToNotation(move, contextBefore.board) + notation = translateMoveToNotation(move, contextBefore.board), ) invoker.execute(cmd) currentContext = nextContext - notifyObservers(MoveExecutedEvent( - currentContext, - move.from.toString, - move.to.toString, - captured.map(c => s"${c.color.label} ${c.pieceType.label}") - )) + notifyObservers( + MoveExecutedEvent( + currentContext, + move.from.toString, + move.to.toString, + captured.map(c => s"${c.color.label} ${c.pieceType.label}"), + ), + ) if ruleSet.isCheckmate(currentContext) then val winner = currentContext.turn.opposite @@ -232,18 +229,16 @@ class GameEngine( notifyObservers(StalemateEvent(currentContext)) invoker.clear() currentContext = GameContext.initial - else if ruleSet.isCheck(currentContext) then - notifyObservers(CheckDetectedEvent(currentContext)) + else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext)) - if currentContext.halfMoveClock >= 100 then - notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) + if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) private def translateMoveToNotation(move: Move, boardBefore: Board): String = move.moveType match - case MoveType.CastleKingside => "O-O" - case MoveType.CastleQueenside => "O-O-O" - case MoveType.EnPassant => enPassantNotation(move) - case MoveType.Promotion(pp) => promotionNotation(move, pp) + case MoveType.CastleKingside => "O-O" + case MoveType.CastleQueenside => "O-O-O" + case MoveType.EnPassant => enPassantNotation(move) + case MoveType.Promotion(pp) => promotionNotation(move, pp) case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture) private def enPassantNotation(move: Move): String = @@ -295,8 +290,7 @@ class GameEngine( moveCmd.previousContext.foreach(currentContext = _) invoker.undo() notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation)) - else - notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo.")) + else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo.")) private def performRedo(): Unit = if invoker.canRedo then @@ -307,12 +301,13 @@ class GameEngine( currentContext = nextCtx invoker.redo() val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}") - notifyObservers(MoveRedoneEvent( - currentContext, - moveCmd.notation, - moveCmd.from.toString, - moveCmd.to.toString, - capturedDesc - )) - else - notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo.")) + notifyObservers( + MoveRedoneEvent( + currentContext, + moveCmd.notation, + moveCmd.from.toString, + moveCmd.to.toString, + capturedDesc, + ), + ) + else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo.")) diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index db518c4..0aafaa2 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -3,82 +3,81 @@ package de.nowchess.chess.observer import de.nowchess.api.board.{Color, Square} import de.nowchess.api.game.GameContext -/** Base trait for all game state events. - * Events are immutable snapshots of game state changes. - */ +/** Base trait for all game state events. Events are immutable snapshots of game state changes. + */ sealed trait GameEvent: def context: GameContext /** Fired when a move is successfully executed. */ case class MoveExecutedEvent( - context: GameContext, - fromSquare: String, - toSquare: String, - capturedPiece: Option[String] + context: GameContext, + fromSquare: String, + toSquare: String, + capturedPiece: Option[String], ) extends GameEvent /** Fired when the current player is in check. */ case class CheckDetectedEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Fired when the game reaches checkmate. */ case class CheckmateEvent( - context: GameContext, - winner: Color + context: GameContext, + winner: Color, ) extends GameEvent /** Fired when the game reaches stalemate. */ case class StalemateEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Fired when a move is invalid. */ case class InvalidMoveEvent( - context: GameContext, - reason: String + context: GameContext, + reason: String, ) extends GameEvent /** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ case class PromotionRequiredEvent( - context: GameContext, - from: Square, - to: Square + context: GameContext, + from: Square, + to: Square, ) extends GameEvent /** Fired when the board is reset. */ case class BoardResetEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */ case class FiftyMoveRuleAvailableEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Fired when a player successfully claims a draw under the 50-move rule. */ case class DrawClaimedEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Fired when a move is undone, carrying PGN notation of the reversed move. */ case class MoveUndoneEvent( - context: GameContext, - pgnNotation: String + context: GameContext, + pgnNotation: String, ) extends GameEvent /** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */ case class MoveRedoneEvent( - context: GameContext, - pgnNotation: String, - fromSquare: String, - toSquare: String, - capturedPiece: Option[String] + context: GameContext, + pgnNotation: String, + fromSquare: String, + toSquare: String, + capturedPiece: Option[String], ) extends GameEvent /** Fired after a PGN string is successfully loaded and all moves are replayed into history. */ case class PgnLoadedEvent( - context: GameContext + context: GameContext, ) extends GameEvent /** Observer trait: implement to receive game state updates. */ diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala index c84c33e..5583ee6 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala @@ -1,6 +1,6 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -10,13 +10,16 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) private case class FailingCommand() extends Command: - override def execute(): Boolean = false - override def undo(): Boolean = false + override def execute(): Boolean = false + override def undo(): Boolean = false override def description: String = "Failing command" - private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command: - override def execute(): Boolean = !shouldFailOnExecute - override def undo(): Boolean = !shouldFailOnUndo + private case class ConditionalFailCommand( + var shouldFailOnUndo: Boolean = false, + var shouldFailOnExecute: Boolean = false, + ) extends Command: + override def execute(): Boolean = !shouldFailOnExecute + override def undo(): Boolean = !shouldFailOnUndo override def description: String = "Conditional fail" private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = @@ -24,12 +27,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: from = from, to = to, moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None, - previousContext = Some(GameContext.initial) + previousContext = Some(GameContext.initial), ) test("execute rejects failing commands and keeps history unchanged"): val invoker = new CommandInvoker() - val cmd = FailingCommand() + val cmd = FailingCommand() invoker.execute(cmd) shouldBe false invoker.history.size shouldBe 0 invoker.getCurrentIndex shouldBe -1 @@ -52,8 +55,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: { val invoker = new CommandInvoker() - val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() @@ -62,7 +65,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: } { - val invoker = new CommandInvoker() + val invoker = new CommandInvoker() val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true) invoker.execute(failingUndoCmd) shouldBe true invoker.canUndo shouldBe true @@ -71,7 +74,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: } { - val invoker = new CommandInvoker() + val invoker = new CommandInvoker() val successUndoCmd = ConditionalFailCommand() invoker.execute(successUndoCmd) shouldBe true invoker.undo() shouldBe true @@ -85,15 +88,15 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: { val invoker = new CommandInvoker() - val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) invoker.canRedo shouldBe false invoker.redo() shouldBe false } { - val invoker = new CommandInvoker() - val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val invoker = new CommandInvoker() + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val redoFailCmd = ConditionalFailCommand() invoker.execute(cmd1) invoker.execute(redoFailCmd) @@ -106,7 +109,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: { val invoker = new CommandInvoker() - val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) shouldBe true invoker.undo() shouldBe true invoker.redo() shouldBe true @@ -115,9 +118,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: { val invoker = new CommandInvoker() - val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() @@ -130,10 +133,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: { val invoker = new CommandInvoker() - val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) - val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) + val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.execute(cmd3) diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala index f09a117..c9e82af 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala @@ -1,6 +1,6 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: from = from, to = to, moveResult = Some(MoveResult.Successful(GameContext.initial, None)), - previousContext = Some(GameContext.initial) + previousContext = Some(GameContext.initial), ) test("execute appends commands and updates index"): val invoker = new CommandInvoker() - val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) shouldBe true invoker.history.size shouldBe 1 invoker.getCurrentIndex shouldBe 0 @@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: test("undo and redo update index and availability flags"): val invoker = new CommandInvoker() - val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.canUndo shouldBe false invoker.execute(cmd) invoker.canUndo shouldBe true @@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: test("clear removes full history and resets index"): val invoker = new CommandInvoker() - val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) invoker.clear() invoker.history.size shouldBe 0 @@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: test("execute after undo discards redo history"): val invoker = new CommandInvoker() - val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala index f002578..d2ff845 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala @@ -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 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala index efd95d2..86f2e50 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala @@ -27,7 +27,7 @@ object EngineTestHelpers: private val _events = mutable.ListBuffer[GameEvent]() def events: mutable.ListBuffer[GameEvent] = _events - def eventCount: Int = _events.length + def eventCount: Int = _events.length def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean = _events.exists(ct.runtimeClass.isInstance(_)) def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] = diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala index 0b67094..a06e0b9 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala @@ -2,7 +2,7 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent} +import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -10,82 +10,91 @@ import org.scalatest.matchers.should.Matchers class GameEngineGameEndingTest extends AnyFunSuite with Matchers: test("GameEngine handles Checkmate (Fool's Mate)"): - val engine = new GameEngine() + val engine = new GameEngine() val observer = new EndingMockObserver() engine.subscribe(observer) - + // Play Fool's mate engine.processUserInput("f2f3") engine.processUserInput("e7e5") engine.processUserInput("g2g4") - + observer.events.clear() engine.processUserInput("d8h4") // Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) observer.events.last shouldBe a[CheckmateEvent] - + val event = observer.events.last.asInstanceOf[CheckmateEvent] event.winner shouldBe Color.Black - + // Board should be reset after checkmate engine.board shouldBe Board.initial engine.turn shouldBe Color.White test("GameEngine handles check detection"): - val engine = new GameEngine() + val engine = new GameEngine() val observer = new EndingMockObserver() engine.subscribe(observer) - + // Play a simple check engine.processUserInput("e2e4") engine.processUserInput("e7e5") engine.processUserInput("f1c4") engine.processUserInput("g8f6") - + observer.events.clear() engine.processUserInput("c4f7") // Check! - + val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e } checkEvents.size shouldBe 1 checkEvents.head.context.turn shouldBe Color.Black // Black is now in check - + // Shortest known stalemate is 19 moves. Here is a faster one: // e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6 // Wait, let's just use Sam Loyd's 10-move stalemate: // 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6 test("GameEngine handles Stalemate via 10-move known sequence"): - val engine = new GameEngine() + val engine = new GameEngine() val observer = new EndingMockObserver() engine.subscribe(observer) - + val moves = List( - "e2e3", "a7a5", - "d1h5", "a8a6", - "h5a5", "h7h5", - "h2h4", "a6h6", - "a5c7", "f7f6", - "c7d7", "e8f7", - "d7b7", "d8d3", - "b7b8", "d3h7", - "b8c8", "f7g6", - "c8e6" + "e2e3", + "a7a5", + "d1h5", + "a8a6", + "h5a5", + "h7h5", + "h2h4", + "a6h6", + "a5c7", + "f7f6", + "c7d7", + "e8f7", + "d7b7", + "d8d3", + "b7b8", + "d3h7", + "b8c8", + "f7g6", + "c8e6", ) - + moves.dropRight(1).foreach(engine.processUserInput) - + observer.events.clear() engine.processUserInput(moves.last) - + val stalemateEvents = observer.events.collect { case e: StalemateEvent => e } stalemateEvents.size shouldBe 1 - + // Board should be reset after stalemate engine.board shouldBe Board.initial engine.turn shouldBe Color.White private class EndingMockObserver extends Observer: val events = mutable.ListBuffer[GameEvent]() - + override def onGameEvent(event: GameEvent): Unit = - events += event \ No newline at end of file + events += event diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index 599e2b6..3cb9d08 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square) def legalMoves(context: GameContext)(square: Square): List[Move] = if square == sq("e2") then List(promotionMove) else List.empty - def allLegalMoves(context: GameContext): List[Move] = List(promotionMove) - def isCheck(context: GameContext): Boolean = false - def isCheckmate(context: GameContext): Boolean = false - def isStalemate(context: GameContext): Boolean = false - def isInsufficientMaterial(context: GameContext): Boolean = false - def isFiftyMoveRule(context: GameContext): Boolean = false + def allLegalMoves(context: GameContext): List[Move] = List(promotionMove) + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move) val engine = new GameEngine(ruleSet = permissiveRules) @@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) val noLegalMoves = new RuleSet: def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty - def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty - def allLegalMoves(context: GameContext): List[Move] = List.empty - def isCheck(context: GameContext): Boolean = false - def isCheckmate(context: GameContext): Boolean = false - def isStalemate(context: GameContext): Boolean = false - def isInsufficientMaterial(context: GameContext): Boolean = false - def isFiftyMoveRule(context: GameContext): Boolean = false - def applyMove(context: GameContext)(move: Move): GameContext = context + def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty + def allLegalMoves(context: GameContext): List[Move] = List.empty + def isCheck(context: GameContext): Boolean = false + def isCheckmate(context: GameContext): Boolean = false + def isStalemate(context: GameContext): Boolean = false + def isInsufficientMaterial(context: GameContext): Boolean = false + def isFiftyMoveRule(context: GameContext): Boolean = false + def applyMove(context: GameContext)(move: Move): GameContext = context val engine = new GameEngine(ruleSet = noLegalMoves) engine.processUserInput("e2e4") @@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: test("loadGame replay executes non-promotion moves through default replay branch"): val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal()) - val engine = new GameEngine() + val engine = new GameEngine() engine.replayMoves(List(normalMove), engine.context) shouldBe Right(()) engine.context.moves.lastOption shouldBe Some(normalMove) test("replayMoves skips later moves after the first move triggers an error"): - val engine = new GameEngine() - val saved = engine.context + val engine = new GameEngine() + val saved = engine.context val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) - val trailingMove = Move(sq("e2"), sq("e4")) + val trailingMove = Move(sq("e2"), sq("e4")) engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1") engine.context shouldBe saved - test("normalMoveNotation handles missing source piece"): val engine = new GameEngine() val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false) @@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.observerCount shouldBe 1 engine.unsubscribe(observer) engine.observerCount shouldBe 0 - - diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala index a23f4b1..d926147 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala @@ -3,7 +3,7 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} import de.nowchess.api.game.GameContext -import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent} +import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent} import de.nowchess.io.pgn.PgnParser import de.nowchess.io.fen.FenParser import de.nowchess.io.pgn.PgnExporter @@ -15,7 +15,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): val engine = new GameEngine() - val pgn = "[Event \"Test\"]\n\n1. e4 e5\n" + val pgn = "[Event \"Test\"]\n\n1. e4 e5\n" val result = engine.loadGame(PgnParser, pgn) result shouldBe Right(()) engine.context.moves.size shouldBe 2 @@ -23,7 +23,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: test("loadGame with FenParser: loads position without replaying moves"): val engine = new GameEngine() - val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" + val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val result = engine.loadGame(FenParser, fen) result shouldBe Right(()) engine.context.moves.isEmpty shouldBe true diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala index a431535..948fd27 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers /** Tests that exercise moveToPgn branches not covered by other test files: - * - CastleQueenside (line 223) - * - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255) - * - Promotion(Bishop) notation (line 230) - * - King normal move notation (line 246) - */ + * - CastleQueenside (line 223) + * - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255) + * - Promotion(Bishop) notation (line 230) + * - King normal move notation (line 246) + */ class GameEngineNotationTest extends AnyFunSuite with Matchers: private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = @@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get // Castling rights: white queen-side only (no king-side rook present) val castlingRights = de.nowchess.api.board.CastlingRights( - whiteKingSide = false, + whiteKingSide = false, whiteQueenSide = true, - blackKingSide = false, - blackQueenSide = false + blackKingSide = false, + blackQueenSide = false, ) val ctx = GameContext.initial .withBoard(board) @@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // White castles queenside: e1c1 engine.processUserInput("e1c1") - events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true) events.clear() engine.undo() @@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"): // White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6 - val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get + val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get val epSquare = Square.fromAlgebraic("d6") val ctx = GameContext.initial .withBoard(board) @@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // White pawn on e5 captures en passant to d6 engine.processUserInput("e5d6") - events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true) // Verify the captured pawn was found (computeCaptured EnPassant branch) val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head moveEvt.capturedPiece shouldBe defined - moveEvt.capturedPiece.get should include ("Black") + moveEvt.capturedPiece.get should include("Black") events.clear() engine.undo() @@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: // King moves e1 -> f1 engine.processUserInput("e1f1") - events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true) events.clear() engine.undo() val evt = events.collect { case e: MoveUndoneEvent => e }.head - evt.pgnNotation should startWith ("K") - evt.pgnNotation should include ("f1") + evt.pgnNotation should startWith("K") + evt.pgnNotation should include("f1") diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala index ce628c7..87c6155 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala @@ -10,7 +10,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: // ── Checkmate ─────────────────────────────────────────────────── test("checkmate ends game with CheckmateEvent"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -24,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: observer.hasEvent[CheckmateEvent] shouldBe true test("checkmate with white winner"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -45,20 +45,29 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: // ── Stalemate ─────────────────────────────────────────────────── test("stalemate ends game with StalemateEvent"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) val moves = List( - "e2e3", "a7a5", - "d1h5", "a8a6", - "h5a5", "h7h5", - "h2h4", "a6h6", - "a5c7", "f7f6", - "c7d7", "e8f7", - "d7b7", "d8d3", - "b7b8", "d3h7", - "b8c8", "f7g6" + "e2e3", + "a7a5", + "d1h5", + "a8a6", + "h5a5", + "h7h5", + "h2h4", + "a6h6", + "a5c7", + "f7f6", + "c7d7", + "e8f7", + "d7b7", + "d8d3", + "b7b8", + "d3h7", + "b8c8", + "f7g6", ) moves.foreach(engine.processUserInput) observer.clear() @@ -68,21 +77,30 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: observer.hasEvent[StalemateEvent] shouldBe true test("stalemate when king has no moves and no pieces"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) val moves = List( - "e2e3", "a7a5", - "d1h5", "a8a6", - "h5a5", "h7h5", - "h2h4", "a6h6", - "a5c7", "f7f6", - "c7d7", "e8f7", - "d7b7", "d8d3", - "b7b8", "d3h7", - "b8c8", "f7g6", - "c8e6" + "e2e3", + "a7a5", + "d1h5", + "a8a6", + "h5a5", + "h7h5", + "h2h4", + "a6h6", + "a5c7", + "f7f6", + "c7d7", + "e8f7", + "d7b7", + "d8d3", + "b7b8", + "d3h7", + "b8c8", + "f7g6", + "c8e6", ) moves.foreach(engine.processUserInput) @@ -93,7 +111,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: // ── Check detection ──────────────────────────────────────────── test("check detected after move puts king in check"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -108,7 +126,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: observer.hasEvent[CheckDetectedEvent] shouldBe true test("check by knight"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -122,7 +140,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: // ── Fifty-move rule ──────────────────────────────────────────── test("fifty-move rule triggers when half-move clock reaches 100"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -155,7 +173,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: // ── Draw claim ──────────────────────────────────────────────── test("draw can be claimed when fifty-move rule is available"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -167,7 +185,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers: observer.hasEvent[DrawClaimedEvent] shouldBe true test("draw cannot be claimed when not available"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 984b51e..8f057f7 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = engineWith(promotionBoard) - val events = captureEvents(engine) + val engine = engineWith(promotionBoard) + val events = captureEvents(engine) engine.processUserInput("e7e8") - events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true) - events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7)) + events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true) + events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7)) } test("isPendingPromotion is true after PromotionRequired input") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = engineWith(promotionBoard) + val engine = engineWith(promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") - engine.isPendingPromotion should be (true) + engine.isPendingPromotion should be(true) } test("isPendingPromotion is false before any promotion input") { val engine = new GameEngine() - engine.isPendingPromotion should be (false) + engine.isPendingPromotion should be(false) } test("completePromotion fires MoveExecutedEvent with promoted piece") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = engineWith(promotionBoard) - val events = captureEvents(engine) + val engine = engineWith(promotionBoard) + val events = captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) - engine.isPendingPromotion should be (false) - engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) - engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None) + engine.isPendingPromotion should be(false) + engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen))) + engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None) engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) - events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true) } test("completePromotion with rook underpromotion") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = engineWith(promotionBoard) + val engine = engineWith(promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Rook) - engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) + engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook))) } test("completePromotion with no pending promotion fires InvalidMoveEvent") { @@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.completePromotion(PromotionPiece.Queen) - events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) - engine.isPendingPromotion should be (false) + events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true) + engine.isPendingPromotion should be(false) } test("completePromotion fires CheckDetectedEvent when promotion gives check") { val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get - val engine = engineWith(promotionBoard) - val events = captureEvents(engine) + val engine = engineWith(promotionBoard) + val events = captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) - events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true) + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true) } test("completePromotion results in Moved when promotion doesn't give check") { - val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get + val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("e7e8") engine.completePromotion(PromotionPiece.Queen) - engine.isPendingPromotion should be (false) - engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) + engine.isPendingPromotion should be(false) + engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen))) events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty - events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false) } test("completePromotion results in Checkmate when promotion delivers checkmate") { - val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get + val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("h7h8") engine.completePromotion(PromotionPiece.Queen) - engine.isPendingPromotion should be (false) - events.exists(_.isInstanceOf[CheckmateEvent]) should be (true) + engine.isPendingPromotion should be(false) + events.exists(_.isInstanceOf[CheckmateEvent]) should be(true) } test("completePromotion results in Stalemate when promotion creates stalemate") { - val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get + val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("b7b8") engine.completePromotion(PromotionPiece.Knight) - engine.isPendingPromotion should be (false) - events.exists(_.isInstanceOf[StalemateEvent]) should be (true) + engine.isPendingPromotion should be(false) + events.exists(_.isInstanceOf[StalemateEvent]) should be(true) } test("completePromotion with black pawn promotion results in Moved") { - val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get + val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get val engine = engineWith(board, Color.Black) val events = captureEvents(engine) engine.processUserInput("e2e1") engine.completePromotion(PromotionPiece.Queen) - engine.isPendingPromotion should be (false) - engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen))) + engine.isPendingPromotion should be(false) + engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen))) events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty - events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) + events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false) } test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { @@ -177,21 +177,21 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: DefaultRules.applyMove(context)(move) val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) - val engine = new GameEngine(initialCtx, delegatingRuleSet) - val events = captureEvents(engine) + val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) + val engine = new GameEngine(initialCtx, delegatingRuleSet) + val events = captureEvents(engine) // isPromotionMove will fire because pawn is on rank 7 heading to rank 8, // and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion engine.processUserInput("e7e8") - engine.isPendingPromotion should be (true) + engine.isPendingPromotion should be(true) // completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves, // but only Normal moves exist → fires InvalidMoveEvent engine.completePromotion(PromotionPiece.Queen) - engine.isPendingPromotion should be (false) - events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) + engine.isPendingPromotion should be(false) + events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true) val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last - invalidEvt.reason should include ("Error completing promotion") + invalidEvt.reason should include("Error completing promotion") } diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala index 0145b9b..031eda0 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineScenarioTest.scala @@ -1,6 +1,6 @@ package de.nowchess.chess.engine -import de.nowchess.api.board.{Color, File, Rank, Square, Piece} +import de.nowchess.api.board.{Color, File, Piece, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.chess.observer.* import de.nowchess.io.fen.FenParser @@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers: // ── Observer wiring ──────────────────────────────────────────── test("observer subscribe and unsubscribe behavior"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) engine.processUserInput("e2e4") @@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers: // ── Invalid moves (minimal) ──────────────────────────────────── test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) engine.processUserInput("h3h4") observer.hasEvent[InvalidMoveEvent] shouldBe true - engine.turn shouldBe Color.White // turn unchanged + engine.turn shouldBe Color.White // turn unchanged - engine.processUserInput("e7e5") // try to move black pawn on white's turn + engine.processUserInput("e7e5") // try to move black pawn on white's turn observer.hasEvent[InvalidMoveEvent] shouldBe true engine.processUserInput("e2e4") - engine.processUserInput("e5e4") // pawn backward + engine.processUserInput("e5e4") // pawn backward observer.hasEvent[InvalidMoveEvent] shouldBe true // ── Undo/Redo ──────────────────────────────────────────────── test("undo redo success and empty-history failures"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers: // ── Fifty-move rule ──────────────────────────────────────────── test("fifty-move event and draw claim success/failure"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala index f74ce87..b3524c3 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineSpecialMovesTest.scala @@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: // ── Castling ──────────────────────────────────────────────────── test("kingside castling executes successfully"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: engine.turn shouldBe Color.Black test("queenside castling executes successfully"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: engine.turn shouldBe Color.Black test("undo castling emits PGN notation"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: // ── En passant ────────────────────────────────────────────────── test("en passant capture executes successfully"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: observer.hasEvent[MoveExecutedEvent] shouldBe true val moveEvt = observer.getEvent[MoveExecutedEvent] - moveEvt.get.capturedPiece shouldBe defined // pawn was captured + moveEvt.get.capturedPiece shouldBe defined // pawn was captured test("undo en passant emits file-x-destination notation"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: // ── Pawn promotion ───────────────────────────────────────────── test("pawn reaching back rank requires promotion"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: engine.turn shouldBe Color.Black test("promotion to Queen with discovered check emits CheckDetectedEvent"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: observer.hasEvent[CheckDetectedEvent] shouldBe true test("promotion to Queen with checkmate emits CheckmateEvent"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) @@ -171,7 +171,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers: observer.hasEvent[CheckmateEvent] shouldBe true test("undo promotion emits notation with piece suffix"): - val engine = EngineTestHelpers.makeEngine() + val engine = EngineTestHelpers.makeEngine() val observer = new EngineTestHelpers.MockObserver() engine.subscribe(observer) diff --git a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala index bd69ba5..f84d4fb 100644 --- a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala +++ b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala @@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets import scala.util.Try /** Service for persisting and loading game states to/from disk. - * - * Abstracts file I/O operations away from the UI layer. - * Handles both reading and writing game files. - */ + * + * Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files. + */ trait GameFileService: def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] @@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService: () }.fold( ex => Left(s"Failed to save file: ${ex.getMessage}"), - _ => Right(()) + _ => Right(()), ) /** Load a game context from a file using the specified importer. */ @@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService: importer.importGameContext(json) }.fold( ex => Left(s"Failed to load file: ${ex.getMessage}"), - result => result + result => result, ) diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala index 1af88a6..8ba9303 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala @@ -15,28 +15,22 @@ object FenExporter extends GameContextExport: /** Build the FEN representation for a single rank. */ private def buildRankString(board: Board, rank: Rank): String = val rankSquares = File.values.map(file => Square(file, rank)) - val rankChars = scala.collection.mutable.ListBuffer[Char]() - var emptyCount = 0 - - for square <- rankSquares do - board.pieceAt(square) match - case Some(piece) => - if emptyCount > 0 then - rankChars += emptyCount.toString.charAt(0) - emptyCount = 0 - rankChars += pieceToFenChar(piece) - case None => - emptyCount += 1 - - if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0) - rankChars.mkString + val (result, emptyCount) = rankSquares.foldLeft(("", 0)): + case ((acc, empty), square) => + board.pieceAt(square) match + case Some(piece) => + val flushed = if empty > 0 then acc + empty.toString else acc + (flushed + pieceToFenChar(piece), 0) + case None => + (acc, empty + 1) + if emptyCount > 0 then result + emptyCount.toString else result /** Convert a GameContext to a complete FEN string. */ def gameContextToFen(context: GameContext): String = val piecePlacement = boardToFen(context.board) - val activeColor = if context.turn == Color.White then "w" else "b" - val castling = castlingString(context.castlingRights) - val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-") + val activeColor = if context.turn == Color.White then "w" else "b" + val castling = castlingString(context.castlingRights) + val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-") val fullMoveNumber = 1 + (context.moves.length / 2) s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" @@ -44,10 +38,10 @@ object FenExporter extends GameContextExport: /** Convert castling rights to FEN notation. */ private def castlingString(rights: CastlingRights): String = - val wk = if rights.whiteKingSide then "K" else "" - val wq = if rights.whiteQueenSide then "Q" else "" - val bk = if rights.blackKingSide then "k" else "" - val bq = if rights.blackQueenSide then "q" else "" + val wk = if rights.whiteKingSide then "K" else "" + val wq = if rights.whiteQueenSide then "Q" else "" + val bk = if rights.blackKingSide then "k" else "" + val bq = if rights.blackQueenSide then "q" else "" val result = s"$wk$wq$bk$bq" if result.isEmpty then "-" else result @@ -61,4 +55,3 @@ object FenExporter extends GameContextExport: case PieceType.Queen => 'q' case PieceType.King => 'k' if piece.color == Color.White then base.toUpper else base - diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala index 7f4a173..1f206ff 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala @@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport object FenParser extends GameContextImport: - /** Parse a complete FEN string into a GameContext. - * Returns Left with error message if the format is invalid. */ + /** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid. + */ def parseFen(fen: String): Either[String, GameContext] = val parts = fen.trim.split("\\s+") - if parts.length != 6 then - Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}") + if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}") else for - board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position") - activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')") + board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position") + activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')") castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights") - enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square") - halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)") + enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square") + halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)") fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)") - _ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts") + _ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts") yield GameContext( board = board, turn = activeColor, castlingRights = castlingRights, enPassantSquare = enPassant, halfMoveClock = halfMoveClock, - moves = List.empty + moves = List.empty, ) def importGameContext(input: String): Either[String, GameContext] = @@ -41,25 +40,26 @@ object FenParser extends GameContextImport: /** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */ private def parseCastling(s: String): Option[CastlingRights] = - if s == "-" then - Some(CastlingRights.None) + if s == "-" then Some(CastlingRights.None) else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then - Some(CastlingRights( - whiteKingSide = s.contains('K'), - whiteQueenSide = s.contains('Q'), - blackKingSide = s.contains('k'), - blackQueenSide = s.contains('q') - )) - else - None + Some( + CastlingRights( + whiteKingSide = s.contains('K'), + whiteQueenSide = s.contains('Q'), + blackKingSide = s.contains('k'), + blackQueenSide = s.contains('q'), + ), + ) + else None /** Parse en passant target square ("-" for none, or algebraic like "e3"). */ private def parseEnPassant(s: String): Option[Option[Square]] = if s == "-" then Some(None) else Square.fromAlgebraic(s).map(Some(_)) - /** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. - * Returns None if the format is invalid. */ + /** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format + * is invalid. + */ def parseBoard(fen: String): Option[Board] = val rankStrings = fen.split("/", -1) if rankStrings.length != 8 then None @@ -73,28 +73,22 @@ object FenParser extends GameContextImport: parsePieceRank(rankStr, rank).map(squares => acc :+ squares) parsedRanks.map(ranks => Board(ranks.flatten.toMap)) - /** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. - * Returns None if the rank string contains invalid characters or the wrong number of files. */ + /** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the + * rank string contains invalid characters or the wrong number of files. + */ private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] = - var fileIdx = 0 - val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]() - var failed = false - - for c <- rankStr if !failed do - if fileIdx > 7 then - failed = true - else if c.isDigit then - fileIdx += c.asDigit - else - charToPiece(c) match - case None => failed = true - case Some(piece) => - val file = File.values(fileIdx) - squares += (Square(file, rank) -> piece) - fileIdx += 1 - + val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])): + case ((idx, true, acc), _) => (idx, true, acc) + case ((idx, false, acc), c) => + if idx > 7 then (idx, true, acc) + else if c.isDigit then (idx + c.asDigit, false, acc) + else + charToPiece(c) match + case None => (idx, true, acc) + case Some(piece) => + (idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece)) if failed || fileIdx != 8 then None - else Some(squares.toList) + else Some(squares) /** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */ private def charToPiece(c: Char): Option[Piece] = @@ -108,4 +102,3 @@ object FenParser extends GameContextImport: case 'k' => Some(PieceType.King) case _ => None pieceTypeOpt.map(pt => Piece(color, pt)) - diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala index a8e77ca..f51b46f 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala @@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport: private def pieceChar: Parser[Piece] = "[prnbqkPRNBQK]".r ^^ { s => - val c = s.head + val c = s.head val color = if c.isUpper then Color.White else Color.Black Piece(color, charToPieceType(c.toLower)) } @@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport: private def rankTokens: Parser[List[RankToken]] = rep1(rankToken) - /** Parse rank string for a given Rank, producing (Square, Piece) pairs. - * Fails if total file count != 8 or any piece placement exceeds board bounds. */ + /** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece + * placement exceeds board bounds. + */ private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] = rankTokens >> { tokens => buildSquares(rank, tokens) match @@ -45,16 +46,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport: /** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */ private def boardParser: Parser[Board] = rankParser(Rank.R8) ~ - (rankSep ~> rankParser(Rank.R7)) ~ - (rankSep ~> rankParser(Rank.R6)) ~ - (rankSep ~> rankParser(Rank.R5)) ~ - (rankSep ~> rankParser(Rank.R4)) ~ - (rankSep ~> rankParser(Rank.R3)) ~ - (rankSep ~> rankParser(Rank.R2)) ~ - (rankSep ~> rankParser(Rank.R1)) ^^ { - case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 => + (rankSep ~> rankParser(Rank.R7)) ~ + (rankSep ~> rankParser(Rank.R6)) ~ + (rankSep ~> rankParser(Rank.R5)) ~ + (rankSep ~> rankParser(Rank.R4)) ~ + (rankSep ~> rankParser(Rank.R3)) ~ + (rankSep ~> rankParser(Rank.R2)) ~ + (rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 => Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) - } + } // ── Color parser ───────────────────────────────────────────────────────── @@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport: private def castlingParser: Parser[CastlingRights] = "-" ^^^ CastlingRights.None | - "[KQkq]{1,4}".r ^^ { s => - CastlingRights( - whiteKingSide = s.contains('K'), - whiteQueenSide = s.contains('Q'), - blackKingSide = s.contains('k'), - blackQueenSide = s.contains('q') - ) - } + "[KQkq]{1,4}".r ^^ { s => + CastlingRights( + whiteKingSide = s.contains('K'), + whiteQueenSide = s.contains('Q'), + blackKingSide = s.contains('k'), + blackQueenSide = s.contains('q'), + ) + } // ── En passant parser ──────────────────────────────────────────────────── private def enPassantParser: Parser[Option[Square]] = "-" ^^^ Option.empty[Square] | - "[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) } + "[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) } // ── Clock parser ───────────────────────────────────────────────────────── @@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport: private def fenParser: Parser[GameContext] = boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~ - (" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ { - case board ~ color ~ castling ~ ep ~ halfMove ~ _ => - GameContext( - board = board, - turn = color, - castlingRights = castling, - enPassantSquare = ep, - halfMoveClock = halfMove, - moves = List.empty - ) - } + (" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ { + case board ~ color ~ castling ~ ep ~ halfMove ~ _ => + GameContext( + board = board, + turn = color, + castlingRights = castling, + enPassantSquare = ep, + halfMoveClock = halfMove, + moves = List.empty, + ) + } // ── Public API ─────────────────────────────────────────────────────────── diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala index df129c3..4a8ffca 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala @@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport: private def pieceChar(using P[Any]): P[Piece] = CharIn("prnbqkPRNBQK").!.map { s => - val c = s.head + val c = s.head val color = if c.isUpper then Color.White else Color.Black Piece(color, charToPieceType(c.toLower)) } @@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport: private def boardParser(using P[Any]): P[Board] = (rankParser(Rank.R8) ~ sep ~ - rankParser(Rank.R7) ~ sep ~ - rankParser(Rank.R6) ~ sep ~ - rankParser(Rank.R5) ~ sep ~ - rankParser(Rank.R4) ~ sep ~ - rankParser(Rank.R3) ~ sep ~ - rankParser(Rank.R2) ~ sep ~ - rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) => + rankParser(Rank.R7) ~ sep ~ + rankParser(Rank.R6) ~ sep ~ + rankParser(Rank.R5) ~ sep ~ + rankParser(Rank.R4) ~ sep ~ + rankParser(Rank.R3) ~ sep ~ + rankParser(Rank.R2) ~ sep ~ + rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) => Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) } @@ -61,20 +61,20 @@ object FenParserFastParse extends GameContextImport: private def castlingParser(using P[Any]): P[CastlingRights] = LiteralStr("-").map(_ => CastlingRights.None) | - CharsWhileIn("KQkq").!.map { s => - CastlingRights( - whiteKingSide = s.contains('K'), - whiteQueenSide = s.contains('Q'), - blackKingSide = s.contains('k'), - blackQueenSide = s.contains('q') - ) - } + CharsWhileIn("KQkq").!.map { s => + CastlingRights( + whiteKingSide = s.contains('K'), + whiteQueenSide = s.contains('Q'), + blackKingSide = s.contains('k'), + blackQueenSide = s.contains('q'), + ) + } // ── En passant parser ──────────────────────────────────────────────────── private def enPassantParser(using P[Any]): P[Option[Square]] = LiteralStr("-").map(_ => Option.empty[Square]) | - (CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s)) + (CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s)) // ── Clock parser ───────────────────────────────────────────────────────── @@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport: private def fenParser(using P[Any]): P[GameContext] = (boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~ - enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map { + enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map { case (board, color, castling, ep, halfMove, _) => GameContext( - board = board, - turn = color, - castlingRights = castling, + board = board, + turn = color, + castlingRights = castling, enPassantSquare = ep, - halfMoveClock = halfMove, - moves = List.empty + halfMoveClock = halfMove, + moves = List.empty, ) } diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala index ea33502..2771642 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala @@ -14,19 +14,20 @@ private[fen] object FenParserSupport: 'n' -> PieceType.Knight, 'b' -> PieceType.Bishop, 'q' -> PieceType.Queen, - 'k' -> PieceType.King + 'k' -> PieceType.King, ) def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] = - tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))): - case (None, _) => None - case (Some((acc, fileIdx)), PieceToken(piece)) => - if fileIdx > 7 then None - else - val sq = Square(File.values(fileIdx), rank) - Some((acc :+ (sq -> piece), fileIdx + 1)) - case (Some((acc, fileIdx)), EmptyToken(n)) => - val next = fileIdx + n - if next > 8 then None - else Some((acc, next)) - .flatMap { case (squares, total) => if total == 8 then Some(squares) else None } + tokens + .foldLeft(Option((List.empty[(Square, Piece)], 0))): + case (None, _) => None + case (Some((acc, fileIdx)), PieceToken(piece)) => + if fileIdx > 7 then None + else + val sq = Square(File.values(fileIdx), rank) + Some((acc :+ (sq -> piece), fileIdx + 1)) + case (Some((acc, fileIdx)), EmptyToken(n)) => + val next = fileIdx + n + if next > 8 then None + else Some((acc, next)) + .flatMap { case (squares, total) => if total == 8 then Some(squares) else None } diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala index 0524d46..44ba4cc 100644 --- a/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala @@ -8,31 +8,31 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.game.GameContext import de.nowchess.io.GameContextExport import de.nowchess.io.pgn.PgnExporter -import java.time.{LocalDate, ZonedDateTime, ZoneId} +import java.time.{LocalDate, ZoneId, ZonedDateTime} /** Exports a GameContext to a comprehensive JSON format using Jackson. - * - * The JSON includes: - * - Game metadata (players, event, date, result) - * - Board state (all pieces and their positions) - * - Current game state (turn, castling rights, en passant, half-move clock) - * - Move history in both algebraic notation (PGN) and detailed move objects - * - Captured pieces tracking (which pieces have been removed) - * - Timestamp for record-keeping - */ + * + * The JSON includes: + * - Game metadata (players, event, date, result) + * - Board state (all pieces and their positions) + * - Current game state (turn, castling rights, en passant, half-move clock) + * - Move history in both algebraic notation (PGN) and detailed move objects + * - Captured pieces tracking (which pieces have been removed) + * - Timestamp for record-keeping + */ object JsonExporter extends GameContextExport: private val mapper = createMapper() - + private def createMapper(): ObjectMapper = val mapper = new ObjectMapper() .registerModule(DefaultScalaModule) - + // Configure pretty printer with custom spacing to match test expectations val indenter = new DefaultIndenter(" ", "\n") - val printer = new DefaultPrettyPrinter() + val printer = new DefaultPrettyPrinter() printer.indentArraysWith(indenter) printer.indentObjectsWith(indenter) - + mapper.setDefaultPrettyPrinter(printer) mapper.enable(SerializationFeature.INDENT_OUTPUT) mapper @@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport: formatJson(mapper.writeValueAsString(record)) private def buildGameRecord(context: GameContext): JsonGameRecord = - val pgn = try { - Some(PgnExporter.exportGameContext(context)) - } catch { - case _: Exception => None - } + val pgn = + try + Some(PgnExporter.exportGameContext(context)) + catch { + case _: Exception => None + } JsonGameRecord( metadata = Some(buildMetadata()), gameState = Some(buildGameState(context)), moveHistory = pgn, moves = Some(buildMoves(context.moves)), capturedPieces = Some(buildCapturedPieces(context.board)), - timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString) + timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString), ) private def buildMetadata(): JsonMetadata = @@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport: event = Some("Game"), players = Some(Map("white" -> "White Player", "black" -> "Black Player")), date = Some(LocalDate.now().toString), - result = Some("*") + result = Some("*"), ) private def buildGameState(context: GameContext): JsonGameState = @@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport: turn = Some(context.turn.label), castlingRights = Some(buildCastlingRights(context.castlingRights)), enPassantSquare = context.enPassantSquare.map(_.toString), - halfMoveClock = Some(context.halfMoveClock) + halfMoveClock = Some(context.halfMoveClock), ) private def buildBoardPieces(board: Board): List[JsonPiece] = @@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport: Some(rights.whiteKingSide), Some(rights.whiteQueenSide), Some(rights.blackKingSide), - Some(rights.blackQueenSide) + Some(rights.blackQueenSide), ) private def buildMoves(moves: List[Move]): List[JsonMove] = @@ -128,12 +129,11 @@ object JsonExporter extends GameContextExport: val captured = Square.all.flatMap { square => initialBoard.pieceAt(square).flatMap { initialPiece => board.pieceAt(square) match - case None => Some(initialPiece) + case None => Some(initialPiece) case Some(_) => None } } - + val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList (blackCaptured, whiteCaptured) - diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala index 208140e..015a0fc 100644 --- a/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala @@ -1,55 +1,55 @@ package de.nowchess.io.json case class JsonMetadata( - event: Option[String] = None, - players: Option[Map[String, String]] = None, - date: Option[String] = None, - result: Option[String] = None + event: Option[String] = None, + players: Option[Map[String, String]] = None, + date: Option[String] = None, + result: Option[String] = None, ) case class JsonPiece( - square: Option[String] = None, - color: Option[String] = None, - piece: Option[String] = None + square: Option[String] = None, + color: Option[String] = None, + piece: Option[String] = None, ) case class JsonCastlingRights( - whiteKingSide: Option[Boolean] = None, - whiteQueenSide: Option[Boolean] = None, - blackKingSide: Option[Boolean] = None, - blackQueenSide: Option[Boolean] = None + whiteKingSide: Option[Boolean] = None, + whiteQueenSide: Option[Boolean] = None, + blackKingSide: Option[Boolean] = None, + blackQueenSide: Option[Boolean] = None, ) case class JsonGameState( - board: Option[List[JsonPiece]] = None, - turn: Option[String] = None, - castlingRights: Option[JsonCastlingRights] = None, - enPassantSquare: Option[String] = None, - halfMoveClock: Option[Int] = None + board: Option[List[JsonPiece]] = None, + turn: Option[String] = None, + castlingRights: Option[JsonCastlingRights] = None, + enPassantSquare: Option[String] = None, + halfMoveClock: Option[Int] = None, ) case class JsonCapturedPieces( - byWhite: Option[List[String]] = None, - byBlack: Option[List[String]] = None + byWhite: Option[List[String]] = None, + byBlack: Option[List[String]] = None, ) case class JsonMoveType( - `type`: Option[String] = None, - isCapture: Option[Boolean] = None, - promotionPiece: Option[String] = None + `type`: Option[String] = None, + isCapture: Option[Boolean] = None, + promotionPiece: Option[String] = None, ) case class JsonMove( - from: Option[String] = None, - to: Option[String] = None, - `type`: Option[JsonMoveType] = None + from: Option[String] = None, + to: Option[String] = None, + `type`: Option[JsonMoveType] = None, ) case class JsonGameRecord( - metadata: Option[JsonMetadata] = None, - gameState: Option[JsonGameState] = None, - moveHistory: Option[String] = None, - moves: Option[List[JsonMove]] = None, - capturedPieces: Option[JsonCapturedPieces] = None, - timestamp: Option[String] = None + metadata: Option[JsonMetadata] = None, + gameState: Option[JsonGameState] = None, + moveHistory: Option[String] = None, + moves: Option[List[JsonMove]] = None, + capturedPieces: Option[JsonCapturedPieces] = None, + timestamp: Option[String] = None, ) diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala index deefc4c..ca283a6 100644 --- a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala @@ -1,6 +1,6 @@ package de.nowchess.io.json -import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature} +import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper} import com.fasterxml.jackson.module.scala.DefaultScalaModule import de.nowchess.api.board.* import de.nowchess.api.move.{Move, MoveType, PromotionPiece} @@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport import scala.util.Try /** Imports a GameContext from JSON format using Jackson. - * - * Parses JSON exported by JsonExporter and reconstructs the GameContext including: - * - Board state - * - Current turn - * - Castling rights - * - En passant square - * - Half-move clock - * - Move history - * - * Returns Left(error message) if the JSON is malformed or invalid. - */ + * + * Parses JSON exported by JsonExporter and reconstructs the GameContext including: + * - Board state + * - Current turn + * - Castling rights + * - En passant square + * - Half-move clock + * - Move history + * + * Returns Left(error message) if the JSON is malformed or invalid. + */ object JsonParser extends GameContextImport: private val mapper = new ObjectMapper() @@ -27,20 +27,20 @@ object JsonParser extends GameContextImport: .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) def importGameContext(input: String): Either[String, GameContext] = - Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither - .left.map(e => "JSON parsing error: " + e.getMessage) + Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left + .map(e => "JSON parsing error: " + e.getMessage) .flatMap { data => - val gs = data.gameState.getOrElse(JsonGameState()) + val gs = data.gameState.getOrElse(JsonGameState()) val rawBoard = gs.board.getOrElse(Nil) - val rawTurn = gs.turn.getOrElse("White") - val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights()) - val rawHmc = gs.halfMoveClock.getOrElse(0) + val rawTurn = gs.turn.getOrElse("White") + val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights()) + val rawHmc = gs.halfMoveClock.getOrElse(0) val rawMoves = data.moves.getOrElse(Nil) for board <- parseBoard(rawBoard) - turn <- parseTurn(rawTurn) - castlingRights = parseCastlingRights(rawCr) + turn <- parseTurn(rawTurn) + castlingRights = parseCastlingRights(rawCr) enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s)) moves <- parseMoves(rawMoves) yield GameContext( @@ -49,16 +49,16 @@ object JsonParser extends GameContextImport: castlingRights = castlingRights, enPassantSquare = enPassantSquare, halfMoveClock = rawHmc, - moves = moves + moves = moves, ) } private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] = val parsedPieces = pieces.flatMap { p => for - sq <- p.square.flatMap(Square.fromAlgebraic) + sq <- p.square.flatMap(Square.fromAlgebraic) color <- p.color.flatMap(parseColor) - pt <- p.piece.flatMap(parsePieceType) + pt <- p.piece.flatMap(parsePieceType) yield (sq, Piece(color, pt)) } Right(Board(parsedPieces.toMap)) @@ -73,27 +73,27 @@ object JsonParser extends GameContextImport: private def parsePieceType(pt: String): Option[PieceType] = pt match - case "Pawn" => Some(PieceType.Pawn) + case "Pawn" => Some(PieceType.Pawn) case "Knight" => Some(PieceType.Knight) case "Bishop" => Some(PieceType.Bishop) - case "Rook" => Some(PieceType.Rook) - case "Queen" => Some(PieceType.Queen) - case "King" => Some(PieceType.King) - case _ => None + case "Rook" => Some(PieceType.Rook) + case "Queen" => Some(PieceType.Queen) + case "King" => Some(PieceType.King) + case _ => None private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights = CastlingRights( cr.whiteKingSide.getOrElse(false), cr.whiteQueenSide.getOrElse(false), cr.blackKingSide.getOrElse(false), - cr.blackQueenSide.getOrElse(false) + cr.blackQueenSide.getOrElse(false), ) private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] = Right(moves.flatMap { m => for - from <- m.from.flatMap(Square.fromAlgebraic) - to <- m.to.flatMap(Square.fromAlgebraic) + from <- m.from.flatMap(Square.fromAlgebraic) + to <- m.to.flatMap(Square.fromAlgebraic) moveType <- m.`type`.flatMap(parseMoveType) yield Move(from, to, moveType) }) @@ -110,10 +110,10 @@ object JsonParser extends GameContextImport: Some(MoveType.EnPassant) case Some("promotion") => val piece = mt.promotionPiece match - case Some("queen") => PromotionPiece.Queen - case Some("rook") => PromotionPiece.Rook + case Some("queen") => PromotionPiece.Queen + case Some("rook") => PromotionPiece.Rook case Some("bishop") => PromotionPiece.Bishop case Some("knight") => PromotionPiece.Knight - case _ => PromotionPiece.Queen // default + case _ => PromotionPiece.Queen // default Some(MoveType.Promotion(piece)) case _ => None diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala index 42ccb8e..afb697a 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala @@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport: /** Export a GameContext to PGN format. */ def exportGameContext(context: GameContext): String = val headers = Map( - "Event" -> "?", - "White" -> "?", - "Black" -> "?", - "Result" -> "*" + "Event" -> "?", + "White" -> "?", + "Black" -> "?", + "Result" -> "*", ) exportGame(headers, context.moves) /** Export a game with headers and moves to PGN format. */ def exportGame(headers: Map[String, String], moves: List[Move]): String = - val headerLines = headers.map { case (key, value) => - s"""[$key "$value"]""" - }.mkString("\n") - - val moveText = if moves.isEmpty then "" - else - var ctx = GameContext.initial - val sanMoves = moves.map { move => - val algebraic = moveToAlgebraic(move, ctx.board) - ctx = DefaultRules.applyMove(ctx)(move) - algebraic + val headerLines = headers + .map { case (key, value) => + s"""[$key "$value"]""" } + .mkString("\n") - val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2) - val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield - val moveNum = moveNumber + 1 - val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") - val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") - if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" - else s"$moveNum. $whiteMoveStr $blackMoveStr" + val moveText = + if moves.isEmpty then "" + else + val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)) + val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) } - val termination = headers.getOrElse("Result", "*") - moveLines.mkString(" ") + s" $termination" + val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2) + val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield + val moveNum = moveNumber + 1 + val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") + val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") + if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" + else s"$moveNum. $whiteMoveStr $blackMoveStr" + + val termination = headers.getOrElse("Result", "*") + moveLines.mkString(" ") + s" $termination" if headerLines.isEmpty then moveText else if moveText.isEmpty then headerLines @@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport: case MoveType.CastleKingside => "O-O" case MoveType.CastleQueenside => "O-O-O" case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}" - case MoveType.Promotion(pp) => + case MoveType.Promotion(pp) => val promSuffix = pp match case PromotionPiece.Queen => "=Q" case PromotionPiece.Rook => "=R" @@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport: case PieceType.Rook => s"R$capStr$dest" case PieceType.Queen => s"Q$capStr$dest" case PieceType.King => s"K$capStr$dest" - - diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala index 1fd201b..561e2da 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala @@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules /** A parsed PGN game containing headers and the resolved move list. */ case class PgnGame( - headers: Map[String, String], - moves: List[Move] + headers: Map[String, String], + moves: List[Move], ) object PgnParser extends GameContextImport: - /** Strictly validate a PGN text. - * Returns Right(PgnGame) if every move token is a legal move in the evolving position. - * Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */ + /** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position. + * Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. + */ def validatePgn(pgn: String): Either[String, PgnGame] = - val lines = pgn.split("\n").map(_.trim) + val lines = pgn.split("\n").map(_.trim) val (headerLines, rest) = lines.span(_.startsWith("[")) - val headers = parseHeaders(headerLines) - val moveText = rest.mkString(" ") + val headers = parseHeaders(headerLines) + val moveText = rest.mkString(" ") validateMovesText(moveText).map(moves => PgnGame(headers, moves)) - /** Import a PGN text into a GameContext by validating and replaying all moves. - * Returns Right(GameContext) with all moves applied and .moves populated. - * Returns Left(error message) if validation fails or move replay encounters an issue. */ + /** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all + * moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an + * issue. + */ def importGameContext(input: String): Either[String, GameContext] = validatePgn(input).flatMap { game => Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))) } - /** Parse a complete PGN text into a PgnGame with headers and moves. - * Always succeeds (returns Some); malformed tokens are silently skipped. */ + /** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens + * are silently skipped. + */ def parsePgn(pgn: String): Option[PgnGame] = - val lines = pgn.split("\n").map(_.trim) + val lines = pgn.split("\n").map(_.trim) val (headerLines, rest) = lines.span(_.startsWith("[")) - val headers = parseHeaders(headerLines) - val moveText = rest.mkString(" ") - val moves = parseMovesText(moveText) + val headers = parseHeaders(headerLines) + val moveText = rest.mkString(" ") + val moves = parseMovesText(moveText) Some(PgnGame(headers, moves)) /** Parse PGN header lines of the form [Key "Value"]. */ @@ -51,25 +53,25 @@ object PgnParser extends GameContextImport: private def parseMovesText(moveText: String): List[Move] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) val (_, _, moves) = tokens.foldLeft( - (GameContext.initial, Color.White, List.empty[Move]) + (GameContext.initial, Color.White, List.empty[Move]), ): case (state @ (ctx, color, acc), token) => if isMoveNumberOrResult(token) then state else parseAlgebraicMove(token, ctx, color) match - case None => state + case None => state case Some(move) => - val nextCtx = DefaultRules.applyMove(ctx)(move) + val nextCtx = DefaultRules.applyMove(ctx)(move) (nextCtx, color.opposite, acc :+ move) moves /** True for move-number tokens ("1.", "12.") and PGN result tokens. */ private def isMoveNumberOrResult(token: String): Boolean = token.matches("""\d+\.""") || - token == "*" || - token == "1-0" || - token == "0-1" || - token == "1/2-1/2" + token == "*" || + token == "1-0" || + token == "0-1" || + token == "1/2-1/2" /** Parse a single algebraic notation token into a Move, given the current game context. */ def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] = @@ -98,47 +100,52 @@ object PgnParser extends GameContextImport: if clean.length < 2 then None else val destStr = clean.takeRight(2) - Square.fromAlgebraic(destStr).flatMap: toSquare => - val disambig = clean.dropRight(2) + Square + .fromAlgebraic(destStr) + .flatMap: toSquare => + val disambig = clean.dropRight(2) - val requiredPieceType: Option[PieceType] = - if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head) - else if clean.head.isUpper then charToPieceType(clean.head) - else Some(PieceType.Pawn) + val requiredPieceType: Option[PieceType] = + if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head) + else if clean.head.isUpper then charToPieceType(clean.head) + else Some(PieceType.Pawn) - val hint = - if disambig.nonEmpty && disambig.head.isUpper then disambig.tail - else disambig + val hint = + if disambig.nonEmpty && disambig.head.isUpper then disambig.tail + else disambig - val promotion = extractPromotion(notation) + val promotion = extractPromotion(notation) - // Get all legal moves for this color that reach toSquare - val allLegal = DefaultRules.allLegalMoves(ctx) - val candidates = allLegal.filter { move => - move.to == toSquare && - ctx.board.pieceAt(move.from).exists(p => - p.color == color && - requiredPieceType.forall(_ == p.pieceType) - ) && - (hint.isEmpty || matchesHint(move.from, hint)) && - promotionMatches(move, promotion) - } + // Get all legal moves for this color that reach toSquare + val allLegal = DefaultRules.allLegalMoves(ctx) + val candidates = allLegal.filter { move => + move.to == toSquare && + ctx.board + .pieceAt(move.from) + .exists(p => + p.color == color && + requiredPieceType.forall(_ == p.pieceType), + ) && + (hint.isEmpty || matchesHint(move.from, hint)) && + promotionMatches(move, promotion) + } - candidates.headOption + candidates.headOption /** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */ private def matchesHint(sq: Square, hint: String): Boolean = hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') - else true + else true, ) private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean = promotion match - case None => move.moveType match - case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true - case _ => false + case None => + move.moveType match + case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true + case _ => false case Some(pp) => move.moveType == MoveType.Promotion(pp) /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ @@ -168,17 +175,18 @@ object PgnParser extends GameContextImport: /** Walk all move tokens, failing immediately on any unresolvable or illegal move. */ private def validateMovesText(moveText: String): Either[String, List[Move]] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) - tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) { - case (acc, token) => + tokens + .foldLeft( + Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])], + ) { case (acc, token) => acc.flatMap { case (ctx, color, moves) => if isMoveNumberOrResult(token) then Right((ctx, color, moves)) else parseAlgebraicMove(token, ctx, color) match - case None => Left(s"Illegal or impossible move: '$token'") + case None => Left(s"Illegal or impossible move: '$token'") case Some(move) => - val nextCtx = DefaultRules.applyMove(ctx)(move) + val nextCtx = DefaultRules.applyMove(ctx)(move) Right((nextCtx, color.opposite, moves :+ move)) } - }.map(_._3) - - + } + .map(_._3) diff --git a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala index f4b06e9..8c0971f 100644 --- a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala @@ -1,7 +1,7 @@ package de.nowchess.io import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.board.{File, Rank, Square} import de.nowchess.api.move.Move import de.nowchess.io.json.{JsonExporter, JsonParser} import java.nio.file.{Files, Paths} @@ -15,37 +15,35 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: val tmpFile = Files.createTempFile("chess_test_", ".json") try val context = GameContext.initial - val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter) - + val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter) + assert(result.isRight) assert(Files.exists(tmpFile)) assert(Files.size(tmpFile) > 0) - finally - Files.deleteIfExists(tmpFile) + finally Files.deleteIfExists(tmpFile) } test("loadGameFromFile: reads JSON file successfully") { val tmpFile = Files.createTempFile("chess_test_", ".json") try val originalContext = GameContext.initial - + // Save FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter) - + // Load val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) - + assert(result.isRight) val loaded = result.getOrElse(GameContext.initial) assert(loaded == originalContext) - finally - Files.deleteIfExists(tmpFile) + finally Files.deleteIfExists(tmpFile) } test("loadGameFromFile: returns error on missing file") { val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json") - val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser) - + val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser) + assert(result.isLeft) } @@ -57,16 +55,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: val context = GameContext.initial .withMove(move1) .withMove(move2) - + val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter) assert(saveResult.isRight) - + val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) 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") { @@ -76,18 +73,17 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: val context1 = GameContext.initial FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter) val size1 = Files.size(tmpFile) - + // Write second file (should overwrite) - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val context2 = GameContext.initial.withMove(move) FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter) - + val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) 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") { @@ -95,10 +91,9 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: try Files.write(tmpFile, "{ invalid json}".getBytes()) 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") { @@ -110,16 +105,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: .withMove(move1) .withMove(move2) .withHalfMoveClock(3) - + FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter) val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) - + assert(loadResult.isRight) 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") { @@ -127,13 +121,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers: try val context = GameContext.initial val faultyExporter = new GameContextExport { - def exportGameContext(c: GameContext): String = + def exportGameContext(c: GameContext): String = throw new RuntimeException("Export failed") } - + 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) } diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala index 95ad45e..6e524b4 100644 --- a/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala @@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers class FenExporterTest extends AnyFunSuite with Matchers: private def context( - piecePlacement: String, - turn: Color, - castlingRights: CastlingRights, - enPassantSquare: Option[Square], - halfMoveClock: Int, - moveCount: Int + piecePlacement: String, + turn: Color, + castlingRights: CastlingRights, + enPassantSquare: Option[Square], + halfMoveClock: Int, + moveCount: Int, ): GameContext = - val board = FenParser.parseBoard(piecePlacement).getOrElse( - fail(s"Invalid test board FEN: $piecePlacement") - ) + val board = FenParser + .parseBoard(piecePlacement) + .getOrElse( + fail(s"Invalid test board FEN: $piecePlacement"), + ) val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3)) GameContext( board = board, @@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: castlingRights = castlingRights, enPassantSquare = enPassantSquare, halfMoveClock = halfMoveClock, - moves = List.fill(moveCount)(dummyMove) + moves = List.fill(moveCount)(dummyMove), ) test("exportGameContextToFen handles initial and typical developed position"): @@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: castlingRights = CastlingRights.All, enPassantSquare = Some(Square(File.E, Rank.R3)), halfMoveClock = 0, - moveCount = 0 + moveCount = 0, ) FenExporter.gameContextToFen(gameContext) shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" @@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: castlingRights = CastlingRights.None, enPassantSquare = None, halfMoveClock = 0, - moveCount = 0 + moveCount = 0, ) FenExporter.gameContextToFen(noCastling) shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" @@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers: whiteKingSide = true, whiteQueenSide = false, blackKingSide = false, - blackQueenSide = true + blackQueenSide = true, ), enPassantSquare = None, halfMoveClock = 5, - moveCount = 4 + moveCount = 4, ) FenExporter.gameContextToFen(partialCastling) shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3" @@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: castlingRights = CastlingRights.All, enPassantSquare = Some(Square(File.C, Rank.R6)), halfMoveClock = 2, - moveCount = 4 + moveCount = 4, ) FenExporter.gameContextToFen(withEnPassant) shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" @@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: castlingRights = CastlingRights.All, enPassantSquare = None, halfMoveClock = 42, - moves = List.empty + moves = List.empty, ) val fen = FenExporter.gameContextToFen(gameContext) FenParser.parseFen(fen) match @@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers: val ctx = GameContext.initial FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx) - diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala index e9a2857..6b5a843 100644 --- a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala @@ -8,34 +8,52 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers: test("parseBoard parses canonical positions and supports round-trip"): val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - val empty = "8/8/8/8/8/8/8/8" + val empty = "8/8/8/8/8/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8" - FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) - FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing)) + FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some( + Some(Piece.WhitePawn), + ) + FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some( + Some(Piece.BlackKing), + ) FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0) - FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing)) + FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some( + Some(Piece.BlackKing), + ) FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial) FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) test("parseFen parses full state for common valid inputs"): - FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.White - ctx.castlingRights.whiteKingSide shouldBe true - ctx.enPassantSquare shouldBe None - ctx.halfMoveClock shouldBe 0 - ) + FenParserCombinators + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.White + ctx.castlingRights.whiteKingSide shouldBe true + ctx.enPassantSquare shouldBe None + ctx.halfMoveClock shouldBe 0, + ) - FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.Black - ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) - ) + FenParserCombinators + .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.Black + ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), + ) - FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => - ctx.castlingRights.whiteKingSide shouldBe false - ctx.castlingRights.blackQueenSide shouldBe false - ) + FenParserCombinators + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1") + .fold( + _ => fail(), + ctx => + ctx.castlingRights.whiteKingSide shouldBe false + ctx.castlingRights.blackQueenSide shouldBe false, + ) test("parseFen rejects invalid color and castling tokens"): FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala index 4647d2e..56f10f3 100644 --- a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala @@ -8,7 +8,7 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers: test("parseBoard parses canonical positions and supports round-trip"): val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - val empty = "8/8/8/8/8/8/8/8" + val empty = "8/8/8/8/8/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8" FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) @@ -20,22 +20,34 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers: FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) test("parseFen parses full state for common valid inputs"): - FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.White - ctx.castlingRights.whiteKingSide shouldBe true - ctx.enPassantSquare shouldBe None - ctx.halfMoveClock shouldBe 0 - ) + FenParserFastParse + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.White + ctx.castlingRights.whiteKingSide shouldBe true + ctx.enPassantSquare shouldBe None + ctx.halfMoveClock shouldBe 0, + ) - FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.Black - ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) - ) + FenParserFastParse + .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.Black + ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), + ) - FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => - ctx.castlingRights.whiteKingSide shouldBe false - ctx.castlingRights.blackQueenSide shouldBe false - ) + FenParserFastParse + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1") + .fold( + _ => fail(), + ctx => + ctx.castlingRights.whiteKingSide shouldBe false + ctx.castlingRights.blackQueenSide shouldBe false, + ) test("parseFen rejects invalid color and castling tokens"): FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala index dea534e..0626320 100644 --- a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserTest.scala @@ -8,7 +8,7 @@ class FenParserTest extends AnyFunSuite with Matchers: test("parseBoard parses canonical positions and supports round-trip"): val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" - val empty = "8/8/8/8/8/8/8/8" + val empty = "8/8/8/8/8/8/8/8" val partial = "8/8/4k3/8/4K3/8/8/8" FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) @@ -20,22 +20,34 @@ class FenParserTest extends AnyFunSuite with Matchers: FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) test("parseFen parses full state for common valid inputs"): - FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.White - ctx.castlingRights.whiteKingSide shouldBe true - ctx.enPassantSquare shouldBe None - ctx.halfMoveClock shouldBe 0 - ) + FenParser + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.White + ctx.castlingRights.whiteKingSide shouldBe true + ctx.enPassantSquare shouldBe None + ctx.halfMoveClock shouldBe 0, + ) - FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => - ctx.turn shouldBe Color.Black - ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) - ) + FenParser + .parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1") + .fold( + _ => fail(), + ctx => + ctx.turn shouldBe Color.Black + ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)), + ) - FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => - ctx.castlingRights.whiteKingSide shouldBe false - ctx.castlingRights.blackQueenSide shouldBe false - ) + FenParser + .parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1") + .fold( + _ => fail(), + ctx => + ctx.castlingRights.whiteKingSide shouldBe false + ctx.castlingRights.blackQueenSide shouldBe false, + ) test("parseFen rejects invalid color and castling tokens"): FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true @@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers: FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None - diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala index 2ae9d59..e61cb1e 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala @@ -1,7 +1,7 @@ package de.nowchess.io.json import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType} +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers: (PromotionPiece.Queen, "queen"), (PromotionPiece.Rook, "rook"), (PromotionPiece.Bishop, "bishop"), - (PromotionPiece.Knight, "knight") + (PromotionPiece.Knight, "knight"), ) - for ((piece, expectedName) <- promotions) do + for (piece, expectedName) <- promotions do val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece)) // Empty boards can cause issues in PgnExporter, using initial val ctx = GameContext.initial.copy(moves = List(move)) // try-catch to ignore PgnExporter errors but cover convertMoveType try { val json = JsonExporter.exportGameContext(ctx) - json should include (s""""$expectedName"""") + json should include(s""""$expectedName"""") } catch { case _: Exception => } } test("export normal non-capture move") { val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)) - val ctx = GameContext.initial.copy(moves = List(quietMove)) - val json = JsonExporter.exportGameContext(ctx) - json should include ("\"normal\"") + val ctx = GameContext.initial.copy(moves = List(quietMove)) + val json = JsonExporter.exportGameContext(ctx) + json should include("\"normal\"") } test("export normal capture move manually") { val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)) - val ctx = GameContext.initial.copy(moves = List(move)) + val ctx = GameContext.initial.copy(moves = List(move)) try { val json = JsonExporter.exportGameContext(ctx) - json should include ("\"normal\"") - json should include ("\"isCapture\": true") + json should include("\"normal\"") + json should include("\"isCapture\": true") } catch { case _: Exception => } } test("export all move type categories") { val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4)) - val ctx = GameContext.initial.copy(moves = List(move)) + val ctx = GameContext.initial.copy(moves = List(move)) val json = JsonExporter.exportGameContext(ctx) - - json should include ("\"moves\"") - json should include ("\"from\"") - json should include ("\"to\"") + + json should include("\"moves\"") + json should include("\"from\"") + json should include("\"to\"") } test("export castle queenside move") { val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside) - val ctx = GameContext.initial.copy(moves = List(move)) + val ctx = GameContext.initial.copy(moves = List(move)) try { val json = JsonExporter.exportGameContext(ctx) - json should include ("\"castleQueenside\"") + json should include("\"castleQueenside\"") } catch { case _: Exception => } } test("export castle kingside move") { val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) - val ctx = GameContext.initial.copy(moves = List(move)) + val ctx = GameContext.initial.copy(moves = List(move)) try { val json = JsonExporter.exportGameContext(ctx) - json should include ("\"castleKingside\"") + json should include("\"castleKingside\"") } catch { case _: Exception => } } test("export en passant move manually") { val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) - val ctx = GameContext.initial.copy(moves = List(move)) + val ctx = GameContext.initial.copy(moves = List(move)) try { val json = JsonExporter.exportGameContext(ctx) - json should include ("\"enPassant\"") - json should include ("\"isCapture\": true") + json should include("\"enPassant\"") + json should include("\"isCapture\": true") } catch { case _: Exception => } } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala index 76f2500..0f7b70e 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala @@ -1,7 +1,7 @@ package de.nowchess.io.json import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights} +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -10,8 +10,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers: test("exportGameContext: exports initial position") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"metadata\"") json should include("\"gameState\"") json should include("\"moveHistory\"") @@ -21,8 +21,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers: test("exportGameContext: includes board pieces") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"a1\"") json should include("\"Rook\"") json should include("\"White\"") @@ -30,24 +30,24 @@ class JsonExporterSuite extends AnyFunSuite with Matchers: test("exportGameContext: includes turn information") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"turn\": \"White\"") } test("exportGameContext: includes castling rights") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"whiteKingSide\": true") json should include("\"whiteQueenSide\": true") } test("exportGameContext: exports with moves") { - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val context = GameContext.initial.withMove(move) - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"moves\"") json should include("\"from\"") json should include("\"to\"") @@ -57,8 +57,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers: test("exportGameContext: valid JSON structure") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should startWith("{") json should endWith("}") json should include("\"metadata\": {") @@ -67,47 +67,47 @@ class JsonExporterSuite extends AnyFunSuite with Matchers: test("exportGameContext: empty move history for initial position") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"moves\": []") } test("exportGameContext: exports en passant square") { val epSquare = Some(Square(File.E, Rank.R3)) - val context = GameContext.initial.copy(enPassantSquare = epSquare) - val json = JsonExporter.exportGameContext(context) - + val context = GameContext.initial.copy(enPassantSquare = epSquare) + val json = JsonExporter.exportGameContext(context) + json should include("\"enPassantSquare\": \"e3\"") } test("exportGameContext: exports null en passant square") { val context = GameContext.initial.copy(enPassantSquare = None) - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"enPassantSquare\": null") } test("exportGameContext: exports different move destinations") { - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val context = GameContext.initial.withMove(move) - val json = JsonExporter.exportGameContext(context) - + val json = JsonExporter.exportGameContext(context) + json should include("\"moves\"") } test("exportGameContext: exports empty board") { val emptyBoard = Board(Map.empty) - val context = GameContext.initial.copy(board = emptyBoard) - val json = JsonExporter.exportGameContext(context) - + val context = GameContext.initial.copy(board = emptyBoard) + val json = JsonExporter.exportGameContext(context) + json should include("\"board\": []") } test("exportGameContext: exports all castling rights disabled") { val noCastling = CastlingRights(false, false, false, false) - val context = GameContext.initial.withCastlingRights(noCastling) - val json = JsonExporter.exportGameContext(context) - + val context = GameContext.initial.withCastlingRights(noCastling) + val json = JsonExporter.exportGameContext(context) + json should include("\"whiteKingSide\": false") json should include("\"whiteQueenSide\": false") json should include("\"blackKingSide\": false") diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala index b14ea20..5307f21 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala @@ -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) diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala index 8f7f717..0257ceb 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala @@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: test("parse invalid turn color returns error") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "Invalid", "board": []}, "moves": [] @@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse invalid piece type filters it out") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", @@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse invalid color in board filters piece") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", @@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse with missing turn uses default") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"board": []}, "moves": [] @@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse with missing board uses empty") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White"}, "moves": [] @@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse with missing moves uses empty list") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White", "board": []} }""" @@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse invalid square in board filters it") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", @@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: } test("parse all valid piece types") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", @@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: assert(result.isRight) val ctx = result.toOption.get assert(ctx.board.pieces.size == 6) - assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn) + assert( + ctx.board + .pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)) + .get + .pieceType == PieceType.Pawn, + ) } test("parse with all castling rights false") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala index d62e32c..6e296b4 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala @@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers: test("parse completely invalid JSON returns error") { val invalidJson = "{ this is not valid json at all }" - val result = JsonParser.importGameContext(invalidJson) + val result = JsonParser.importGameContext(invalidJson) assert(result.isLeft) assert(result.left.toOption.get.contains("JSON parsing error")) } @@ -26,26 +26,26 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers: test("parse malformed JSON object returns error") { val malformed = """{"metadata": {"unclosed": """ - val result = JsonParser.importGameContext(malformed) + val result = JsonParser.importGameContext(malformed) assert(result.isLeft) assert(result.left.toOption.get.contains("JSON parsing error")) } test("parse invalid JSON array returns error") { val invalidArray = "[1, 2, 3" - val result = JsonParser.importGameContext(invalidArray) + val result = JsonParser.importGameContext(invalidArray) assert(result.isLeft) } test("parse JSON with missing required fields") { - val json = """{"metadata": {}}""" + val json = """{"metadata": {}}""" val result = JsonParser.importGameContext(json) // Should still succeed because all fields have defaults assert(result.isRight) } test("parse valid JSON with invalid turn falls back to default") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White", "board": []}, "moves": [] diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala index d36ca21..0e47d10 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala @@ -1,7 +1,7 @@ package de.nowchess.io.json import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank} +import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: test("parse all move type variations") { - val json = """{ + val json = """{ "metadata": {"event": "Game", "result": "*"}, "gameState": {"turn": "White", "board": []}, "moves": [ @@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: } test("parse invalid move type defaults to None") { - val json = """{ + val json = """{ "metadata": {"event": "Game"}, "gameState": {"turn": "White", "board": []}, "moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}] @@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: } test("parse promotion with default piece") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White", "board": []}, "moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}] @@ -56,7 +56,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: } test("parse move with missing from/to skips it") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White", "board": []}, "moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}] @@ -69,26 +69,26 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: } test("parse with invalid JSON returns error") { - val json = """{"invalid json""" + val json = """{"invalid json""" val result = JsonParser.importGameContext(json) assert(result.isLeft) } test("parse normal move with isCapture true") { - val json = """{ + val json = """{ "metadata": {}, "gameState": {"turn": "White", "board": []}, "moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}] }""" val result = JsonParser.importGameContext(json) assert(result.isRight) - val ctx = result.toOption.get + val ctx = result.toOption.get val move = ctx.moves.head assert(move.moveType == MoveType.Normal(true)) } test("parse board with invalid pieces filters them") { - val json = """{ + val json = """{ "metadata": {}, "gameState": { "turn": "White", diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala index a499b34..d787efa 100644 --- a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala @@ -1,7 +1,7 @@ package de.nowchess.io.json import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights} +import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -9,39 +9,39 @@ import org.scalatest.matchers.should.Matchers class JsonParserSuite extends AnyFunSuite with Matchers: test("importGameContext: parses valid JSON") { - val json = JsonExporter.exportGameContext(GameContext.initial) + val json = JsonExporter.exportGameContext(GameContext.initial) val result = JsonParser.importGameContext(json) - + assert(result.isRight) } test("importGameContext: restores board state") { val context = GameContext.initial - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result == Right(context)) } test("importGameContext: restores turn") { val context = GameContext.initial.withTurn(Color.Black) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.turn) == Right(Color.Black)) } test("importGameContext: restores moves") { - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val context = GameContext.initial.withMove(move) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.moves.length) == Right(1)) } test("importGameContext: handles empty board") { - val json = """{ + val json = """{ "metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"}, "gameState": { "board": [], @@ -56,30 +56,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers: "timestamp": "2026-04-06T00:00:00Z" }""" val result = JsonParser.importGameContext(json) - + assert(result.isRight) assert(result.map(_.board.pieces.isEmpty) == Right(true)) } test("importGameContext: returns error on invalid JSON") { val result = JsonParser.importGameContext("not valid json {{{") - + assert(result.isLeft) } 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) } test("importGameContext: handles castling rights") { val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false) - val context = GameContext.initial.withCastlingRights(newCastling) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val context = GameContext.initial.withCastlingRights(newCastling) + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.castlingRights.whiteKingSide) == Right(false)) } @@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers: .withMove(move2) .withTurn(Color.White) - val json = JsonExporter.exportGameContext(context) + val json = JsonExporter.exportGameContext(context) val restored = JsonParser.importGameContext(json) assert(restored.map(_.moves.length) == Right(2)) @@ -100,55 +101,55 @@ class JsonParserSuite extends AnyFunSuite with Matchers: test("importGameContext: handles half-move clock") { val context = GameContext.initial.withHalfMoveClock(5) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.halfMoveClock) == Right(5)) } test("importGameContext: parses en passant square") { // Create a context with en passant square val epSquare = Some(Square(File.E, Rank.R3)) - val context = GameContext.initial.copy(enPassantSquare = epSquare) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val context = GameContext.initial.copy(enPassantSquare = epSquare) + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.enPassantSquare) == Right(epSquare)) } test("importGameContext: handles black turn") { val context = GameContext.initial.withTurn(Color.Black) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.turn) == Right(Color.Black)) } test("importGameContext: preserves basic moves in JSON round-trip") { // Use simple move without explicit moveType to let system handle it - val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) val context = GameContext.initial.withMove(move) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.isRight) assert(result.map(_.moves.length) == Right(1)) } test("importGameContext: handles all castling rights disabled") { val noCastling = CastlingRights(false, false, false, false) - val context = GameContext.initial.withCastlingRights(noCastling) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val context = GameContext.initial.withCastlingRights(noCastling) + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.castlingRights) == Right(noCastling)) } test("importGameContext: handles mixed castling rights") { - val mixed = CastlingRights(true, false, false, true) + val mixed = CastlingRights(true, false, false, true) val context = GameContext.initial.withCastlingRights(mixed) - val json = JsonExporter.exportGameContext(context) - val result = JsonParser.importGameContext(json) - + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + assert(result.map(_.castlingRights) == Right(mixed)) } diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala index aeee504..0aeed63 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnExporterTest.scala @@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers class PgnExporterTest extends AnyFunSuite with Matchers: test("exportGame renders headers and basic move text"): - val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") + val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") val emptyPgn = PgnExporter.exportGame(headers, List.empty) emptyPgn.contains("[Event \"Test\"]") shouldBe true emptyPgn.contains("[White \"A\"]") shouldBe true @@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers: PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true test("exportGame renders castling grouping and result markers"): - PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O") - PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O") + PgnExporter.exportGame( + Map("Event" -> "Test"), + List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)), + ) should include("O-O") + PgnExporter.exportGame( + Map("Event" -> "Test"), + List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)), + ) should include("O-O-O") val seq = List( Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()), - Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()) + Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()), ) val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq) grouped should include("1. e4 c5") @@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("exportGame handles promotion suffixes and normal move formatting"): List( - PromotionPiece.Queen -> "=Q", - PromotionPiece.Rook -> "=R", + PromotionPiece.Queen -> "=Q", + PromotionPiece.Rook -> "=R", PromotionPiece.Bishop -> "=B", - PromotionPiece.Knight -> "=N" + PromotionPiece.Knight -> "=N", ).foreach { (piece, suffix) => val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece)) PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix") } - val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))) + val normal = + PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))) normal should include("e4") normal should not include "=" test("exportGameContext preserves moves and default headers"): val moves = List( Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()), - Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()) + Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()), ) val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves)) withMoves.contains("e4") shouldBe true @@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: Move(sq("c7"), sq("c6")), Move(sq("d1"), sq("d7"), MoveType.Normal(true)), Move(sq("d8"), sq("d7"), MoveType.Normal(true)), - Move(sq("e1"), sq("e2"), MoveType.Normal(true)) + Move(sq("e1"), sq("e2"), MoveType.Normal(true)), ) val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves) @@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers: pgn should include("Kxe2") test("exportGame emits en-passant and promotion capture notation"): - val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant) - val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen)) - val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true)) + val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant) + val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen)) + val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true)) val promotionQuietSetup = Move(sq("e8"), sq("e7")) - val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) + val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen)) - val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture)) - val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture)) + val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture)) + val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture)) val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet)) pgn should include("exd3") pgn should include("exf8=Q") pawnCapturePgn should include("exd3") quietPromotionPgn should include("e8=Q") - diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala index f943bc9..0c47399 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala @@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers class PgnParserTest extends AnyFunSuite with Matchers: test("parsePgn handles headers standard sequences captures castling and skipped tokens"): - val headerOnly = """[Event "Test Game"] + val headerOnly = """[Event "Test Game"] [White "Alice"] [Black "Bob"] [Result "1-0"]""" @@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers: capture.map(_.moves.length) shouldBe Some(3) capture.get.moves(2).to shouldBe Square(File.E, Rank.R5) - val whiteKs = PgnParser.parsePgn("""[Event "Test"] + val whiteKs = PgnParser + .parsePgn("""[Event "Test"] -1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last +1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""") + .get + .moves + .last whiteKs.moveType shouldBe MoveType.CastleKingside whiteKs.from shouldBe Square(File.E, Rank.R1) whiteKs.to shouldBe Square(File.G, Rank.R1) - val whiteQs = PgnParser.parsePgn("""[Event "Test"] + val whiteQs = PgnParser + .parsePgn("""[Event "Test"] -1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last +1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""") + .get + .moves + .last whiteQs.moveType shouldBe MoveType.CastleQueenside whiteQs.from shouldBe Square(File.E, Rank.R1) whiteQs.to shouldBe Square(File.C, Rank.R1) - val blackKs = PgnParser.parsePgn("""[Event "Test"] + val blackKs = PgnParser + .parsePgn("""[Event "Test"] -1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last +1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""") + .get + .moves + .last blackKs.moveType shouldBe MoveType.CastleKingside blackKs.from shouldBe Square(File.E, Rank.R8) - val blackQs = PgnParser.parsePgn("""[Event "Test"] + val blackQs = PgnParser + .parsePgn("""[Event "Test"] -1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last +1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""") + .get + .moves + .last blackQs.moveType shouldBe MoveType.CastleQueenside blackQs.from shouldBe Square(File.E, Rank.R8) blackQs.to shouldBe Square(File.C, Rank.R8) - PgnParser.parsePgn("""[Event "Test"] + PgnParser + .parsePgn("""[Event "Test"] -1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2) - PgnParser.parsePgn("""[Event "Test"] +1. e4 e5 1-0""") + .map(_.moves.length) shouldBe Some(2) + PgnParser + .parsePgn("""[Event "Test"] -1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2) +1. e4 INVALID e5""") + .map(_.moves.length) shouldBe Some(2) test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"): val board = Board.initial - PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4) - PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3) + PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square( + File.E, + Rank.R4, + ) + PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square( + File.F, + Rank.R3, + ) val rookPieces: Map[Square, Piece] = Map( Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), - Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), ) val rankPieces: Map[Square, Piece] = Map( Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook), Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), - Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), ) - PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1) - PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1) + PgnParser + .parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White) + .get + .from shouldBe Square(File.A, Rank.R1) + PgnParser + .parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White) + .get + .from shouldBe Square(File.A, Rank.R1) val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get - val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White) + val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White) king.isDefined shouldBe true king.get.from shouldBe Square(File.E, Rank.R1) king.get.to shouldBe Square(File.E, Rank.R2) test("parseAlgebraicMove handles all promotion targets"): val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) - PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) - PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) - PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) + PgnParser + .parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White) + .get + .moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) + PgnParser + .parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White) + .get + .moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) + PgnParser + .parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White) + .get + .moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) + PgnParser + .parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White) + .get + .moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) test("importGameContext accepts valid and empty PGN"): val pgn = """[Event "Test"] @@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None test("parseAlgebraicMove rejects notation with invalid promotion piece"): - val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected")) + val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected")) val context = GameContext.initial.withBoard(board) PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None @@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers: val parsed = PgnParser.parsePgn("1. e4 ??? e5") parsed.map(_.moves.size) shouldBe Some(2) - diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala index 67a0529..c479dd8 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnValidatorTest.scala @@ -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 - diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala index 1386478..e8a0b1d 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala @@ -4,9 +4,9 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.board.Square import de.nowchess.api.move.Move -/** Extension point for chess rule variants (standard, Chess960, etc.). - * All rule queries are stateless: given a GameContext, return the answer. - */ +/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a + * GameContext, return the answer. + */ trait RuleSet: /** All pseudo-legal moves for the piece on `square` (ignores check). */ def candidateMoves(context: GameContext)(square: Square): List[Move] @@ -32,8 +32,7 @@ trait RuleSet: /** True if halfMoveClock >= 100 (50-move rule). */ def isFiftyMoveRule(context: GameContext): Boolean - /** Apply a legal move to produce the next game context. - * Handles all special move types: castling, en passant, promotion. - * Updates castling rights, en passant square, half-move clock, turn, and move history. - */ + /** Apply a legal move to produce the next game context. Handles all special move types: castling, en passant, + * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history. + */ def applyMove(context: GameContext)(move: Move): GameContext diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index e9e4474..59f405c 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -7,20 +7,19 @@ import de.nowchess.rules.RuleSet import scala.annotation.tailrec -/** Standard chess rules implementation. - * Handles move generation, validation, check/checkmate/stalemate detection. - */ +/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection. + */ object DefaultRules extends RuleSet: // ── Direction vectors ────────────────────────────────────────────── - private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1)) + private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1)) private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1)) - private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs + private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs private val KnightJumps: List[(Int, Int)] = List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)) // ── Pawn configuration helpers ───────────────────────────────────── - private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1 + private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1 private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6 private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0 @@ -29,13 +28,14 @@ object DefaultRules extends RuleSet: override def candidateMoves(context: GameContext)(square: Square): List[Move] = context.board.pieceAt(square).fold(List.empty[Move]) { piece => if piece.color != context.turn then List.empty[Move] - else piece.pieceType match - case PieceType.Pawn => pawnCandidates(context, square, piece.color) - case PieceType.Knight => knightCandidates(context, square, piece.color) - case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) - case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs) - case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs) - case PieceType.King => kingCandidates(context, square, piece.color) + else + piece.pieceType match + case PieceType.Pawn => pawnCandidates(context, square, piece.color) + case PieceType.Knight => knightCandidates(context, square, piece.color) + case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs) + case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs) + case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs) + case PieceType.King => kingCandidates(context, square, piece.color) } override def legalMoves(context: GameContext)(square: Square): List[Move] = @@ -65,18 +65,18 @@ object DefaultRules extends RuleSet: // ── Sliding pieces (Bishop, Rook, Queen) ─────────────────────────── private def slidingMoves( - context: GameContext, - from: Square, - color: Color, - dirs: List[(Int, Int)] + context: GameContext, + from: Square, + color: Color, + dirs: List[(Int, Int)], ): List[Move] = dirs.flatMap(dir => castRay(context.board, from, color, dir)) private def castRay( - board: Board, - from: Square, - color: Color, - dir: (Int, Int) + board: Board, + from: Square, + color: Color, + dir: (Int, Int), ): List[Move] = @tailrec def loop(sq: Square, acc: List[Move]): List[Move] = @@ -84,40 +84,40 @@ object DefaultRules extends RuleSet: case None => acc case Some(next) => board.pieceAt(next) match - case None => loop(next, Move(from, next) :: acc) + case None => loop(next, Move(from, next) :: acc) case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc - case Some(_) => acc + case Some(_) => acc loop(from, Nil).reverse // ── Knight ───────────────────────────────────────────────────────── private def knightCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = KnightJumps.flatMap { (df, dr) => from.offset(df, dr).flatMap { to => context.board.pieceAt(to) match case Some(p) if p.color == color => None - case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) - case None => Some(Move(from, to)) + case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) + case None => Some(Move(from, to)) } } // ── King ─────────────────────────────────────────────────────────── private def kingCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = val steps = QueenDirs.flatMap { (df, dr) => from.offset(df, dr).flatMap { to => context.board.pieceAt(to) match case Some(p) if p.color == color => None - case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) - case None => Some(Move(from, to)) + case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true))) + case None => Some(Move(from, to)) } } steps ++ castlingCandidates(context, from, color) @@ -125,17 +125,17 @@ object DefaultRules extends RuleSet: // ── Castling ─────────────────────────────────────────────────────── private case class CastlingMove( - kingFromAlg: String, - kingToAlg: String, - middleAlg: String, - rookFromAlg: String, - moveType: MoveType + kingFromAlg: String, + kingToAlg: String, + middleAlg: String, + rookFromAlg: String, + moveType: MoveType, ) private def castlingCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = color match case Color.White => whiteCastles(context, from) @@ -146,10 +146,18 @@ object DefaultRules extends RuleSet: if from != expected then List.empty else val moves = scala.collection.mutable.ListBuffer[Move]() - addCastleMove(context, moves, context.castlingRights.whiteKingSide, - CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside)) - addCastleMove(context, moves, context.castlingRights.whiteQueenSide, - CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside)) + addCastleMove( + context, + moves, + context.castlingRights.whiteKingSide, + CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside), + ) + addCastleMove( + context, + moves, + context.castlingRights.whiteQueenSide, + CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside), + ) moves.toList private def blackCastles(context: GameContext, from: Square): List[Move] = @@ -157,10 +165,18 @@ object DefaultRules extends RuleSet: if from != expected then List.empty else val moves = scala.collection.mutable.ListBuffer[Move]() - addCastleMove(context, moves, context.castlingRights.blackKingSide, - CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside)) - addCastleMove(context, moves, context.castlingRights.blackQueenSide, - CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside)) + addCastleMove( + context, + moves, + context.castlingRights.blackKingSide, + CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside), + ) + addCastleMove( + context, + moves, + context.castlingRights.blackQueenSide, + CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside), + ) moves.toList private def queensideBSquare(kingToAlg: String): List[String] = @@ -170,10 +186,10 @@ object DefaultRules extends RuleSet: case _ => List.empty private def addCastleMove( - context: GameContext, - moves: scala.collection.mutable.ListBuffer[Move], - castlingRight: Boolean, - castlingMove: CastlingMove + context: GameContext, + moves: scala.collection.mutable.ListBuffer[Move], + castlingRight: Boolean, + castlingMove: CastlingMove, ): Unit = if castlingRight then val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg)) @@ -185,16 +201,15 @@ object DefaultRules extends RuleSet: kt <- Square.fromAlgebraic(castlingMove.kingToAlg) rf <- Square.fromAlgebraic(castlingMove.rookFromAlg) do - val color = context.turn + val color = context.turn val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King) val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook) val squaresSafe = !isAttackedBy(context.board, kf, color.opposite) && - !isAttackedBy(context.board, km, color.opposite) && - !isAttackedBy(context.board, kt, color.opposite) + !isAttackedBy(context.board, km, color.opposite) && + !isAttackedBy(context.board, kt, color.opposite) - if kingPresent && rookPresent && squaresSafe then - moves += Move(kf, kt, castlingMove.moveType) + if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType) private def squaresEmpty(board: Board, squares: List[Square]): Boolean = squares.forall(sq => board.pieceAt(sq).isEmpty) @@ -202,22 +217,26 @@ object DefaultRules extends RuleSet: // ── Pawn ─────────────────────────────────────────────────────────── private def pawnCandidates( - context: GameContext, - from: Square, - color: Color + context: GameContext, + from: Square, + color: Color, ): List[Move] = - val fwd = pawnForward(color) + val fwd = pawnForward(color) val startRank = pawnStartRank(color) val promoRank = pawnPromoRank(color) val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty) - val double = Option.when(from.rank.ordinal == startRank) { - from.offset(0, fwd).flatMap { mid => - Option.when(context.board.pieceAt(mid).isEmpty) { - from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) - }.flatten + val double = Option + .when(from.rank.ordinal == startRank) { + from.offset(0, fwd).flatMap { mid => + Option + .when(context.board.pieceAt(mid).isEmpty) { + from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty) + } + .flatten + } } - }.flatten + .flatten val diagonalCaptures = List(-1, 1).flatMap { df => from.offset(df, fwd).flatMap { to => @@ -236,22 +255,22 @@ object DefaultRules extends RuleSet: def toMoves(dest: Square, isCapture: Boolean): List[Move] = if dest.rank.ordinal == promoRank then List( - PromotionPiece.Queen, PromotionPiece.Rook, - PromotionPiece.Bishop, PromotionPiece.Knight + PromotionPiece.Queen, + PromotionPiece.Rook, + PromotionPiece.Bishop, + PromotionPiece.Knight, ).map(pt => Move(from, dest, MoveType.Promotion(pt))) else List(Move(from, dest, MoveType.Normal(isCapture = isCapture))) - val stepSquares = single.toList ++ double.toList - val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false)) + val stepSquares = single.toList ++ double.toList + val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false)) val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true)) stepMoves ++ captureMoves ++ epCaptures // ── Check detection ──────────────────────────────────────────────── private def kingSquare(board: Board, color: Color): Option[Square] = - Square.all.find(sq => - board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King) - ) + Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King)) private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean = Square.all.exists { sq => @@ -266,26 +285,26 @@ object DefaultRules extends RuleSet: case PieceType.Pawn => from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target) case PieceType.Knight => - KnightJumps.exists { (df, dr) => from.offset(df, dr).contains(target) } + KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target)) case PieceType.Bishop => rayReaches(board, from, BishopDirs, target) - case PieceType.Rook => rayReaches(board, from, RookDirs, target) - case PieceType.Queen => rayReaches(board, from, QueenDirs, target) + case PieceType.Rook => rayReaches(board, from, RookDirs, target) + case PieceType.Queen => rayReaches(board, from, QueenDirs, target) case PieceType.King => - QueenDirs.exists { (df, dr) => from.offset(df, dr).contains(target) } + QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target)) private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean = dirs.exists { dir => @tailrec def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match - case None => false - case Some(next) if next == target => true + case None => false + case Some(next) if next == target => true case Some(next) if board.pieceAt(next).isEmpty => loop(next) - case Some(_) => false + case Some(_) => false loop(from) } private def leavesKingInCheck(context: GameContext, move: Move): Boolean = - val nextBoard = context.board.applyMove(move) + val nextBoard = context.board.applyMove(move) val nextContext = context.withBoard(nextBoard) isCheck(nextContext) @@ -293,7 +312,7 @@ object DefaultRules extends RuleSet: override def applyMove(context: GameContext)(move: Move): GameContext = val color = context.turn - val board = context.board + val board = context.board val newBoard = move.moveType match case MoveType.CastleKingside => applyCastle(board, color, kingside = true) @@ -302,14 +321,14 @@ object DefaultRules extends RuleSet: case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp) case MoveType.Normal(_) => board.applyMove(move) - val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) + val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) val newEnPassantSquare = computeEnPassantSquare(board, move) val isCapture = move.moveType match case MoveType.Normal(capture) => capture case MoveType.EnPassant => true case _ => board.pieceAt(move.to).isDefined val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) - val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 + val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 context .withBoard(newBoard) @@ -322,19 +341,18 @@ object DefaultRules extends RuleSet: private def applyCastle(board: Board, color: Color, kingside: Boolean): Board = val rank = if color == Color.White then Rank.R1 else Rank.R8 val (kingFrom, kingTo, rookFrom, rookTo) = - if kingside then - (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) - else - (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank)) + if kingside then (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) + else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank)) val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King)) val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook)) board - .removed(kingFrom).removed(rookFrom) + .removed(kingFrom) + .removed(rookFrom) .updated(kingTo, king) .updated(rookTo, rook) private def applyEnPassant(board: Board, move: Move): Board = - val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn + val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn val capturedSquare = Square(move.to.file, capturedRank) board.applyMove(move).removed(capturedSquare) @@ -347,7 +365,7 @@ object DefaultRules extends RuleSet: board.removed(move.from).updated(move.to, Piece(color, promotedType)) private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights = - val piece = board.pieceAt(move.from) + val piece = board.pieceAt(move.from) val isKingMove = piece.exists(_.pieceType == PieceType.King) val isRookMove = piece.exists(_.pieceType == PieceType.Rook) @@ -360,14 +378,14 @@ object DefaultRules extends RuleSet: var r = rights if isKingMove then r = r.revokeColor(color) else if isRookMove then - if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) + if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White) - if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) + if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black) // Also revoke if a rook is captured - if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) + if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White) - if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) + if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black) r @@ -386,9 +404,10 @@ object DefaultRules extends RuleSet: private def insufficientMaterial(board: Board): Boolean = val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King) pieces match - case Nil => true + case Nil => true case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List(p1, p2) - if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop - && p1.color != p2.color => true + if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop + && p1.color != p2.color => + true case _ => false diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala index 5f06164..d8c83d6 100644 --- a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala @@ -65,7 +65,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove clears en passant square for non double pawn push"): val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1") - val move = Move(sq("e2"), sq("e3")) + val move = Move(sq("e2"), sq("e3")) val next = DefaultRules.applyMove(context)(move) @@ -73,7 +73,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove resets halfMoveClock on pawn move"): val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1") - val move = Move(sq("e2"), sq("e4")) + val move = Move(sq("e2"), sq("e4")) val next = DefaultRules.applyMove(context)(move) @@ -81,7 +81,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove increments halfMoveClock on quiet non pawn move"): val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1") - val move = Move(sq("g1"), sq("f3")) + val move = Move(sq("g1"), sq("f3")) val next = DefaultRules.applyMove(context)(move) @@ -89,7 +89,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove resets halfMoveClock on capture"): val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1") - val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) + val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) val next = DefaultRules.applyMove(context)(move) @@ -98,7 +98,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove updates castling rights after king move"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") - val move = Move(sq("e1"), sq("e2")) + val move = Move(sq("e1"), sq("e2")) val next = DefaultRules.applyMove(context)(move) @@ -109,7 +109,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove updates castling rights after rook move from h1"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1") - val move = Move(sq("h1"), sq("h2")) + val move = Move(sq("h1"), sq("h2")) val next = DefaultRules.applyMove(context)(move) @@ -118,7 +118,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove revokes opponent castling right when rook on starting square is captured"): val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1") - val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) + val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true)) val next = DefaultRules.applyMove(context)(move) @@ -126,7 +126,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove executes kingside castling and repositions king and rook"): val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1") - val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) + val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) val next = DefaultRules.applyMove(context)(move) @@ -137,7 +137,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove executes queenside castling and repositions king and rook"): val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1") - val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside) + val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside) val next = DefaultRules.applyMove(context)(move) @@ -148,7 +148,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove executes en passant and removes captured pawn"): val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1") - val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant) + val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant) val next = DefaultRules.applyMove(context)(move) @@ -158,7 +158,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove executes promotion with selected piece type"): val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") - val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight)) + val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight)) val next = DefaultRules.applyMove(context)(move) @@ -179,7 +179,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove preserves black castling rights after white kingside castling"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") - val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) + val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside) val next = DefaultRules.applyMove(context)(move) @@ -190,16 +190,18 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove can revoke both white castling rights when both rooks are captured"): val context = GameContext( - board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)), + board = + contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)), turn = Color.Black, castlingRights = CastlingRights(true, true, false, false), enPassantSquare = None, halfMoveClock = 0, - moves = List.empty + moves = List.empty, ) val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true))) - val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true))) + val afterH1Capture = + DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true))) afterH1Capture.castlingRights.whiteKingSide shouldBe false afterH1Capture.castlingRights.whiteQueenSide shouldBe false @@ -233,7 +235,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove executes black kingside castling and repositions pieces on rank 8"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") - val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside) + val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside) val next = DefaultRules.applyMove(context)(move) @@ -244,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove revokes black castling rights when black rook moves from h8"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") - val move = Move(sq("h8"), sq("h7")) + val move = Move(sq("h8"), sq("h7")) val next = DefaultRules.applyMove(context)(move) @@ -253,7 +255,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove revokes black queenside castling right when black rook moves from a8"): val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1") - val move = Move(sq("a8"), sq("a7")) + val move = Move(sq("a8"), sq("a7")) val next = DefaultRules.applyMove(context)(move) @@ -262,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("applyMove revokes black kingside castling right when rook on h8 is captured"): val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1") - val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true)) + val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true)) val next = DefaultRules.applyMove(context)(move) @@ -270,31 +272,25 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers: test("candidateMoves creates all promotion move variants for black pawn"): val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1") - val to = sq("a1") + val to = sq("a1") - val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2")) + val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2")) val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece } promotions.toSet shouldBe Set( PromotionPiece.Queen, PromotionPiece.Rook, PromotionPiece.Bishop, - PromotionPiece.Knight + PromotionPiece.Knight, ) test("applyMove promotion supports queen rook and bishop targets"): val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") - val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen))) - val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook))) + val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen))) + val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook))) val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop))) queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen)) rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook)) bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop)) - - - - - - diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala index 5f7425f..6b4c76d 100644 --- a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesTest.scala @@ -1,6 +1,6 @@ package de.nowchess.rule -import de.nowchess.api.board.{Board, Color, File, Rank, Square, Piece, PieceType, CastlingRights} +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType} import de.nowchess.io.fen.FenParser @@ -15,31 +15,31 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: // ── Pawn moves ────────────────────────────────────────────────── test("pawn can move forward one square"): - val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1" - val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val fen = "8/8/8/8/8/8/4P3/8 w - - 0 1" + val context = FenParser.parseFen(fen).fold(_ => fail(), identity) + val moves = rules.allLegalMoves(context) val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R2)) pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe true test("pawn can move forward two squares from starting position"): val context = GameContext.initial - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val e2Moves = moves.filter(m => m.from == Square(File.E, Rank.R2)) e2Moves.exists(m => m.to == Square(File.E, Rank.R4)) shouldBe true test("pawn can capture diagonally"): // FEN: white pawn e4, black pawn d5 - val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" - val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1" + val context = FenParser.parseFen(fen).fold(_ => fail(), identity) + val moves = rules.allLegalMoves(context) val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal]) captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true test("pawn cannot move backward"): // FEN: white pawn on e4 - val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1" - val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val fen = "8/8/8/8/4P3/8/8/8 w - - 0 1" + val context = FenParser.parseFen(fen).fold(_ => fail(), identity) + val moves = rules.allLegalMoves(context) val pawnMoves = moves.filter(m => m.from == Square(File.E, Rank.R4)) pawnMoves.exists(m => m.to == Square(File.E, Rank.R3)) shouldBe false @@ -47,18 +47,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: test("moving king out of check removes it from legal moves if king stays in check"): // FEN: white king e1, black rook e8, white tries to move away - val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1" + val fen = "4r3/8/8/8/8/8/8/4K3 w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) // King must move; e2 should be valid but d1 might be blocked by rook if still on same file moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true test("king cannot move to square attacked by opponent"): // FEN: white king e1, black rook e2 defended by black king e3 - val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1" + val fen = "8/8/8/8/8/4k3/4r3/4K3 w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) // King cannot move to e2 (occupied and attacked) val kingMovesToE2 = moves.filter(m => m.from == Square(File.E, Rank.R1) && m.to == Square(File.E, Rank.R2)) @@ -67,64 +67,67 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: // ── Castling legality ──────────────────────────────────────────── test("castling kingside is legal when king and rook unmoved and path clear"): - val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1" + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w KQkq - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) castles.nonEmpty shouldBe true test("castling queenside is legal when king and rook unmoved and path clear"): - val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1" + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside) castles.nonEmpty shouldBe true test("castling is illegal when castling rights are false"): // FEN: king and rook in position, but castling rights disabled - val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1" + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) castles.isEmpty shouldBe true test("castling is illegal when king is in check"): // FEN: white king e1 in check from black rook e8 - val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1" + val fen = "4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside || m.moveType == MoveType.CastleQueenside) castles.isEmpty shouldBe true test("castling is illegal when path has piece in the way"): // FEN: white king e1, white rook h1, white bishop f1 (blocks f-file) - val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1" + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBR1 w KQkq - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val castles = moves.filter(m => m.moveType == MoveType.CastleKingside) castles.isEmpty shouldBe true test("castling queenside is illegal when knight blocks on b8"): // Black king e8, black rook a8, black knight b8 (blocks queenside path) - val board = Board(Map( - Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook), - Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight), - Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), - Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), - Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King) - )) + val board = Board( + Map( + Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook), + Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + ), + ) val context = GameContext( board = board, turn = Color.Black, - castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true), + castlingRights = + CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true), enPassantSquare = None, halfMoveClock = 0, - moves = List.empty + moves = List.empty, ) val moves = rules.allLegalMoves(context) @@ -135,18 +138,18 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: test("en passant is legal when en passant square is set"): // FEN: white pawn e5, black pawn d5 (just double-pushed), en passant square d6 - val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1" + val fen = "k7/8/8/3pP3/8/8/8/7K w - d6 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant) epMoves.exists(m => m.to == Square(File.D, Rank.R6)) shouldBe true test("en passant is illegal when en passant square is none"): // FEN: white pawn e5, black pawn d5, but no en passant square - val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1" + val fen = "k7/8/8/3pP3/8/8/8/7K w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) val epMoves = moves.filter(m => m.moveType == MoveType.EnPassant) epMoves.isEmpty shouldBe true @@ -155,9 +158,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: test("pinned piece cannot move and expose king to check"): // FEN: white king e1, white bishop d2 (pinned), black rook a2 - val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1" + val fen = "8/8/8/8/8/8/r1B1K3/8 w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) // Bishop on d2 is pinned by rook on a2; it cannot move val bishopMoves = moves.filter(m => m.from == Square(File.C, Rank.R2)) @@ -166,9 +169,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers: test("piece blocking a check is legal"): // FEN: white king e1, white rook d1, black bishop a4 attacking e1 via d2 // Actually, this is complex. Let's use: white king e1, black rook e8, white pawn blocks on e2 - val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1" + val fen = "4r3/8/8/8/8/8/4P3/4K3 w - - 0 1" val context = FenParser.parseFen(fen).fold(_ => fail(), identity) - val moves = rules.allLegalMoves(context) + val moves = rules.allLegalMoves(context) // White is in check; only moves that block or move the king are legal moves.nonEmpty shouldBe true diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala index 4313506..14e0aea 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala @@ -4,9 +4,9 @@ import de.nowchess.chess.engine.GameEngine import de.nowchess.ui.terminal.TerminalUI import de.nowchess.ui.gui.ChessGUILauncher -/** Application entry point - starts both GUI and Terminal UI for the chess game. - * Both views subscribe to the same GameEngine via Observer pattern. - */ +/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same + * GameEngine via Observer pattern. + */ object Main: def main(args: Array[String]): Unit = // Create the core game engine (single source of truth) @@ -18,4 +18,3 @@ object Main: // Create and start the terminal UI (blocks on main thread) val tui = new TerminalUI(engine) tui.start() - diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index e2626a0..139ec87 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -17,37 +17,36 @@ import de.nowchess.chess.engine.GameEngine import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.json.{JsonExporter, JsonParser} -import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService} +import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService} import java.nio.file.Paths import scalafx.stage.FileChooser import scalafx.stage.FileChooser.ExtensionFilter -/** ScalaFX chess board view that displays the game state. - * Uses chess sprites and color palette. - * Handles user interactions (clicks) and sends moves to GameEngine. - */ +/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user + * interactions (clicks) and sends moves to GameEngine. + */ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane: - - private val squareSize = 70.0 + + private val squareSize = 70.0 private val comicSansFontFamily = "Comic Sans MS" - private val boardGrid = new GridPane() + private val boardGrid = new GridPane() private val messageLabel = new Label { text = "Welcome!" font = Font.font(comicSansFontFamily, 16) padding = Insets(10) } - - private var currentBoard: Board = engine.board - private var currentTurn: Color = engine.turn + + private var currentBoard: Board = engine.board + private var currentTurn: Color = engine.turn private var selectedSquare: Option[Square] = None - private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() - + private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() + private var undoButton: Button = uninitialized private var redoButton: Button = uninitialized // Initialize UI initializeBoard() - + top = new VBox { padding = Insets(10) spacing = 5 @@ -58,17 +57,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B font = Font.font(comicSansFontFamily, 24) style = "-fx-font-weight: bold;" }, - messageLabel + messageLabel, ) } - + center = new VBox { padding = Insets(20) alignment = Pos.Center style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};" children = boardGrid } - + bottom = new VBox { padding = Insets(10) spacing = 8 @@ -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,17 +140,17 @@ 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;" - } + }, ) - } + }, ) } - + private def initializeBoard(): Unit = boardGrid.padding = Insets(5) boardGrid.hgap = 0 boardGrid.vgap = 0 - + // Create 8x8 board with rank/file labels for rank <- 0 until 8 @@ -161,13 +159,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B val square = createSquare(rank, file) squareViews((rank, file)) = square boardGrid.add(square, file, 7 - rank) // Flip rank for proper display - + updateBoard(currentBoard, currentTurn) - + private def createSquare(rank: Int, file: Int): StackPane = - val isWhite = (rank + file) % 2 == 0 + val isWhite = (rank + file) % 2 == 0 val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black - + val bgRect = new Rectangle { width = squareSize height = squareSize @@ -175,21 +173,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B arcWidth = 8 arcHeight = 8 } - + val square = new StackPane { children = Seq(bgRect) onMouseClicked = _ => handleSquareClick(rank, file) style = "-fx-cursor: hand;" } - + 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)) - + selectedSquare match case None => // First click - select piece if it belongs to current player @@ -198,13 +195,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B selectedSquare = Some(clickedSquare) highlightSquare(rank, file, PieceSprites.SquareColors.Selected) - val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare) - .collect { case move if move.from == clickedSquare => move.to } + val legalDests = engine.ruleSet + .legalMoves(engine.context)(clickedSquare) + .collect { case move if move.from == clickedSquare => move.to } legalDests.foreach { sq => - highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) + highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) } } - + case Some(fromSquare) => // Second click - attempt move if clickedSquare == fromSquare then @@ -216,21 +214,21 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B val moveStr = s"${fromSquare}$clickedSquare" engine.processUserInput(moveStr) selectedSquare = None - + def updateBoard(board: Board, turn: Color): Unit = currentBoard = board currentTurn = turn selectedSquare = None - + // Update all squares for rank <- 0 until 8 file <- 0 until 8 do squareViews.get((rank, file)).foreach { stackPane => - val isWhite = (rank + file) % 2 == 0 + val isWhite = (rank + file) % 2 == 0 val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black - + val bgRect = new Rectangle { width = squareSize height = squareSize @@ -238,16 +236,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B arcWidth = 8 arcHeight = 8 } - - val square = Square(File.values(file), Rank.values(rank)) + + val square = Square(File.values(file), Rank.values(rank)) val pieceOption = board.pieceAt(square) - + val children = pieceOption match case Some(piece) => Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8)) case None => Seq(bgRect) - + stackPane.children = children } @@ -266,20 +264,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B arcWidth = 8 arcHeight = 8 } - - val square = Square(File.values(file), Rank.values(rank)) + + val square = Square(File.values(file), Rank.values(rank)) val pieceOption = currentBoard.pieceAt(square) - + stackPane.children = pieceOption match case Some(piece) => Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8)) case None => Seq(bgRect) } - + def showMessage(msg: String): Unit = messageLabel.text = msg - + def showPromotionDialog(from: Square, to: Square): Unit = val choices = Seq("Queen", "Rook", "Bishop", "Knight") val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) { @@ -288,14 +286,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B headerText = "Choose promotion piece" contentText = "Promote to:" } - + val result = dialog.showAndWait() result match - case Some("Queen") => engine.completePromotion(PromotionPiece.Queen) - case Some("Rook") => engine.completePromotion(PromotionPiece.Rook) + case Some("Queen") => engine.completePromotion(PromotionPiece.Queen) + case Some("Rook") => engine.completePromotion(PromotionPiece.Rook) case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop) case Some("Knight") => engine.completePromotion(PromotionPiece.Knight) - case _ => engine.completePromotion(PromotionPiece.Queen) // Default + case _ => engine.completePromotion(PromotionPiece.Queen) // Default private def doFenExport(): Unit = doExport(FenExporter, "FEN") @@ -316,16 +314,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json")) extensionFilters.add(new ExtensionFilter("All files", "*.*")) } - + val selectedFile = fileChooser.showSaveDialog(stage) if selectedFile != null then val result = FileSystemGameService.saveGameToFile( engine.context, selectedFile.toPath, - JsonExporter + JsonExporter, ) result match - case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}") + case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}") case Left(err) => showMessage(s"⚠️ Error saving file: $err") private def doJsonImport(): Unit = @@ -334,12 +332,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json")) extensionFilters.add(new ExtensionFilter("All files", "*.*")) } - + val selectedFile = fileChooser.showOpenDialog(stage) 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 - diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala index 857c1a0..970024b 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessGUI.scala @@ -1,63 +1,58 @@ package de.nowchess.ui.gui -import javafx.application.{Application => JFXApplication, Platform => JFXPlatform} +import javafx.application.{Application as JFXApplication, Platform as JFXPlatform} import javafx.stage.Stage as JFXStage import scalafx.application.Platform import scalafx.scene.Scene import scalafx.stage.Stage import de.nowchess.chess.engine.GameEngine -/** ScalaFX GUI Application for Chess. - * This is launched from Main alongside the TUI. - * Both subscribe to the same GameEngine via Observer pattern. - */ +/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same + * GameEngine via Observer pattern. + */ class ChessGUIApp extends JFXApplication: - + override def start(primaryStage: JFXStage): Unit = val engine = ChessGUILauncher.getEngine - val stage = new Stage(primaryStage) - + val stage = new Stage(primaryStage) + stage.title = "Chess" stage.width = 700 stage.height = 1000 stage.resizable = false - - val boardView = new ChessBoardView(stage, engine) + + val boardView = new ChessBoardView(stage, engine) val guiObserver = new GUIObserver(boardView) - + // Subscribe GUI observer to engine engine.subscribe(guiObserver) - + stage.scene = new Scene { root = boardView // 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() /** Launcher object that holds the engine reference and launches GUI in separate thread. */ object ChessGUILauncher: @volatile private var engine: GameEngine = scala.compiletime.uninitialized - + def getEngine: GameEngine = engine - + 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() diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala index 3dfa8ff..83cdda4 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala @@ -3,13 +3,12 @@ package de.nowchess.ui.gui import scalafx.application.Platform import scalafx.scene.control.Alert import scalafx.scene.control.Alert.AlertType -import de.nowchess.chess.observer.{Observer, GameEvent, *} +import de.nowchess.chess.observer.{GameEvent, Observer, *} import de.nowchess.api.board.Board -/** GUI Observer that implements the Observer pattern. - * Receives game events from GameEngine and updates the ScalaFX UI. - * All UI updates must be done on the JavaFX Application Thread. - */ +/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI. + * All UI updates must be done on the JavaFX Application Thread. + */ class GUIObserver(private val boardView: ChessBoardView) extends Observer: override def onGameEvent(event: GameEvent): Unit = @@ -60,8 +59,7 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: boardView.updateBoard(e.context.board, e.context.turn) if e.capturedPiece.isDefined then boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}") - else - boardView.showMessage(s"↷ Redo: ${e.pgnNotation}") + else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}") boardView.updateUndoRedoButtons() case e: PgnLoadedEvent => diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala index f50eea3..3bc80cb 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/PieceSprites.scala @@ -1,38 +1,36 @@ package de.nowchess.ui.gui import scalafx.scene.image.{Image, ImageView} -import de.nowchess.api.board.{Piece, PieceType, Color} +import de.nowchess.api.board.{Color, Piece, PieceType} /** Utility object for loading chess piece sprites. */ object PieceSprites: - + private val spriteCache = scala.collection.mutable.Map[String, Image]() - - /** Load a piece sprite image from resources. - * Sprites are cached for performance. - */ + + /** Load a piece sprite image from resources. Sprites are cached for performance. + */ def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView = - val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" + val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}" val image = spriteCache.getOrElseUpdate(key, loadImage(key)) - + new ImageView(image) { fitWidth = size fitHeight = size preserveRatio = true smooth = true } - + private def loadImage(key: String): Image = - val path = s"/sprites/pieces/$key.png" + val path = s"/sprites/pieces/$key.png" val stream = getClass.getResourceAsStream(path) - if stream == null then - throw new RuntimeException(s"Could not load sprite: $path") + if stream == null then throw new RuntimeException(s"Could not load sprite: $path") new Image(stream) - + /** Get square colors for the board using theme. */ object SquareColors: - val White = "#F3C8A0" // Warm light beige - val Black = "#BA6D4B" // Warm terracotta - val Selected = "#C19EF5" // Purple highlight + val White = "#F3C8A0" // Warm light beige + val Black = "#BA6D4B" // Warm terracotta + val Selected = "#C19EF5" // Purple highlight val ValidMove = "#E1EAA9" // Light yellow-green - val Border = "#5A2C28" // Dark brown border + val Border = "#5A2C28" // Dark brown border diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 925425f..7854c3f 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -6,12 +6,11 @@ import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.observer.* import de.nowchess.ui.utils.Renderer -/** Terminal UI that implements Observer pattern. - * Subscribes to GameEngine and receives state change events. - * Handles all I/O and user interaction in the terminal. - */ +/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all + * I/O and user interaction in the terminal. + */ class TerminalUI(engine: GameEngine) extends Observer: - private var running = true + private var running = true private var awaitingPromotion = false /** Called by GameEngine whenever a game event occurs. */ diff --git a/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala b/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala index 8c9ef47..27cc533 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/utils/Renderer.scala @@ -5,24 +5,26 @@ import de.nowchess.api.board.* object Renderer: private val AnsiReset = "\u001b[0m" - private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige - private val AnsiDarkSquare = "\u001b[48;5;130m" // brown - private val AnsiWhitePiece = "\u001b[97m" // bright white text - private val AnsiBlackPiece = "\u001b[30m" // black text + private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige + private val AnsiDarkSquare = "\u001b[48;5;130m" // brown + private val AnsiWhitePiece = "\u001b[97m" // bright white text + private val AnsiBlackPiece = "\u001b[30m" // black text def render(board: Board): String = - val rows = (0 until 8).reverse.map { rank => - val cells = (0 until 8).map { file => - val sq = Square(File.values(file), Rank.values(rank)) - val isLightSq = (file + rank) % 2 != 0 - val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare - board.pieceAt(sq) match - case Some(piece) => - val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece - s"$bgColor$fgColor ${piece.unicode} $AnsiReset" - case None => - s"$bgColor $AnsiReset" - }.mkString - s"${rank + 1} $cells ${rank + 1}" - }.mkString("\n") + val rows = (0 until 8).reverse + .map { rank => + val cells = (0 until 8).map { file => + val sq = Square(File.values(file), Rank.values(rank)) + val isLightSq = (file + rank) % 2 != 0 + val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare + board.pieceAt(sq) match + case Some(piece) => + val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece + s"$bgColor$fgColor ${piece.unicode} $AnsiReset" + case None => + s"$bgColor $AnsiReset" + }.mkString + s"${rank + 1} $cells ${rank + 1}" + } + .mkString("\n") s" a b c d e f g h\n$rows\n a b c d e f g h\n" diff --git a/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala b/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala index 6c031cc..4a82afe 100644 --- a/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala +++ b/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala @@ -19,16 +19,16 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers: (Piece(Color.Black, PieceType.Rook), "\u265C"), (Piece(Color.Black, PieceType.Bishop), "\u265D"), (Piece(Color.Black, PieceType.Knight), "\u265E"), - (Piece(Color.Black, PieceType.Pawn), "\u265F") + (Piece(Color.Black, PieceType.Pawn), "\u265F"), ) pieces.foreach { (piece, expected) => piece.unicode shouldBe expected } test("render outputs coordinates ranks ansi escapes and piece glyphs"): - val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen))) + val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen))) val rendered = Renderer.render(Board(Map.empty)) - val lines = rendered.trim.split("\\n").toList.map(_.trim) + val lines = rendered.trim.split("\\n").toList.map(_.trim) lines.head shouldBe "a b c d e f g h" lines.last shouldBe "a b c d e f g h" @@ -38,9 +38,7 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers: Renderer.render(board) should include("\u001b[") test("render applies black piece color for black pieces"): - val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King))) + val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King))) val rendered = Renderer.render(board) - rendered should include("\u265A") // Black king unicode - rendered should include("\u001b[30m") // ANSI black text color - - + rendered should include("\u265A") // Black king unicode + rendered should include("\u001b[30m") // ANSI black text color