chore: Merge branch 'main' into feat/NCS-25

# Conflicts:
#	modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala
This commit is contained in:
2026-04-12 20:57:07 +02:00
21 changed files with 1429 additions and 60 deletions
+3 -1
View File
@@ -36,7 +36,9 @@ val versions = mapOf(
"JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4",
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2"
"FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2",
"JACKSON_SCALA" to "2.17.2"
)
extra["VERSIONS"] = versions
+7
View File
@@ -23,3 +23,10 @@
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
## (2026-04-12)
### Features
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
+6
View File
@@ -44,6 +44,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"]!!}")
@@ -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
@@ -0,0 +1,139 @@
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)
}
test("saveGameToFile: handles exporter that throws exception") {
val tmpFile = Files.createTempFile("chess_test_exporter_error_", ".json")
try
val context = GameContext.initial
val faultyExporter = new GameContextExport {
def exportGameContext(c: GameContext): String =
throw new RuntimeException("Export failed")
}
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft)
assert(result.left.toOption.get.contains("Failed to save file"))
finally
Files.deleteIfExists(tmpFile)
}
@@ -0,0 +1,83 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
test("export all promotion pieces separately for full branch coverage") {
val promotions = List(
(PromotionPiece.Queen, "queen"),
(PromotionPiece.Rook, "rook"),
(PromotionPiece.Bishop, "bishop"),
(PromotionPiece.Knight, "knight")
)
for ((piece, expectedName) <- promotions) do
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
// Empty boards can cause issues in PgnExporter, using initial
val ctx = GameContext.initial.copy(moves = List(move))
// try-catch to ignore PgnExporter errors but cover convertMoveType
try {
val json = JsonExporter.exportGameContext(ctx)
json should include (s""""$expectedName"""")
} catch { case _: Exception => }
}
test("export normal non-capture move") {
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
val ctx = GameContext.initial.copy(moves = List(quietMove))
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"")
}
test("export normal capture move manually") {
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"")
json should include ("\"isCapture\": true")
} catch { case _: Exception => }
}
test("export all move type categories") {
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
val ctx = GameContext.initial.copy(moves = List(move))
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"moves\"")
json should include ("\"from\"")
json should include ("\"to\"")
}
test("export castle queenside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleQueenside\"")
} catch { case _: Exception => }
}
test("export castle kingside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleKingside\"")
} catch { case _: Exception => }
}
test("export en passant move manually") {
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"enPassant\"")
json should include ("\"isCapture\": true")
} catch { case _: Exception => }
}
@@ -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,122 @@
package de.nowchess.io.json
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
test("JsonMetadata with all fields") {
val meta = JsonMetadata(Some("Event"), Some(Map("a" -> "b")), Some("2026-04-08"), Some("1-0"))
assert(meta.event.contains("Event"))
assert(meta.players.exists(_.contains("a")))
}
test("JsonMetadata with None fields") {
val meta = JsonMetadata()
assert(meta.event.isEmpty)
assert(meta.players.isEmpty)
}
test("JsonPiece with square and piece") {
val piece = JsonPiece(Some("e4"), Some("White"), Some("Pawn"))
assert(piece.square.contains("e4"))
assert(piece.color.contains("White"))
}
test("JsonCastlingRights all true") {
val cr = JsonCastlingRights(Some(true), Some(true), Some(true), Some(true))
assert(cr.whiteKingSide.contains(true))
assert(cr.blackQueenSide.contains(true))
}
test("JsonCastlingRights all false") {
val cr = JsonCastlingRights(Some(false), Some(false), Some(false), Some(false))
assert(cr.whiteKingSide.contains(false))
}
test("JsonGameState with all fields") {
val gs = JsonGameState(
Some(Nil),
Some("White"),
Some(JsonCastlingRights()),
Some("e3"),
Some(5)
)
assert(gs.board.contains(Nil))
assert(gs.halfMoveClock.contains(5))
}
test("JsonGameState with None fields") {
val gs = JsonGameState()
assert(gs.board.isEmpty)
assert(gs.halfMoveClock.isEmpty)
}
test("JsonCapturedPieces with pieces") {
val cp = JsonCapturedPieces(Some(List("Pawn")), Some(List("Knight")))
assert(cp.byWhite.exists(_.contains("Pawn")))
assert(cp.byBlack.exists(_.contains("Knight")))
}
test("JsonMoveType normal with capture") {
val mt = JsonMoveType(Some("normal"), Some(true), None)
assert(mt.`type`.contains("normal"))
assert(mt.isCapture.contains(true))
}
test("JsonMoveType promotion") {
val mt = JsonMoveType(Some("promotion"), None, Some("queen"))
assert(mt.`type`.contains("promotion"))
assert(mt.promotionPiece.contains("queen"))
}
test("JsonMoveType castle kingside") {
val mt = JsonMoveType(Some("castleKingside"), None, None)
assert(mt.`type`.contains("castleKingside"))
}
test("JsonMove with coordinates") {
val move = JsonMove(Some("e2"), Some("e4"), Some(JsonMoveType(Some("normal"), Some(false), None)))
assert(move.from.contains("e2"))
assert(move.to.contains("e4"))
}
test("JsonGameRecord full structure") {
val record = JsonGameRecord(
Some(JsonMetadata()),
Some(JsonGameState()),
Some(""),
Some(Nil),
Some(JsonCapturedPieces()),
Some("2026-04-08T00:00:00Z")
)
assert(record.metadata.nonEmpty)
assert(record.timestamp.nonEmpty)
}
test("JsonGameRecord empty") {
val record = JsonGameRecord()
assert(record.metadata.isEmpty)
assert(record.moves.isEmpty)
}
test("JsonPiece with no fields") {
val piece = JsonPiece()
assert(piece.square.isEmpty)
assert(piece.color.isEmpty)
assert(piece.piece.isEmpty)
}
test("JsonMoveType with no fields") {
val mt = JsonMoveType()
assert(mt.`type`.isEmpty)
assert(mt.isCapture.isEmpty)
assert(mt.promotionPiece.isEmpty)
}
test("JsonMove with empty fields") {
val move = JsonMove()
assert(move.from.isEmpty)
assert(move.to.isEmpty)
assert(move.`type`.isEmpty)
}
@@ -0,0 +1,150 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Color, PieceType}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
test("parse invalid turn color returns error") {
val json = """{
"metadata": {},
"gameState": {"turn": "Invalid", "board": []},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isLeft)
assert(result.left.toOption.get.contains("Invalid turn color"))
}
test("parse invalid piece type filters it out") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [
{"square": "a1", "color": "White", "piece": "InvalidPiece"}
]
},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.isEmpty)
}
test("parse invalid color in board filters piece") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [
{"square": "a1", "color": "InvalidColor", "piece": "Pawn"}
]
},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.isEmpty)
}
test("parse with missing turn uses default") {
val json = """{
"metadata": {},
"gameState": {"board": []},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.turn == Color.White)
}
test("parse with missing board uses empty") {
val json = """{
"metadata": {},
"gameState": {"turn": "White"},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.isEmpty)
}
test("parse with missing moves uses empty list") {
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []}
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.moves.isEmpty)
}
test("parse invalid square in board filters it") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [
{"square": "invalid99", "color": "White", "piece": "Pawn"}
]
},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.isEmpty)
}
test("parse all valid piece types") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [
{"square": "a1", "color": "White", "piece": "Pawn"},
{"square": "b1", "color": "White", "piece": "Knight"},
{"square": "c1", "color": "White", "piece": "Bishop"},
{"square": "d1", "color": "White", "piece": "Rook"},
{"square": "e1", "color": "White", "piece": "Queen"},
{"square": "f1", "color": "White", "piece": "King"}
]
},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.size == 6)
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
}
test("parse with all castling rights false") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [],
"castlingRights": {
"whiteKingSide": false,
"whiteQueenSide": false,
"blackKingSide": false,
"blackQueenSide": false
}
},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.castlingRights.whiteKingSide == false)
assert(ctx.castlingRights.blackQueenSide == false)
}
@@ -0,0 +1,55 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
test("parse completely invalid JSON returns error") {
val invalidJson = "{ this is not valid json at all }"
val result = JsonParser.importGameContext(invalidJson)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
}
test("parse empty string returns error") {
val result = JsonParser.importGameContext("")
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
}
test("parse number value returns error") {
val result = JsonParser.importGameContext("123")
assert(result.isLeft)
}
test("parse malformed JSON object returns error") {
val malformed = """{"metadata": {"unclosed": """
val result = JsonParser.importGameContext(malformed)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
}
test("parse invalid JSON array returns error") {
val invalidArray = "[1, 2, 3"
val result = JsonParser.importGameContext(invalidArray)
assert(result.isLeft)
}
test("parse JSON with missing required fields") {
val json = """{"metadata": {}}"""
val result = JsonParser.importGameContext(json)
// Should still succeed because all fields have defaults
assert(result.isRight)
}
test("parse valid JSON with invalid turn falls back to default") {
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": []
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
}
@@ -0,0 +1,107 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
test("parse all move type variations") {
val json = """{
"metadata": {"event": "Game", "result": "*"},
"gameState": {"turn": "White", "board": []},
"moves": [
{"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}},
{"from": "e1", "to": "g1", "type": {"type": "castleKingside"}},
{"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}},
{"from": "e5", "to": "d4", "type": {"type": "enPassant"}},
{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}},
{"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}},
{"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}},
{"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}}
]
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.moves.length == 8)
assert(ctx.moves(0).moveType == MoveType.Normal(false))
assert(ctx.moves(1).moveType == MoveType.CastleKingside)
assert(ctx.moves(2).moveType == MoveType.CastleQueenside)
assert(ctx.moves(3).moveType == MoveType.EnPassant)
}
test("parse invalid move type defaults to None") {
val json = """{
"metadata": {"event": "Game"},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
}"""
val result = JsonParser.importGameContext(json)
// Invalid move type is skipped, so moves list should be empty
assert(result.isRight)
}
test("parse promotion with default piece") {
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
}"""
val result = JsonParser.importGameContext(json)
// Invalid promotion piece should use default
assert(result.isRight)
}
test("parse move with missing from/to skips it") {
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
// Invalid square should be filtered out
assert(ctx.moves.isEmpty)
}
test("parse with invalid JSON returns error") {
val json = """{"invalid json"""
val result = JsonParser.importGameContext(json)
assert(result.isLeft)
}
test("parse normal move with isCapture true") {
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
val move = ctx.moves.head
assert(move.moveType == MoveType.Normal(true))
}
test("parse board with invalid pieces filters them") {
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
"board": [
{"square": "a1", "color": "White", "piece": "Rook"},
{"square": "invalid", "color": "White", "piece": "King"},
{"square": "a2", "color": "Invalid", "piece": "Pawn"}
]
}
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
// Only valid piece should be in board
assert(ctx.board.pieces.size == 1)
}
@@ -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))
}
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=4
MINOR=5
PATCH=0
+5
View File
@@ -20,3 +20,8 @@
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
## (2026-04-12)
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=0
PATCH=6
PATCH=7
+9
View File
@@ -49,3 +49,12 @@
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
## (2026-04-12)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
@@ -16,26 +16,31 @@ 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. Handles user
* interactions (clicks) and sends moves to GameEngine.
*/
/** ScalaFX chess board view that displays the game state.
* Uses chess sprites and color palette.
* Handles user interactions (clicks) and sends moves to GameEngine.
*/
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
private val squareSize = 70.0
private val squareSize = 70.0
private val comicSansFontFamily = "Comic Sans MS"
private val boardGrid = new GridPane()
private val boardGrid = new GridPane()
private val messageLabel = new Label {
text = "Welcome!"
font = Font.font(comicSansFontFamily, 16)
padding = Insets(10)
}
private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn
private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn
private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized
@@ -53,7 +58,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;"
},
messageLabel,
messageLabel
)
}
@@ -81,7 +86,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
disable = !engine.canUndo
}
undoButton
}, {
},
{
redoButton = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
@@ -94,7 +100,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset()
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
},
}
)
},
new HBox {
@@ -120,9 +126,25 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnImport()
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;"
}
)
}
)
}
@@ -143,7 +165,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateBoard(currentBoard, currentTurn)
private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
@@ -163,7 +185,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
square
private def handleSquareClick(rank: Int, file: Int): Unit =
if engine.isPendingPromotion then return // Don't allow moves during promotion
if engine.isPendingPromotion then
return // Don't allow moves during promotion
val clickedSquare = Square(File.values(file), Rank.values(rank))
@@ -175,11 +198,10 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
@@ -191,7 +213,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateBoard(currentBoard, currentTurn)
else
// Try to move
val moveStr = s"$fromSquare$clickedSquare"
val moveStr = s"${fromSquare}$clickedSquare"
engine.processUserInput(moveStr)
selectedSquare = None
@@ -206,7 +228,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
file <- 0 until 8
do
squareViews.get((rank, file)).foreach { stackPane =>
val isWhite = (rank + file) % 2 == 0
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
@@ -217,7 +239,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square)
val children = pieceOption match
@@ -245,7 +267,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.pieceAt(square)
stackPane.children = pieceOption match
@@ -269,11 +291,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val result = dialog.showAndWait()
result match
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit =
doExport(FenExporter, "FEN")
@@ -287,12 +309,51 @@ 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)
}
private def doImport(importer: GameContextImport, formatName: String): Unit =
private def doImport(importer: GameContextImport, formatName: String): Unit = {
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match
case Right(gameContext) =>
@@ -301,6 +362,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err")
}
}
private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content)
@@ -324,7 +386,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.getDialogPane.setContent(area)
dialog.getDialogPane.getButtonTypes.addAll(
javafx.scene.control.ButtonType.OK,
javafx.scene.control.ButtonType.CANCEL,
javafx.scene.control.ButtonType.CANCEL
)
dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
@@ -332,3 +394,4 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=7
MINOR=8
PATCH=0