From 55b4328916948ecfaac00df45c994bc293e54b61 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 21 Apr 2026 21:59:06 +0200 Subject: [PATCH] feat: enhance FenExporter with detailed FEN string generation and refactor PgnExporter for improved game context export --- .../de/nowchess/api/io/FenExporter.scala | 51 ---------------- .../chess/resource/GameResource.scala | 14 +---- .../chess/engine/GameEngineLoadGameTest.scala | 11 ++-- .../GameResourceIntegrationTest.scala | 2 +- .../de/nowchess/io/fen/FenExporter.scala | 51 ++++++++++++++-- .../de/nowchess/io/pgn/PgnExporter.scala | 58 +++++++++++++++++-- .../de/nowchess/rules/pgn/PgnExporter.scala | 58 ------------------- 7 files changed, 106 insertions(+), 139 deletions(-) delete mode 100644 modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala delete mode 100644 modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala diff --git a/modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala b/modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala deleted file mode 100644 index 3fd4eb9..0000000 --- a/modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala +++ /dev/null @@ -1,51 +0,0 @@ -package de.nowchess.api.io - -import de.nowchess.api.board.* -import de.nowchess.api.game.GameContext - -object FenExporter extends GameContextExport: - - def boardToFen(board: Board): String = - Rank.values.reverse - .map(rank => buildRankString(board, rank)) - .mkString("/") - - def gameContextToFen(context: GameContext): String = - val piecePlacement = boardToFen(context.board) - val activeColor = if context.turn == Color.White then "w" else "b" - val castling = castlingString(context.castlingRights) - val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-") - val fullMoveNumber = 1 + (context.moves.length / 2) - s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" - - def exportGameContext(context: GameContext): String = gameContextToFen(context) - - private def buildRankString(board: Board, rank: Rank): String = - val rankSquares = File.values.map(file => Square(file, rank)) - val (result, emptyCount) = rankSquares.foldLeft(("", 0)): - case ((acc, empty), square) => - board.pieceAt(square) match - case Some(piece) => - val flushed = if empty > 0 then acc + empty.toString else acc - (flushed + pieceToFenChar(piece), 0) - case None => - (acc, empty + 1) - if emptyCount > 0 then result + emptyCount.toString else result - - private def castlingString(rights: CastlingRights): String = - val wk = if rights.whiteKingSide then "K" else "" - val wq = if rights.whiteQueenSide then "Q" else "" - val bk = if rights.blackKingSide then "k" else "" - val bq = if rights.blackQueenSide then "q" else "" - val result = s"$wk$wq$bk$bq" - if result.isEmpty then "-" else result - - private def pieceToFenChar(piece: Piece): Char = - val base = piece.pieceType match - case PieceType.Pawn => 'p' - case PieceType.Knight => 'n' - case PieceType.Bishop => 'b' - case PieceType.Rook => 'r' - case PieceType.Queen => 'q' - case PieceType.King => 'k' - if piece.color == Color.White then base.toUpper else base diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index 283c6a8..e5dbf94 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.nowchess.api.board.Square import de.nowchess.api.dto.* import de.nowchess.api.game.{DrawReason, GameContext, GameResult} -import de.nowchess.api.io.FenExporter import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.chess.client.IoServiceClient @@ -13,7 +12,6 @@ import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException} import de.nowchess.chess.observer.* import de.nowchess.chess.registry.{GameEntry, GameRegistry} -import de.nowchess.rules.pgn.PgnExporter import io.smallrye.mutiny.Multi import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @@ -88,16 +86,8 @@ class GameResource: private def toGameStateDto(entry: GameEntry): GameStateDto = val ctx = entry.engine.context GameStateDto( - fen = FenExporter.exportGameContext(ctx), - pgn = PgnExporter.exportGame( - Map( - "Event" -> "NowChess game", - "White" -> entry.white.displayName, - "Black" -> entry.black.displayName, - "Result" -> "*", - ), - ctx.moves, - ), + fen = ioClient.exportFen(ctx), + pgn = ioClient.exportPgn(ctx), turn = ctx.turn.label.toLowerCase, status = statusOf(entry), winner = ctx.result.collect { case GameResult.Win(c) => c.label.toLowerCase }, diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala index 437006d..91ac2b1 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala @@ -1,16 +1,13 @@ package de.nowchess.chess.engine -import scala.collection.mutable -import de.nowchess.api.board.{Board, Color} -import de.nowchess.api.game.GameContext -import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent} -import de.nowchess.api.io.FenExporter +import de.nowchess.chess.observer.{GameEvent, Observer} import de.nowchess.io.fen.FenParser -import de.nowchess.io.pgn.PgnParser -import de.nowchess.rules.pgn.PgnExporter +import de.nowchess.io.pgn.{PgnExporter, PgnParser} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import scala.collection.mutable + class GameEngineLoadGameTest extends AnyFunSuite with Matchers: test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala index 3d27898..1ee458e 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala @@ -4,7 +4,7 @@ import de.nowchess.api.dto.* import de.nowchess.api.game.GameContext import de.nowchess.chess.client.IoServiceClient import de.nowchess.chess.exception.BadRequestException -import de.nowchess.api.io.FenExporter +import de.nowchess.io.fen.FenExporter import de.nowchess.io.pgn.PgnParser import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala index 9fa37e0..9e90afa 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenExporter.scala @@ -1,11 +1,52 @@ package de.nowchess.io.fen -import de.nowchess.api.board.Board +import de.nowchess.api.board.* import de.nowchess.api.game.GameContext -import de.nowchess.api.io.FenExporter as ApiFenExporter import de.nowchess.io.GameContextExport object FenExporter extends GameContextExport: - def boardToFen(board: Board): String = ApiFenExporter.boardToFen(board) - def gameContextToFen(context: GameContext): String = ApiFenExporter.gameContextToFen(context) - def exportGameContext(context: GameContext): String = ApiFenExporter.exportGameContext(context) + + def boardToFen(board: Board): String = + Rank.values.reverse + .map(rank => buildRankString(board, rank)) + .mkString("/") + + def gameContextToFen(context: GameContext): String = + val piecePlacement = boardToFen(context.board) + val activeColor = if context.turn == Color.White then "w" else "b" + val castling = castlingString(context.castlingRights) + val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-") + val fullMoveNumber = 1 + (context.moves.length / 2) + s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" + + def exportGameContext(context: GameContext): String = gameContextToFen(context) + + private def buildRankString(board: Board, rank: Rank): String = + val rankSquares = File.values.map(file => Square(file, rank)) + val (result, emptyCount) = rankSquares.foldLeft(("", 0)): + case ((acc, empty), square) => + board.pieceAt(square) match + case Some(piece) => + val flushed = if empty > 0 then acc + empty.toString else acc + (flushed + pieceToFenChar(piece), 0) + case None => + (acc, empty + 1) + if emptyCount > 0 then result + emptyCount.toString else result + + private def castlingString(rights: CastlingRights): String = + val wk = if rights.whiteKingSide then "K" else "" + val wq = if rights.whiteQueenSide then "Q" else "" + val bk = if rights.blackKingSide then "k" else "" + val bq = if rights.blackQueenSide then "q" else "" + val result = s"$wk$wq$bk$bq" + if result.isEmpty then "-" else result + + private def pieceToFenChar(piece: Piece): Char = + val base = piece.pieceType match + case PieceType.Pawn => 'p' + case PieceType.Knight => 'n' + case PieceType.Bishop => 'b' + case PieceType.Rook => 'r' + case PieceType.Queen => 'q' + case PieceType.King => 'k' + if piece.color == Color.White then base.toUpper else base diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala index c7312a8..c95efa3 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala @@ -1,10 +1,58 @@ package de.nowchess.io.pgn +import de.nowchess.api.board.{Board, PieceType} import de.nowchess.api.game.GameContext -import de.nowchess.api.move.Move -import de.nowchess.io.GameContextExport -import de.nowchess.rules.pgn.PgnExporter as RulesPgnExporter +import de.nowchess.api.io.GameContextExport +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.rules.sets.DefaultRules object PgnExporter extends GameContextExport: - def exportGameContext(context: GameContext): String = RulesPgnExporter.exportGameContext(context) - def exportGame(headers: Map[String, String], moves: List[Move]): String = RulesPgnExporter.exportGame(headers, moves) + + def exportGameContext(context: GameContext): String = + exportGame( + Map("Event" -> "?", "White" -> "?", "Black" -> "?", "Result" -> "*"), + context.moves, + ) + + def exportGame(headers: Map[String, String], moves: List[Move]): String = + val headerLines = headers.map { case (k, v) => s"""[$k "$v"]""" }.mkString("\n") + val moveText = if moves.isEmpty then "" else buildMoveText(headers, moves) + if headerLines.isEmpty then moveText + else if moveText.isEmpty then headerLines + else s"$headerLines\n\n$moveText" + + private def buildMoveText(headers: Map[String, String], moves: List[Move]): String = + val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)) + val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) } + val grouped = sanMoves.zipWithIndex.groupBy(_._2 / 2) + val moveLines = grouped.toList.sortBy(_._1).map { case (n, pairs) => + val w = pairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") + val b = pairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") + if b.isEmpty then s"${n + 1}. $w" else s"${n + 1}. $w $b" + } + moveLines.mkString(" ") + s" ${headers.getOrElse("Result", "*")}" + + private def moveToAlgebraic(move: Move, boardBefore: Board): String = + move.moveType match + case MoveType.CastleKingside => "O-O" + case MoveType.CastleQueenside => "O-O-O" + case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}" + case MoveType.Promotion(pp) => + val suffix = pp match + case PromotionPiece.Queen => "=Q" + case PromotionPiece.Rook => "=R" + case PromotionPiece.Bishop => "=B" + case PromotionPiece.Knight => "=N" + val isCapture = boardBefore.pieceAt(move.to).isDefined + if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$suffix" + else s"${move.to}$suffix" + case MoveType.Normal(isCapture) => + val dest = move.to.toString + val capStr = if isCapture then "x" else "" + boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match + case PieceType.Pawn => if isCapture then s"${move.from.file.toString.toLowerCase}x$dest" else dest + case PieceType.Knight => s"N$capStr$dest" + case PieceType.Bishop => s"B$capStr$dest" + case PieceType.Rook => s"R$capStr$dest" + case PieceType.Queen => s"Q$capStr$dest" + case PieceType.King => s"K$capStr$dest" diff --git a/modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala b/modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala deleted file mode 100644 index 5657cb3..0000000 --- a/modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala +++ /dev/null @@ -1,58 +0,0 @@ -package de.nowchess.rules.pgn - -import de.nowchess.api.board.* -import de.nowchess.api.game.GameContext -import de.nowchess.api.io.GameContextExport -import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.rules.sets.DefaultRules - -object PgnExporter extends GameContextExport: - - def exportGameContext(context: GameContext): String = - exportGame( - Map("Event" -> "?", "White" -> "?", "Black" -> "?", "Result" -> "*"), - context.moves, - ) - - def exportGame(headers: Map[String, String], moves: List[Move]): String = - val headerLines = headers.map { case (k, v) => s"""[$k "$v"]""" }.mkString("\n") - val moveText = if moves.isEmpty then "" else buildMoveText(headers, moves) - if headerLines.isEmpty then moveText - else if moveText.isEmpty then headerLines - else s"$headerLines\n\n$moveText" - - private def buildMoveText(headers: Map[String, String], moves: List[Move]): String = - val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)) - val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) } - val grouped = sanMoves.zipWithIndex.groupBy(_._2 / 2) - val moveLines = grouped.toList.sortBy(_._1).map { case (n, pairs) => - val w = pairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") - val b = pairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") - if b.isEmpty then s"${n + 1}. $w" else s"${n + 1}. $w $b" - } - moveLines.mkString(" ") + s" ${headers.getOrElse("Result", "*")}" - - private def moveToAlgebraic(move: Move, boardBefore: Board): String = - move.moveType match - case MoveType.CastleKingside => "O-O" - case MoveType.CastleQueenside => "O-O-O" - case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}" - case MoveType.Promotion(pp) => - val suffix = pp match - case PromotionPiece.Queen => "=Q" - case PromotionPiece.Rook => "=R" - case PromotionPiece.Bishop => "=B" - case PromotionPiece.Knight => "=N" - val isCapture = boardBefore.pieceAt(move.to).isDefined - if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$suffix" - else s"${move.to}$suffix" - case MoveType.Normal(isCapture) => - val dest = move.to.toString - val capStr = if isCapture then "x" else "" - boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match - case PieceType.Pawn => if isCapture then s"${move.from.file.toString.toLowerCase}x$dest" else dest - case PieceType.Knight => s"N$capStr$dest" - case PieceType.Bishop => s"B$capStr$dest" - case PieceType.Rook => s"R$capStr$dest" - case PieceType.Queen => s"Q$capStr$dest" - case PieceType.King => s"K$capStr$dest"