feat: NCS-29 JSON - Cherry Picked (#28)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #28 Reviewed-by: Shahd Lala <shosho996@blackhole.local> Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #28.
This commit is contained in:
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
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.{LocalDate, ZonedDateTime, ZoneId}
|
||||
|
||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
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 record = buildGameRecord(context)
|
||||
formatJson(mapper.writeValueAsString(record))
|
||||
|
||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||
val pgn = try {
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
} catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
JsonGameRecord(
|
||||
metadata = Some(buildMetadata()),
|
||||
gameState = Some(buildGameState(context)),
|
||||
moveHistory = pgn,
|
||||
moves = Some(buildMoves(context.moves)),
|
||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
||||
)
|
||||
|
||||
private def buildMetadata(): JsonMetadata =
|
||||
JsonMetadata(
|
||||
event = Some("Game"),
|
||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||
date = Some(LocalDate.now().toString),
|
||||
result = Some("*")
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
private def buildCastlingRights(rights: CastlingRights): JsonCastlingRights =
|
||||
JsonCastlingRights(
|
||||
Some(rights.whiteKingSide),
|
||||
Some(rights.whiteQueenSide),
|
||||
Some(rights.blackKingSide),
|
||||
Some(rights.blackQueenSide)
|
||||
)
|
||||
|
||||
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) =>
|
||||
(Some("normal"), Some(isCapture), None)
|
||||
case MoveType.CastleKingside =>
|
||||
(Some("castleKingside"), None, None)
|
||||
case MoveType.CastleQueenside =>
|
||||
(Some("castleQueenside"), None, None)
|
||||
case MoveType.EnPassant =>
|
||||
(Some("enPassant"), Some(true), None)
|
||||
case MoveType.Promotion(piece) =>
|
||||
val pName = piece match {
|
||||
case PromotionPiece.Queen => "queen"
|
||||
case PromotionPiece.Rook => "rook"
|
||||
case PromotionPiece.Bishop => "bishop"
|
||||
case PromotionPiece.Knight => "knight"
|
||||
}
|
||||
(Some("promotion"), None, Some(pName))
|
||||
}
|
||||
Some(JsonMoveType(tpe, isC, pp))
|
||||
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
|
||||
/** Imports a GameContext from JSON format using Jackson.
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
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] =
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
private def parseTurn(color: String): Either[String, Color] =
|
||||
parseColor(color).toRight(s"Invalid turn color: $color")
|
||||
|
||||
private def parseColor(color: String): Option[Color] =
|
||||
if color == "White" then Some(Color.White)
|
||||
else if color == "Black" then Some(Color.Black)
|
||||
else None
|
||||
|
||||
private def parsePieceType(pt: String): Option[PieceType] =
|
||||
pt 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
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user