feat: enhance FenExporter with detailed FEN string generation and refactor PgnExporter for improved game context export
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user