feat: I/O json export import, tests should be 100%
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
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,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")
|
||||
|
||||
@@ -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(_ != '\'')
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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]"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user