This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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"]!!}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
+183
-21
@@ -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]"
|
||||
}
|
||||
Reference in New Issue
Block a user