diff --git a/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala b/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala deleted file mode 100644 index 8cc51ef..0000000 --- a/modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala +++ /dev/null @@ -1,3 +0,0 @@ -package de.nowchess.io - -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 deleted file mode 100644 index e2c60f6..0000000 --- a/modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala +++ /dev/null @@ -1,3 +0,0 @@ -package de.nowchess.io - -type GameContextImport = de.nowchess.api.io.GameContextImport diff --git a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala index f84d4fb..9bbfc44 100644 --- a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala +++ b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala @@ -1,6 +1,8 @@ package de.nowchess.io import de.nowchess.api.game.GameContext +import de.nowchess.api.io.{GameContextExport, GameContextImport} + import java.nio.file.{Files, Path} import java.nio.charset.StandardCharsets import scala.util.Try 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 9e90afa..377459b 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 @@ -2,25 +2,17 @@ package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext -import de.nowchess.io.GameContextExport +import de.nowchess.api.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("/") - 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) - + /** 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)): @@ -33,6 +25,18 @@ object FenExporter extends GameContextExport: (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 "" @@ -41,6 +45,7 @@ object FenExporter extends GameContextExport: 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' diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala index 1f206ff..179ae95 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala @@ -2,7 +2,7 @@ package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext -import de.nowchess.io.GameContextImport +import de.nowchess.api.io.GameContextImport object FenParser extends GameContextImport: diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala index f51b46f..11c4416 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala @@ -2,9 +2,10 @@ package de.nowchess.io.fen import de.nowchess.api.board.* import de.nowchess.api.game.GameContext -import de.nowchess.io.GameContextImport + import scala.util.parsing.combinator.RegexParsers import FenParserSupport.* +import de.nowchess.api.io.GameContextImport object FenParserCombinators extends RegexParsers with GameContextImport: diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala index 4a8ffca..5ed7aec 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala @@ -4,8 +4,8 @@ import fastparse.* import fastparse.NoWhitespace.* import de.nowchess.api.board.* import de.nowchess.api.game.GameContext -import de.nowchess.io.GameContextImport import FenParserSupport.* +import de.nowchess.api.io.GameContextImport object FenParserFastParse extends GameContextImport: diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala index 44ba4cc..e33be19 100644 --- a/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala @@ -6,8 +6,9 @@ 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.api.io.GameContextExport import de.nowchess.io.pgn.PgnExporter + import java.time.{LocalDate, ZoneId, ZonedDateTime} /** Exports a GameContext to a comprehensive JSON format using Jackson. diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala index ca283a6..1827434 100644 --- a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala @@ -5,7 +5,8 @@ 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 de.nowchess.api.io.GameContextImport + import scala.util.Try /** Imports a GameContext from JSON format using Jackson. 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 c95efa3..4a7b832 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,56 +1,75 @@ package de.nowchess.io.pgn -import de.nowchess.api.board.{Board, PieceType} +import de.nowchess.api.board.* +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} 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: + /** Export a GameContext to PGN format. */ def exportGameContext(context: GameContext): String = - exportGame( - Map("Event" -> "?", "White" -> "?", "Black" -> "?", "Result" -> "*"), - context.moves, + 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 (k, v) => s"""[$k "$v"]""" }.mkString("\n") - val moveText = if moves.isEmpty then "" else buildMoveText(headers, moves) + 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" - 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", "*")}" - + /** 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 suffix = pp match + 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}$suffix" - else s"${move.to}$suffix" + 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.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" diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala index 561e2da..27a81cd 100644 --- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala @@ -3,7 +3,7 @@ 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.io.GameContextImport +import de.nowchess.api.io.GameContextImport import de.nowchess.rules.sets.DefaultRules /** A parsed PGN game containing headers and the resolved move list. */