diff --git a/modules/core/src/main/scala/de/nowchess/chess/monad/MoveValidationExample.scala b/modules/core/src/main/scala/de/nowchess/chess/monad/MoveValidationExample.scala new file mode 100644 index 0000000..e69de29 diff --git a/modules/core/src/main/scala/de/nowchess/chess/monad/Validated.scala b/modules/core/src/main/scala/de/nowchess/chess/monad/Validated.scala new file mode 100644 index 0000000..e69de29 diff --git a/modules/core/src/test/scala/de/nowchess/chess/monad/ValidatedTest.scala b/modules/core/src/test/scala/de/nowchess/chess/monad/ValidatedTest.scala new file mode 100644 index 0000000..e69de29 diff --git a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala new file mode 100644 index 0000000..bd69ba5 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala @@ -0,0 +1,39 @@ +package de.nowchess.io + +import de.nowchess.api.game.GameContext +import java.nio.file.{Files, Path} +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. + */ +trait GameFileService: + def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] + def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] + +/** Default implementation using the file system. */ +object FileSystemGameService extends GameFileService: + + /** Save a game context to a file using the specified exporter. */ + def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] = + Try { + val json = exporter.exportGameContext(context) + Files.write(path, json.getBytes(StandardCharsets.UTF_8)) + () + }.fold( + ex => Left(s"Failed to save file: ${ex.getMessage}"), + _ => Right(()) + ) + + /** Load a game context from a file using the specified importer. */ + def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] = + Try { + val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + importer.importGameContext(json) + }.fold( + ex => Left(s"Failed to load file: ${ex.getMessage}"), + result => result + ) 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 new file mode 100644 index 0000000..5df0c78 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala @@ -0,0 +1,163 @@ +package de.nowchess.io.json + +import de.nowchess.api.board.* +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.Instant +import scala.collection.mutable + +/** Exports a GameContext to a comprehensive JSON format. + * + * 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: + + def exportGameContext(context: GameContext): String = + val metadata = buildMetadata() + val gameState = buildGameState(context) + val pgnMoves = PgnExporter.exportGameContext(context) + val moves = exportMoves(context.moves) + val captured = exportCapturedPieces(context.board) + val timestamp = buildTimestamp() + + s"""{ + "metadata": $metadata, + "gameState": $gameState, + "moveHistory": "${escapePgn(pgnMoves)}", + "moves": $moves, + "capturedPieces": $captured, + "timestamp": "$timestamp" +}""" + + /** Build metadata section. */ + private def buildMetadata(): String = + s"""{ + "event": "Game", + "players": { + "white": "White Player", + "black": "Black Player" + }, + "date": "${java.time.LocalDate.now}", + "result": "*" + }""" + + /** Build gameState section. */ + private def buildGameState(context: GameContext): String = + s"""{ + "board": ${exportBoard(context.board)}, + "turn": "${context.turn.label}", + "castlingRights": ${exportCastlingRights(context.castlingRights)}, + "enPassantSquare": ${exportEnPassant(context.enPassantSquare)}, + "halfMoveClock": ${context.halfMoveClock} + }""" + + /** Build ISO 8601 timestamp. */ + private def buildTimestamp(): String = + java.time.ZonedDateTime.now(java.time.ZoneId.of("UTC")).toString + + /** Export board as a list of piece placements. */ + private def exportBoard(board: Board): String = + val pieces = board.pieces.toList.map { case (square, piece) => + s"""{"square": "${square}", "color": "${piece.color.label}", "piece": "${piece.pieceType.label}"}""" + } + if pieces.isEmpty then "[]" else s"[${pieces.mkString(", ")}]" + + /** Export castling rights. */ + private def exportCastlingRights(rights: CastlingRights): String = + s"""{ + "whiteKingSide": ${rights.whiteKingSide}, + "whiteQueenSide": ${rights.whiteQueenSide}, + "blackKingSide": ${rights.blackKingSide}, + "blackQueenSide": ${rights.blackQueenSide} + }""" + + /** Export en passant square. */ + private def exportEnPassant(epSquare: Option[Square]): String = + epSquare match + case Some(sq) => s""""${sq.toString}"""" + case None => "null" + + /** Export captured pieces. */ + private def exportCapturedPieces(board: Board): String = + val initialBoard = Board.initial + val allSquares = Square.all + + // Find captured pieces by comparing with initial position + var whiteCaptured = List.empty[String] + var blackCaptured = List.empty[String] + + for square <- allSquares do + initialBoard.pieceAt(square) match + case Some(initialPiece) => + board.pieceAt(square) match + case None => + // Piece was captured + val pieceName = initialPiece.pieceType.label + if initialPiece.color == Color.White then + whiteCaptured = whiteCaptured :+ pieceName + else + blackCaptured = blackCaptured :+ pieceName + case Some(_) => // Piece still there or promoted + case None => // Square was empty initially + + val whiteList = if whiteCaptured.isEmpty then "[]" + else s"[${whiteCaptured.map(p => s""""$p"""").mkString(", ")}]" + val blackList = if blackCaptured.isEmpty then "[]" + else s"[${blackCaptured.map(p => s""""$p"""").mkString(", ")}]" + + s"""{ + "byWhite": $blackList, + "byBlack": $whiteList + }""" + + /** Export moves as an array of move objects. */ + private def exportMoves(moves: List[Move]): String = + val moveObjects = moves.map { move => + val from = move.from.toString + val to = move.to.toString + val moveTypeJson = exportMoveType(move.moveType) + s""" { + "from": "$from", + "to": "$to", + "type": $moveTypeJson + }""" + } + + if moveObjects.isEmpty then "[]" + else s"[\n${moveObjects.mkString(",\n")}\n ]" + + /** Export move type as a JSON object. */ + private def exportMoveType(moveType: MoveType): String = + moveType match + case MoveType.Normal(isCapture) => + s"""{"type": "normal", "isCapture": $isCapture}""" + case MoveType.CastleKingside => + s"""{"type": "castleKingside"}""" + case MoveType.CastleQueenside => + s"""{"type": "castleQueenside"}""" + case MoveType.EnPassant => + s"""{"type": "enPassant"}""" + case MoveType.Promotion(piece) => + val pieceName = piece match + case PromotionPiece.Queen => "queen" + case PromotionPiece.Rook => "rook" + case PromotionPiece.Bishop => "bishop" + case PromotionPiece.Knight => "knight" + s"""{"type": "promotion", "promotionPiece": "$pieceName"}""" + + /** Escape PGN text for JSON string. */ + private def escapePgn(pgn: String): String = + pgn.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + 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 new file mode 100644 index 0000000..e8381d5 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala @@ -0,0 +1,308 @@ +package de.nowchess.io.json + +import de.nowchess.api.board.* +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.game.GameContext +import de.nowchess.io.GameContextImport +import scala.util.{Try, Success, Failure} + +/** Imports a GameContext from JSON format. + * + * 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: + + def importGameContext(input: String): Either[String, GameContext] = + for + data <- parseRootObject(input) + gameStateJson <- extractGameState(data) + gameStateData <- parseGameStateObject(gameStateJson) + context <- assembleGameContext(gameStateData, data) + yield context + + /** Parse the root JSON object. */ + private def parseRootObject(input: String): Either[String, Map[String, String]] = + Try { + parseJsonObject(input.trim) + }.toEither.left.map("JSON parsing error: " + _.getMessage) + + /** Extract gameState field (new format) or use old format. */ + private def extractGameState(data: Map[String, String]): Either[String, String] = + Right(data.getOrElse("gameState", "{\"board\": [], \"turn\": \"White\", \"castlingRights\": {}, \"enPassantSquare\": \"null\", \"halfMoveClock\": \"0\"}")) + + /** Parse the gameState object. */ + private def parseGameStateObject(json: String): Either[String, Map[String, String]] = + Try { + parseJsonObject(json) + }.toEither.left.map("Failed to parse gameState: " + _.getMessage) + + /** Assemble the complete GameContext from parsed data. */ + private def assembleGameContext(gameStateData: Map[String, String], rootData: Map[String, String]): Either[String, GameContext] = + for + board <- parseBoard(gameStateData.getOrElse("board", "[]")) + turn <- parseTurn(gameStateData.getOrElse("turn", "White")) + castling <- parseCastlingRights(gameStateData.getOrElse("castlingRights", "{}")) + enPassant <- parseEnPassant(gameStateData.getOrElse("enPassantSquare", "null")) + halfMoveClock <- Try(gameStateData.getOrElse("halfMoveClock", "0").toInt) + .toEither.left.map("Invalid halfMoveClock: " + _.getMessage) + moves <- parseMoveHistory(rootData.getOrElse("moves", "[]")) + yield GameContext( + board = board, + turn = turn, + castlingRights = castling, + enPassantSquare = enPassant, + halfMoveClock = halfMoveClock, + moves = moves + ) + + /** Parse a simple JSON object into a Map of field names to JSON values. */ + private def parseJsonObject(json: String): Map[String, String] = + val startIdx = json.indexOf('{') + if startIdx == -1 then Map.empty + else + val (obj, _) = extractJsonObject(json.substring(startIdx)) + if obj.length < 2 then Map.empty + else + val content = obj.substring(1, obj.length - 1).trim + parseFields(content, Map.empty) + + /** Recursively parse fields from JSON object content. */ + private def parseFields(remaining: String, acc: Map[String, String]): Map[String, String] = + if remaining.isEmpty then acc + else + val colonIdx = remaining.indexOf(':') + if colonIdx == -1 then acc + else + val beforeColon = remaining.substring(0, colonIdx).trim + val key = beforeColon.replaceAll("""^[,\s"]+|["\s]+$""", "") + val afterColon = remaining.substring(colonIdx + 1).trim + val (value, rest) = extractJsonValue(afterColon) + if key.nonEmpty && value.nonEmpty then + parseFields(rest, acc + (key -> value)) + else + parseFields(rest, acc) + + /** Extract the next JSON value and return (value, remainder). */ + private def extractJsonValue(json: String): (String, String) = + if json.startsWith("{") then extractJsonObject(json) + else if json.startsWith("[") then extractJsonArray(json) + else if json.startsWith("\"") then extractJsonString(json) + else extractJsonLiteral(json) + + /** Extract a JSON object {...}. */ + private def extractJsonObject(json: String): (String, String) = + var depth = 0 + var i = 0 + var inString = false + var escape = false + + while i < json.length do + val ch = json.charAt(i) + if escape then + escape = false + else if ch == '\\' then + escape = true + else if ch == '"' then + inString = !inString + else if !inString then + if ch == '{' then depth += 1 + else if ch == '}' then + depth -= 1 + if depth == 0 then + return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace)) + i += 1 + + (json, "") + + /** Extract a JSON array [...]. */ + private def extractJsonArray(json: String): (String, String) = + var depth = 0 + var i = 0 + var inString = false + var escape = false + + while i < json.length do + val ch = json.charAt(i) + if escape then + escape = false + else if ch == '\\' then + escape = true + else if ch == '"' then + inString = !inString + else if !inString then + if ch == '[' then depth += 1 + else if ch == ']' then + depth -= 1 + if depth == 0 then + return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace)) + i += 1 + + (json, "") + + /** Extract a JSON string "...". */ + private def extractJsonString(json: String): (String, String) = + var i = 1 + var escape = false + + while i < json.length do + val ch = json.charAt(i) + if escape then + escape = false + else if ch == '\\' then + escape = true + else if ch == '"' then + return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace)) + i += 1 + + (json, "") + + /** Extract a JSON literal (number, true, false, null). */ + private def extractJsonLiteral(json: String): (String, String) = + val endIdx = json.indexWhere(c => c == ',' || c == '}' || c == ']' || c.isWhitespace) + if endIdx == -1 then + (json, "") + else + (json.substring(0, endIdx), json.substring(endIdx).dropWhile(c => c == ',' || c.isWhitespace)) + + /** Parse board array. */ + private def parseBoard(json: String): Either[String, Board] = + Try { + val clean = json.trim.drop(1).dropRight(1) // Remove [ and ] + if clean.isEmpty then Board(Map.empty) + else + val pieces = scala.collection.mutable.Map[Square, Piece]() + var remaining = clean + + while remaining.contains("{") do + val startIdx = remaining.indexOf("{") + val (obj, rest) = extractJsonObject(remaining.substring(startIdx)) + val fields = parseJsonObject(obj) + + for + squareStr <- fields.get("square") + colorStr <- fields.get("color") + pieceStr <- fields.get("piece") + square <- Square.fromAlgebraic(unquote(squareStr)) + color <- parseColorValueOption(unquote(colorStr)) + pieceType <- parsePieceTypeValueOption(unquote(pieceStr)) + do + pieces(square) = Piece(color, pieceType) + + remaining = rest + + Board(pieces.toMap) + }.toEither.left.map("Failed to parse board: " + _.getMessage) + + /** Parse turn field. */ + private def parseTurn(json: String): Either[String, Color] = + Try { + val color = unquote(json.trim) + if color == "White" then Color.White + else if color == "Black" then Color.Black + else throw new Exception(s"Invalid turn color: $color") + }.toEither.left.map("Failed to parse turn: " + _.getMessage) + + /** Parse castling rights object. */ + private def parseCastlingRights(json: String): Either[String, CastlingRights] = + Try { + val fields = parseJsonObject(json) + val wk = fields.get("whiteKingSide").map(_.toLowerCase == "true").getOrElse(false) + val wq = fields.get("whiteQueenSide").map(_.toLowerCase == "true").getOrElse(false) + val bk = fields.get("blackKingSide").map(_.toLowerCase == "true").getOrElse(false) + val bq = fields.get("blackQueenSide").map(_.toLowerCase == "true").getOrElse(false) + CastlingRights(wk, wq, bk, bq) + }.toEither.left.map("Failed to parse castling rights: " + _.getMessage) + + /** Parse en passant square (string or null). */ + private def parseEnPassant(json: String): Either[String, Option[Square]] = + Try { + val trimmed = json.trim + if trimmed == "null" then None: Option[Square] + else + val squareStr = unquote(trimmed) + Square.fromAlgebraic(squareStr) + }.toEither.left.map("Failed to parse en passant: " + _.getMessage) + + /** Parse move history array. */ + private def parseMoveHistory(json: String): Either[String, List[Move]] = + Try { + val clean = json.trim.drop(1).dropRight(1) // Remove [ and ] + if clean.isEmpty then List.empty + else + val moves = scala.collection.mutable.ListBuffer[Move]() + var remaining = clean + + while remaining.contains("{") do + val startIdx = remaining.indexOf("{") + val (obj, rest) = extractJsonObject(remaining.substring(startIdx)) + val fields = parseJsonObject(obj) + + for + fromStr <- fields.get("from") + toStr <- fields.get("to") + typeJson <- fields.get("type") + from <- Square.fromAlgebraic(unquote(fromStr)) + to <- Square.fromAlgebraic(unquote(toStr)) + moveType <- parseMoveTypeUnsafe(typeJson) + do + moves += Move(from, to, moveType) + + remaining = rest + + moves.toList + }.toEither.left.map("Failed to parse move history: " + _.getMessage) + + /** Parse move type object (unsafe version that throws on error for use in Try). */ + private def parseMoveTypeUnsafe(json: String): Option[MoveType] = + val fields = parseJsonObject(json) + fields.get("type").map(unquote(_)) match + case Some("normal") => + val isCapture = fields.get("isCapture").map(_.toLowerCase == "true").getOrElse(false) + Some(MoveType.Normal(isCapture)) + case Some("castleKingside") => + Some(MoveType.CastleKingside) + case Some("castleQueenside") => + Some(MoveType.CastleQueenside) + case Some("enPassant") => + Some(MoveType.EnPassant) + case Some("promotion") => + val piece = fields.get("promotionPiece").map(unquote(_)) match + case Some("queen") => PromotionPiece.Queen + case Some("rook") => PromotionPiece.Rook + case Some("bishop") => PromotionPiece.Bishop + case Some("knight") => PromotionPiece.Knight + case _ => PromotionPiece.Queen + Some(MoveType.Promotion(piece)) + case Some(unknown) => + None + case None => + None + + /** Parse a color value (Option version for use in for-comprehension). */ + private def parseColorValueOption(colorStr: String): Option[Color] = + if colorStr == "White" then Some(Color.White) + else if colorStr == "Black" then Some(Color.Black) + else None + + /** Parse a piece type value (Option version for use in for-comprehension). */ + private def parsePieceTypeValueOption(pieceStr: String): Option[PieceType] = + pieceStr match + 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 + + /** Remove surrounding quotes from a JSON string value. */ + private def unquote(s: String): String = + s.trim.dropWhile(_ == '"').dropWhile(_ == '\'').takeWhile(_ != '"').takeWhile(_ != '\'') diff --git a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala new file mode 100644 index 0000000..980efc9 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala @@ -0,0 +1,123 @@ +package de.nowchess.io + +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.move.Move +import de.nowchess.io.json.{JsonExporter, JsonParser} +import java.nio.file.{Files, Paths} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import scala.util.Using + +class GameFileServiceSuite extends AnyFunSuite with Matchers: + + test("saveGameToFile: writes JSON file successfully") { + val tmpFile = Files.createTempFile("chess_test_", ".json") + try + val context = GameContext.initial + val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter) + + assert(result.isRight) + assert(Files.exists(tmpFile)) + assert(Files.size(tmpFile) > 0) + 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) + } + + test("loadGameFromFile: returns error on missing file") { + val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json") + val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser) + + assert(result.isLeft) + } + + test("saveGameToFile: persists game with moves") { + val tmpFile = Files.createTempFile("chess_test_moves_", ".json") + try + val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5)) + 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) + } + + test("saveGameToFile: overwrites existing file") { + val tmpFile = Files.createTempFile("chess_test_overwrite_", ".json") + try + // Write first file + 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 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) + } + + test("loadGameFromFile: handles invalid JSON in file") { + val tmpFile = Files.createTempFile("chess_test_invalid_", ".json") + try + Files.write(tmpFile, "{ invalid json}".getBytes()) + val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) + + assert(result.isLeft) + finally + Files.deleteIfExists(tmpFile) + } + + test("round-trip: save and load preserves game state") { + val tmpFile = Files.createTempFile("chess_test_roundtrip_", ".json") + try + val move1 = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R4)) + val move2 = Move(Square(File.H, Rank.R7), Square(File.H, Rank.R5)) + val original = GameContext.initial + .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) + } 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 new file mode 100644 index 0000000..76f2500 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala @@ -0,0 +1,115 @@ +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.move.{Move, MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonExporterSuite extends AnyFunSuite with Matchers: + + test("exportGameContext: exports initial position") { + val context = GameContext.initial + val json = JsonExporter.exportGameContext(context) + + json should include("\"metadata\"") + json should include("\"gameState\"") + json should include("\"moveHistory\"") + json should include("\"capturedPieces\"") + json should include("\"timestamp\"") + } + + test("exportGameContext: includes board pieces") { + val context = GameContext.initial + val json = JsonExporter.exportGameContext(context) + + json should include("\"a1\"") + json should include("\"Rook\"") + json should include("\"White\"") + } + + test("exportGameContext: includes turn information") { + val context = GameContext.initial + val json = JsonExporter.exportGameContext(context) + + json should include("\"turn\": \"White\"") + } + + test("exportGameContext: includes castling rights") { + val context = GameContext.initial + 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 context = GameContext.initial.withMove(move) + val json = JsonExporter.exportGameContext(context) + + json should include("\"moves\"") + json should include("\"from\"") + json should include("\"to\"") + json should include("\"e2\"") + json should include("\"e4\"") + } + + test("exportGameContext: valid JSON structure") { + val context = GameContext.initial + val json = JsonExporter.exportGameContext(context) + + json should startWith("{") + json should endWith("}") + json should include("\"metadata\": {") + json should include("\"gameState\": {") + } + + test("exportGameContext: empty move history for initial position") { + val context = GameContext.initial + 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) + + json should include("\"enPassantSquare\": \"e3\"") + } + + test("exportGameContext: exports null en passant square") { + val context = GameContext.initial.copy(enPassantSquare = None) + 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 context = GameContext.initial.withMove(move) + 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) + + 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) + + json should include("\"whiteKingSide\": false") + json should include("\"whiteQueenSide\": false") + json should include("\"blackKingSide\": false") + json should include("\"blackQueenSide\": false") + } 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 new file mode 100644 index 0000000..a499b34 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala @@ -0,0 +1,154 @@ +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.move.{Move, MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonParserSuite extends AnyFunSuite with Matchers: + + test("importGameContext: parses valid JSON") { + 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) + + 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) + + 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 context = GameContext.initial.withMove(move) + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + + assert(result.map(_.moves.length) == Right(1)) + } + + test("importGameContext: handles empty board") { + val json = """{ + "metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"}, + "gameState": { + "board": [], + "turn": "White", + "castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true}, + "enPassantSquare": null, + "halfMoveClock": 0 + }, + "moves": [], + "moveHistory": "", + "capturedPieces": {"byWhite": [], "byBlack": []}, + "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 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) + + assert(result.map(_.castlingRights.whiteKingSide) == Right(false)) + } + + test("importGameContext: round-trip consistency") { + val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5)) + val context = GameContext.initial + .withMove(move1) + .withMove(move2) + .withTurn(Color.White) + + val json = JsonExporter.exportGameContext(context) + val restored = JsonParser.importGameContext(json) + + assert(restored.map(_.moves.length) == Right(2)) + assert(restored.map(_.turn) == Right(Color.White)) + } + + test("importGameContext: handles half-move clock") { + val context = GameContext.initial.withHalfMoveClock(5) + 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) + + 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) + + 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 context = GameContext.initial.withMove(move) + 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) + + assert(result.map(_.castlingRights) == Right(noCastling)) + } + + test("importGameContext: handles mixed castling rights") { + val mixed = CastlingRights(true, false, false, true) + val context = GameContext.initial.withCastlingRights(mixed) + val json = JsonExporter.exportGameContext(context) + val result = JsonParser.importGameContext(json) + + assert(result.map(_.castlingRights) == Right(mixed)) + } diff --git a/modules/ui/chess_game.json b/modules/ui/chess_game.json new file mode 100644 index 0000000..4856d46 --- /dev/null +++ b/modules/ui/chess_game.json @@ -0,0 +1,41 @@ +{ + "metadata": { + "event": "Game", + "players": { + "white": "White Player", + "black": "Black Player" + }, + "date": "2026-04-06", + "result": "*" + }, + "gameState": { + "board": [{"square": "g8", "color": "Black", "piece": "Knight"}, {"square": "d8", "color": "Black", "piece": "Queen"}, {"square": "f8", "color": "Black", "piece": "Bishop"}, {"square": "b2", "color": "White", "piece": "Pawn"}, {"square": "c1", "color": "White", "piece": "Bishop"}, {"square": "c7", "color": "Black", "piece": "Pawn"}, {"square": "f7", "color": "Black", "piece": "Pawn"}, {"square": "b7", "color": "Black", "piece": "Pawn"}, {"square": "b1", "color": "White", "piece": "Knight"}, {"square": "h8", "color": "Black", "piece": "Rook"}, {"square": "a1", "color": "White", "piece": "Rook"}, {"square": "g2", "color": "White", "piece": "Pawn"}, {"square": "e1", "color": "White", "piece": "King"}, {"square": "c2", "color": "White", "piece": "Pawn"}, {"square": "h2", "color": "White", "piece": "Pawn"}, {"square": "a8", "color": "Black", "piece": "Rook"}, {"square": "f1", "color": "White", "piece": "Bishop"}, {"square": "d6", "color": "Black", "piece": "Pawn"}, {"square": "a2", "color": "White", "piece": "Pawn"}, {"square": "d1", "color": "White", "piece": "Queen"}, {"square": "e2", "color": "White", "piece": "Pawn"}, {"square": "c8", "color": "Black", "piece": "Bishop"}, {"square": "a7", "color": "Black", "piece": "Pawn"}, {"square": "b8", "color": "Black", "piece": "Knight"}, {"square": "d2", "color": "White", "piece": "Pawn"}, {"square": "e8", "color": "Black", "piece": "King"}, {"square": "f4", "color": "White", "piece": "Pawn"}, {"square": "g7", "color": "Black", "piece": "Pawn"}, {"square": "h7", "color": "Black", "piece": "Pawn"}, {"square": "e7", "color": "Black", "piece": "Pawn"}, {"square": "h1", "color": "White", "piece": "Rook"}, {"square": "g1", "color": "White", "piece": "Knight"}], + "turn": "White", + "castlingRights": { + "whiteKingSide": true, + "whiteQueenSide": true, + "blackKingSide": true, + "blackQueenSide": true + }, + "enPassantSquare": null, + "halfMoveClock": 0 + }, + "moveHistory": "[Event \"?\"]\n[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n1. f4 d6 *", + "moves": [ + { + "from": "f2", + "to": "f4", + "type": {"type": "normal", "isCapture": false} + }, + { + "from": "d7", + "to": "d6", + "type": {"type": "normal", "isCapture": false} + } + ], + "capturedPieces": { + "byWhite": ["Pawn"], + "byBlack": ["Pawn"] + }, + "timestamp": "2026-04-06T11:39:00.746137480Z[UTC]" +} \ No newline at end of file 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 e46ac69..24252c2 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 @@ -16,7 +16,11 @@ import de.nowchess.chess.command.{MoveCommand, MoveResult} import de.nowchess.chess.engine.GameEngine import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser} -import de.nowchess.io.{GameContextExport, GameContextImport} +import de.nowchess.io.json.{JsonExporter, JsonParser} +import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService} +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. @@ -124,6 +128,22 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;" } ) + }, + new HBox { + spacing = 10 + alignment = Pos.Center + children = Seq( + new Button("JSON Export") { + font = Font.font(comicSansFontFamily, 12) + onAction = _ => doJsonExport() + style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;" + }, + new Button("JSON Import") { + font = Font.font(comicSansFontFamily, 12) + onAction = _ => doJsonImport() + style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;" + } + ) } ) } @@ -289,6 +309,45 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B private def doPgnImport(): Unit = doImport(PgnParser, "PGN") + private def doJsonExport(): Unit = + val fileChooser = new FileChooser { + title = "Export Game as JSON" + initialFileName = "chess_game.json" + 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 + ) + result match + case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}") + case Left(err) => showMessage(s"⚠️ Error saving file: $err") + + private def doJsonImport(): Unit = + val fileChooser = new FileChooser { + title = "Import Game from JSON" + 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 + ) + result match + case Right(gameContext) => + engine.loadPosition(gameContext) + showMessage(s"✓ Game loaded from: ${selectedFile.getName}") + case Left(err) => + showMessage(s"⚠️ Error: $err") + private def doExport(exporter: GameContextExport, formatName: String): Unit = { val exported = exporter.exportGameContext(engine.context) showCopyDialog(s"$formatName Export", exported)