From 96d4f995fa47225399da2a2a73c44d00eb54f7c0 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 21 Apr 2026 21:37:43 +0200 Subject: [PATCH] feat: refactor IO module to API structure and add FEN export functionality --- .../nowchess/api/dto/ImportFenRequest.scala | 3 + .../nowchess/api/dto/ImportPgnRequest.scala | 3 + .../de/nowchess/api/io/FenExporter.scala | 51 +++++++++++++ .../nowchess/api/io/GameContextExport.scala | 6 ++ .../nowchess/api/io/GameContextImport.scala | 6 ++ modules/core/build.gradle.kts | 3 +- .../chess/client/IoServiceClient.scala | 2 +- .../nowchess/chess/config/JacksonConfig.scala | 1 - .../chess/config/SquareKeyDeserializer.scala | 8 ++ .../chess/config/SquareKeySerializer.scala | 9 +++ .../de/nowchess/chess/engine/GameEngine.scala | 2 +- .../chess/resource/GameResource.scala | 5 +- .../engine/GameEngineIntegrationTest.scala | 2 +- .../chess/engine/GameEngineLoadGameTest.scala | 6 +- .../GameResourceIntegrationTest.scala | 2 +- .../de/nowchess/io/GameContextExport.scala | 6 +- .../de/nowchess/io/GameContextImport.scala | 6 +- .../de/nowchess/io/fen/FenExporter.scala | 56 ++------------ .../de/nowchess/io/pgn/PgnExporter.scala | 75 +------------------ .../de/nowchess/rules/pgn/PgnExporter.scala | 58 ++++++++++++++ 20 files changed, 166 insertions(+), 144 deletions(-) create mode 100644 modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequest.scala create mode 100644 modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequest.scala create mode 100644 modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala create mode 100644 modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala create mode 100644 modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequest.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequest.scala new file mode 100644 index 0000000..9a30b62 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequest.scala @@ -0,0 +1,3 @@ +package de.nowchess.api.dto + +case class ImportFenRequest(fen: String) diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequest.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequest.scala new file mode 100644 index 0000000..ef3613a --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/dto/ImportPgnRequest.scala @@ -0,0 +1,3 @@ +package de.nowchess.api.dto + +case class ImportPgnRequest(pgn: String) 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 new file mode 100644 index 0000000..3fd4eb9 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/io/FenExporter.scala @@ -0,0 +1,51 @@ +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/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala b/modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala new file mode 100644 index 0000000..a261742 --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala @@ -0,0 +1,6 @@ +package de.nowchess.api.io + +import de.nowchess.api.game.GameContext + +trait GameContextExport: + def exportGameContext(context: GameContext): String diff --git a/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala b/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala new file mode 100644 index 0000000..22c149e --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala @@ -0,0 +1,6 @@ +package de.nowchess.api.io + +import de.nowchess.api.game.GameContext + +trait GameContextImport: + def importGameContext(input: String): Either[String, GameContext] diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index d572500..d057a24 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -48,7 +48,6 @@ dependencies { } implementation(project(":modules:api")) - implementation(project(":modules:io")) implementation(project(":modules:rule")) implementation(project(":modules:bot")) @@ -69,6 +68,8 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + testImplementation(project(":modules:io")) + testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala index dac4c3d..bc41b1b 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.client +import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest} import de.nowchess.api.game.GameContext -import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest} import jakarta.ws.rs.* import jakarta.ws.rs.core.MediaType import org.eclipse.microprofile.rest.client.inject.RegisterRestClient diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala index 816cb0b..1393880 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.scala.DefaultScalaModule import de.nowchess.api.board.Square -import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer} import io.quarkus.jackson.ObjectMapperCustomizer import jakarta.inject.Singleton diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala new file mode 100644 index 0000000..35e9604 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala @@ -0,0 +1,8 @@ +package de.nowchess.chess.config + +import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer} +import de.nowchess.api.board.Square + +class SquareKeyDeserializer extends KeyDeserializer: + override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef = + Square.fromAlgebraic(key).orNull diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala new file mode 100644 index 0000000..c0ca0d0 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.chess.config + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.board.Square + +class SquareKeySerializer extends JsonSerializer[Square]: + override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeFieldName(value.toString) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 43f099b..0759048 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -7,7 +7,7 @@ import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} -import de.nowchess.io.{GameContextExport, GameContextImport} +import de.nowchess.api.io.{GameContextExport, GameContextImport} import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules 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 ece9752..283c6a8 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,6 +4,7 @@ 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 @@ -12,9 +13,7 @@ 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.io.fen.FenExporter -import de.nowchess.io.pgn.PgnExporter -import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest} +import de.nowchess.rules.pgn.PgnExporter import io.smallrye.mutiny.Multi import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index 371f2dd..e07336b 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -4,7 +4,7 @@ import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square} import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer} -import de.nowchess.io.GameContextImport +import de.nowchess.api.io.GameContextImport import de.nowchess.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite 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 d926147..437006d 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 @@ -4,10 +4,10 @@ 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.io.pgn.PgnParser +import de.nowchess.api.io.FenExporter import de.nowchess.io.fen.FenParser -import de.nowchess.io.pgn.PgnExporter -import de.nowchess.io.fen.FenExporter +import de.nowchess.io.pgn.PgnParser +import de.nowchess.rules.pgn.PgnExporter import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers 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 1ee458e..3d27898 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.io.fen.FenExporter +import de.nowchess.api.io.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/GameContextExport.scala b/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala index 3968e71..8cc51ef 100644 --- a/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala +++ b/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala @@ -1,7 +1,3 @@ package de.nowchess.io -import de.nowchess.api.game.GameContext - -trait GameContextExport: - - def exportGameContext(context: GameContext): String +type GameContextExport = de.nowchess.api.io.GameContextExport diff --git a/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala b/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala index c56850f..e2c60f6 100644 --- a/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala +++ b/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala @@ -1,7 +1,3 @@ package de.nowchess.io -import de.nowchess.api.game.GameContext - -trait GameContextImport: - - def importGameContext(input: String): Either[String, GameContext] +type GameContextImport = de.nowchess.api.io.GameContextImport 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 8ba9303..e71df91 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,57 +1,11 @@ package de.nowchess.io.fen -import de.nowchess.api.board.* +import de.nowchess.api.board.Board import de.nowchess.api.game.GameContext +import de.nowchess.api.io.{FenExporter => ApiFenExporter} import de.nowchess.io.GameContextExport object FenExporter extends GameContextExport: - - /** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */ - def boardToFen(board: Board): String = - Rank.values.reverse - .map(rank => buildRankString(board, rank)) - .mkString("/") - - /** Build the FEN representation for a single rank. */ - 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 - - /** Convert a GameContext to a complete FEN string. */ - 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) - - /** Convert castling rights to FEN notation. */ - 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 - - /** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */ - 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 + def boardToFen(board: Board): String = ApiFenExporter.boardToFen(board) + def gameContextToFen(context: GameContext): String = ApiFenExporter.gameContextToFen(context) + def exportGameContext(context: GameContext): String = ApiFenExporter.exportGameContext(context) 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 afb697a..5e002f6 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,77 +1,10 @@ package de.nowchess.io.pgn -import de.nowchess.api.board.* -import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move import de.nowchess.io.GameContextExport -import de.nowchess.rules.sets.DefaultRules +import de.nowchess.rules.pgn.{PgnExporter => RulesPgnExporter} object PgnExporter extends GameContextExport: - - /** Export a GameContext to PGN format. */ - def exportGameContext(context: GameContext): String = - val headers = Map( - "Event" -> "?", - "White" -> "?", - "Black" -> "?", - "Result" -> "*", - ) - - exportGame(headers, context.moves) - - /** Export a game with headers and moves to PGN format. */ - def exportGame(headers: Map[String, String], moves: List[Move]): String = - val headerLines = headers - .map { case (key, value) => - s"""[$key "$value"]""" - } - .mkString("\n") - - val moveText = - if moves.isEmpty then "" - else - 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 groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2) - val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield - val moveNum = moveNumber + 1 - val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("") - val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("") - if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" - else s"$moveNum. $whiteMoveStr $blackMoveStr" - - val termination = headers.getOrElse("Result", "*") - moveLines.mkString(" ") + s" $termination" - - if headerLines.isEmpty then moveText - else if moveText.isEmpty then headerLines - else s"$headerLines\n\n$moveText" - - /** Convert a Move to Standard Algebraic Notation using the board state before the move. */ - 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 promSuffix = 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}$promSuffix" - else s"${move.to}$promSuffix" - 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" + def exportGameContext(context: GameContext): String = RulesPgnExporter.exportGameContext(context) + def exportGame(headers: Map[String, String], moves: List[Move]): String = RulesPgnExporter.exportGame(headers, moves) 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 new file mode 100644 index 0000000..34b2973 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/pgn/PgnExporter.scala @@ -0,0 +1,58 @@ +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"