diff --git a/build.gradle.kts b/build.gradle.kts index 5526d7a..ae4c348 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,7 +32,9 @@ val versions = mapOf( "SCOVERAGE" to "2.1.1", "SCALAFX" to "21.0.0-R32", "JAVAFX" to "21.0.1", - "JUNIT_BOM" to "5.13.4" + "JUNIT_BOM" to "5.13.4", + "JACKSON" to "2.17.2", + "JACKSON_SCALA" to "2.17.2" ) extra["VERSIONS"] = versions diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts index 8c8fffb..af4e060 100644 --- a/modules/io/build.gradle.kts +++ b/modules/io/build.gradle.kts @@ -41,6 +41,12 @@ dependencies { implementation(project(":modules:api")) implementation(project(":modules:rule")) + // Jackson for JSON serialization/deserialization + implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}") + implementation("com.fasterxml.jackson.core:jackson-core:${versions["JACKSON"]!!}") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}") + testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") 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 5df0c78..b508f2d 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 @@ -1,14 +1,16 @@ package de.nowchess.io.json +import com.fasterxml.jackson.databind.{ObjectMapper, SerializationFeature} +import com.fasterxml.jackson.core.util.{DefaultIndenter, DefaultPrettyPrinter} +import com.fasterxml.jackson.module.scala.DefaultScalaModule 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 +import java.time.{LocalDate, ZonedDateTime, ZoneId} -/** Exports a GameContext to a comprehensive JSON format. +/** Exports a GameContext to a comprehensive JSON format using Jackson. * * The JSON includes: * - Game metadata (players, event, date, result) @@ -19,145 +21,114 @@ import scala.collection.mutable * - 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() + printer.indentArraysWith(indenter) + printer.indentObjectsWith(indenter) + + mapper.setDefaultPrettyPrinter(printer) + mapper.enable(SerializationFeature.INDENT_OUTPUT) + mapper 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() + val record = buildGameRecord(context) + formatJson(mapper.writeValueAsString(record)) - s"""{ - "metadata": $metadata, - "gameState": $gameState, - "moveHistory": "${escapePgn(pgnMoves)}", - "moves": $moves, - "capturedPieces": $captured, - "timestamp": "$timestamp" -}""" + private def buildGameRecord(context: GameContext): JsonGameRecord = + JsonGameRecord( + metadata = Some(buildMetadata()), + gameState = Some(buildGameState(context)), + moveHistory = Some(PgnExporter.exportGameContext(context)), + moves = Some(buildMoves(context.moves)), + capturedPieces = Some(buildCapturedPieces(context.board)), + timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString) + ) - /** Build metadata section. */ - private def buildMetadata(): String = - s"""{ - "event": "Game", - "players": { - "white": "White Player", - "black": "Black Player" - }, - "date": "${java.time.LocalDate.now}", - "result": "*" - }""" + private def buildMetadata(): JsonMetadata = + JsonMetadata( + event = Some("Game"), + players = Some(Map("white" -> "White Player", "black" -> "Black Player")), + date = Some(LocalDate.now().toString), + result = Some("*") + ) - /** 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} - }""" + private def buildGameState(context: GameContext): JsonGameState = + JsonGameState( + board = Some(buildBoardPieces(context.board)), + turn = Some(context.turn.label), + castlingRights = Some(buildCastlingRights(context.castlingRights)), + enPassantSquare = context.enPassantSquare.map(_.toString), + halfMoveClock = Some(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 - }""" + private def buildBoardPieces(board: Board): List[JsonPiece] = + board.pieces.toList.map { case (sq, p) => + JsonPiece(Some(sq.toString), Some(p.color.label), Some(p.pieceType.label)) } - if moveObjects.isEmpty then "[]" - else s"[\n${moveObjects.mkString(",\n")}\n ]" + private def buildCastlingRights(rights: CastlingRights): JsonCastlingRights = + JsonCastlingRights( + Some(rights.whiteKingSide), + Some(rights.whiteQueenSide), + Some(rights.blackKingSide), + Some(rights.blackQueenSide) + ) - /** Export move type as a JSON object. */ - private def exportMoveType(moveType: MoveType): String = - moveType match + private def buildMoves(moves: List[Move]): List[JsonMove] = + moves.map { m => + val moveType = convertMoveType(m.moveType) + JsonMove(Some(m.from.toString), Some(m.to.toString), moveType) + } + + private def convertMoveType(moveType: MoveType): Option[JsonMoveType] = + val (tpe, isC, pp) = moveType match { case MoveType.Normal(isCapture) => - s"""{"type": "normal", "isCapture": $isCapture}""" + (Some("normal"), Some(isCapture), None) case MoveType.CastleKingside => - s"""{"type": "castleKingside"}""" + (Some("castleKingside"), None, None) case MoveType.CastleQueenside => - s"""{"type": "castleQueenside"}""" + (Some("castleQueenside"), None, None) case MoveType.EnPassant => - s"""{"type": "enPassant"}""" + (Some("enPassant"), None, None) case MoveType.Promotion(piece) => - val pieceName = piece match + val pName = piece match { case PromotionPiece.Queen => "queen" case PromotionPiece.Rook => "rook" case PromotionPiece.Bishop => "bishop" case PromotionPiece.Knight => "knight" - s"""{"type": "promotion", "promotionPiece": "$pieceName"}""" + } + (Some("promotion"), None, Some(pName)) + } + Some(JsonMoveType(tpe, isC, pp)) - /** Escape PGN text for JSON string. */ - private def escapePgn(pgn: String): String = - pgn.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") + private def buildCapturedPieces(board: Board): JsonCapturedPieces = + val (byWhite, byBlack) = getCapturedPieces(board) + JsonCapturedPieces(Some(byWhite), Some(byBlack)) + + private def formatJson(json: String): String = + json + .replace(" : ", ": ") + .replaceAll("\\[\\s*\\]", "[]") + .replaceAll("\\{\\s*\\}", "{}") + + private def getCapturedPieces(board: Board): (List[String], List[String]) = + val initialBoard = Board.initial + val captured = Square.all.flatMap { square => + initialBoard.pieceAt(square).flatMap { initialPiece => + board.pieceAt(square) match + 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 new file mode 100644 index 0000000..208140e --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala @@ -0,0 +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 +) + +case class JsonPiece( + 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 +) + +case class JsonGameState( + 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 +) + +case class JsonMoveType( + `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 +) + +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 +) 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 e8381d5..deefc4c 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,12 +1,14 @@ package de.nowchess.io.json +import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature} +import com.fasterxml.jackson.module.scala.DefaultScalaModule 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} +import scala.util.Try -/** Imports a GameContext from JSON format. +/** Imports a GameContext from JSON format using Jackson. * * Parses JSON exported by JsonExporter and reconstructs the GameContext including: * - Board state @@ -20,281 +22,57 @@ import scala.util.{Try, Success, Failure} */ object JsonParser extends GameContextImport: + private val mapper = new ObjectMapper() + .registerModule(DefaultScalaModule) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + def importGameContext(input: String): Either[String, GameContext] = - for - data <- parseRootObject(input) - gameStateJson <- extractGameState(data) - gameStateData <- parseGameStateObject(gameStateJson) - context <- assembleGameContext(gameStateData, data) - yield context + Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither + .left.map(e => "JSON parsing error: " + e.getMessage) + .flatMap { data => + val gs = data.gameState.getOrElse(JsonGameState()) + val rawBoard = gs.board.getOrElse(Nil) + val rawTurn = gs.turn.getOrElse("White") + val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights()) + val rawHmc = gs.halfMoveClock.getOrElse(0) + val rawMoves = data.moves.getOrElse(Nil) - /** 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) + for + board <- parseBoard(rawBoard) + turn <- parseTurn(rawTurn) + castlingRights = parseCastlingRights(rawCr) + enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s)) + moves <- parseMoves(rawMoves) + yield GameContext( + board = board, + turn = turn, + castlingRights = castlingRights, + enPassantSquare = enPassantSquare, + halfMoveClock = rawHmc, + moves = moves + ) + } - /** 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\"}")) + private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] = + val parsedPieces = pieces.flatMap { p => + for + sq <- p.square.flatMap(Square.fromAlgebraic) + color <- p.color.flatMap(parseColor) + pt <- p.piece.flatMap(parsePieceType) + yield (sq, Piece(color, pt)) + } + Right(Board(parsedPieces.toMap)) - /** 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) + private def parseTurn(color: String): Either[String, Color] = + parseColor(color).toRight(s"Invalid turn color: $color") - /** 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) + private def parseColor(color: String): Option[Color] = + if color == "White" then Some(Color.White) + else if color == "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 + private def parsePieceType(pt: String): Option[PieceType] = + pt match case "Pawn" => Some(PieceType.Pawn) case "Knight" => Some(PieceType.Knight) case "Bishop" => Some(PieceType.Bishop) @@ -303,6 +81,39 @@ object JsonParser extends GameContextImport: 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(_ != '\'') + private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights = + CastlingRights( + cr.whiteKingSide.getOrElse(false), + cr.whiteQueenSide.getOrElse(false), + cr.blackKingSide.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) + moveType <- m.`type`.flatMap(parseMoveType) + yield Move(from, to, moveType) + }) + + private def parseMoveType(mt: JsonMoveType): Option[MoveType] = + mt.`type` match + case Some("normal") => + Some(MoveType.Normal(mt.isCapture.getOrElse(false))) + case Some("castleKingside") => + Some(MoveType.CastleKingside) + case Some("castleQueenside") => + Some(MoveType.CastleQueenside) + case Some("enPassant") => + Some(MoveType.EnPassant) + case Some("promotion") => + val piece = mt.promotionPiece match + case Some("queen") => PromotionPiece.Queen + case Some("rook") => PromotionPiece.Rook + case Some("bishop") => PromotionPiece.Bishop + case Some("knight") => PromotionPiece.Knight + case _ => PromotionPiece.Queen // default + Some(MoveType.Promotion(piece)) + case _ => None diff --git a/modules/ui/chess_game.json b/modules/ui/chess_game.json index 4856d46..22ee103 100644 --- a/modules/ui/chess_game.json +++ b/modules/ui/chess_game.json @@ -5,37 +5,199 @@ "white": "White Player", "black": "Black Player" }, - "date": "2026-04-06", + "date": "2026-04-07", "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", + "board": [ + { + "square": "d1", + "color": "White", + "piece": "Queen" + }, + { + "square": "f1", + "color": "White", + "piece": "Bishop" + }, + { + "square": "c7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "g2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "f2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "b1", + "color": "White", + "piece": "Knight" + }, + { + "square": "e2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "f7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "d8", + "color": "Black", + "piece": "Queen" + }, + { + "square": "a2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "f3", + "color": "White", + "piece": "Knight" + }, + { + "square": "g8", + "color": "Black", + "piece": "Knight" + }, + { + "square": "e7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "c8", + "color": "Black", + "piece": "Bishop" + }, + { + "square": "h2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "d2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "g7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "a1", + "color": "White", + "piece": "Rook" + }, + { + "square": "h7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "b8", + "color": "Black", + "piece": "Knight" + }, + { + "square": "c2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "a8", + "color": "Black", + "piece": "Rook" + }, + { + "square": "f8", + "color": "Black", + "piece": "Bishop" + }, + { + "square": "c1", + "color": "White", + "piece": "Bishop" + }, + { + "square": "b7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "a7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "e1", + "color": "White", + "piece": "King" + }, + { + "square": "d7", + "color": "Black", + "piece": "Pawn" + }, + { + "square": "b2", + "color": "White", + "piece": "Pawn" + }, + { + "square": "h8", + "color": "Black", + "piece": "Rook" + }, + { + "square": "e8", + "color": "Black", + "piece": "King" + }, + { + "square": "h1", + "color": "White", + "piece": "Rook" + } + ], + "turn": "Black", "castlingRights": { - "whiteKingSide": true, - "whiteQueenSide": true, - "blackKingSide": true, - "blackQueenSide": true - }, + "whiteKingSide": true, + "whiteQueenSide": true, + "blackKingSide": true, + "blackQueenSide": true + }, "enPassantSquare": null, - "halfMoveClock": 0 + "halfMoveClock": 1 }, - "moveHistory": "[Event \"?\"]\n[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n1. f4 d6 *", + "moveHistory": "[Event \"?\"]\n[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n1. Nf3 *", "moves": [ { - "from": "f2", - "to": "f4", - "type": {"type": "normal", "isCapture": false} - }, - { - "from": "d7", - "to": "d6", - "type": {"type": "normal", "isCapture": false} + "from": "g1", + "to": "f3", + "type": { + "type": "normal", + "isCapture": false, + "promotionPiece": null + } } ], "capturedPieces": { - "byWhite": ["Pawn"], - "byBlack": ["Pawn"] + "byWhite": [], + "byBlack": [ + "Knight" + ] }, - "timestamp": "2026-04-06T11:39:00.746137480Z[UTC]" + "timestamp": "2026-04-07T12:53:26.346013008Z[UTC]" } \ No newline at end of file