feat: refactor IO module to API structure and add FEN export functionality
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-21 21:37:43 +02:00
parent b4d197a9b2
commit 96d4f995fa
20 changed files with 166 additions and 144 deletions
@@ -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
@@ -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
@@ -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)
@@ -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)