feat: NCS-6 Implementing FEN & PGN (#7)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #7
Reviewed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #7.
This commit is contained in:
2026-03-29 14:02:25 +02:00
parent 5f485fed9b
commit f28e69dc18
10 changed files with 952 additions and 2 deletions
@@ -0,0 +1,60 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState}
import de.nowchess.api.board.Color
object FenExporter:
/** 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 rankChars = scala.collection.mutable.ListBuffer[Char]()
var emptyCount = 0
for square <- rankSquares do
board.pieceAt(square) match
case Some(piece) =>
if emptyCount > 0 then
rankChars += emptyCount.toString.charAt(0)
emptyCount = 0
rankChars += pieceToPgnChar(piece)
case None =>
emptyCount += 1
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
/** Convert a GameState to a complete FEN string. */
def gameStateToFen(state: GameState): String =
val piecePlacement = state.piecePlacement
val activeColor = if state.activeColor == Color.White then "w" else "b"
val castling = castlingString(state.castlingWhite, state.castlingBlack)
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
/** Convert castling rights to FEN notation. */
private def castlingString(white: CastlingRights, black: CastlingRights): String =
val wk = if white.kingSide then "K" else ""
val wq = if white.queenSide then "Q" else ""
val bk = if black.kingSide then "k" else ""
val bq = if black.queenSide 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 pieceToPgnChar(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
@@ -0,0 +1,103 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
object FenParser:
/** Parse a complete FEN string into a GameState.
* Returns None if the format is invalid. */
def parseFen(fen: String): Option[GameState] =
val parts = fen.trim.split("\\s+")
Option.when(parts.length == 6)(parts).flatMap: parts =>
for
_ <- parseBoard(parts(0))
activeColor <- parseColor(parts(1))
castlingRights <- parseCastling(parts(2))
enPassant <- parseEnPassant(parts(3))
halfMoveClock <- parts(4).toIntOption
fullMoveNumber <- parts(5).toIntOption
if halfMoveClock >= 0 && fullMoveNumber >= 1
yield GameState(
piecePlacement = parts(0),
activeColor = activeColor,
castlingWhite = castlingRights._1,
castlingBlack = castlingRights._2,
enPassantTarget = enPassant,
halfMoveClock = halfMoveClock,
fullMoveNumber = fullMoveNumber,
status = GameStatus.InProgress
)
/** Parse active color ("w" or "b"). */
private def parseColor(s: String): Option[Color] =
if s == "w" then Some(Color.White)
else if s == "b" then Some(Color.Black)
else None
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
if s == "-" then
Some((CastlingRights.None, CastlingRights.None))
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
Some((white, black))
else
None
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_))
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
* Returns None if the format is invalid. */
def parseBoard(fen: String): Option[Board] =
val rankStrings = fen.split("/", -1)
if rankStrings.length != 8 then None
else
// Parse each rank, collecting all (Square, Piece) pairs or failing on the first error
val parsedRanks: Option[List[List[(Square, Piece)]]] =
rankStrings.zipWithIndex.foldLeft(Option(List.empty[List[(Square, Piece)]])):
case (None, _) => None
case (Some(acc), (rankStr, rankIdx)) =>
val rank = Rank.values(7 - rankIdx) // ranks go 8→1, so reverse
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
* Returns None if the rank string contains invalid characters or the wrong number of files. */
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
var fileIdx = 0
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
var failed = false
for c <- rankStr if !failed do
if fileIdx > 7 then
failed = true
else if c.isDigit then
fileIdx += c.asDigit
else
charToPiece(c) match
case None => failed = true
case Some(piece) =>
val file = File.values(fileIdx)
squares += (Square(file, rank) -> piece)
fileIdx += 1
if failed || fileIdx != 8 then None
else Some(squares.toList)
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
private def charToPiece(c: Char): Option[Piece] =
val color = if Character.isUpperCase(c) then Color.White else Color.Black
val pieceTypeOpt = c.toLower match
case 'p' => Some(PieceType.Pawn)
case 'n' => Some(PieceType.Knight)
case 'b' => Some(PieceType.Bishop)
case 'r' => Some(PieceType.Rook)
case 'q' => Some(PieceType.Queen)
case 'k' => Some(PieceType.King)
case _ => None
pieceTypeOpt.map(pt => Piece(color, pt))
@@ -0,0 +1,35 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
object PgnExporter:
/** Export a game with headers and history to PGN format. */
def exportGame(headers: Map[String, String], history: GameHistory): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if history.moves.isEmpty then ""
else
val groupedMoves = history.moves.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(p => moveToAlgebraic(p._1)).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
moveLines.mkString(" ") + " *"
if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
/** Convert a HistoryMove to algebraic notation. */
private def moveToAlgebraic(move: HistoryMove): String =
move.castleSide match
case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O"
case None => s"${move.from}${move.to}"
@@ -0,0 +1,150 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
headers: Map[String, String],
moves: List[HistoryMove]
)
object PgnParser:
/** Parse a complete PGN text into a PgnGame with headers and moves.
* Always succeeds (returns Some); malformed tokens are silently skipped. */
def parsePgn(pgn: String): Option[PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText)
Some(PgnGame(headers, moves))
/** Parse PGN header lines of the form [Key "Value"]. */
private def parseHeaders(lines: Array[String]): Map[String, String] =
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
private def parseMovesText(moveText: String): List[HistoryMove] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
// Fold over tokens, threading (board, history, currentColor, accumulator)
val (_, _, _, moves) = tokens.foldLeft(
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
):
case (state @ (board, history, color, acc), token) =>
// Skip move-number markers (e.g. "1.", "2.") and result tokens
if isMoveNumberOrResult(token) then state
else
parseAlgebraicMove(token, board, history, color) match
case None => state // unrecognised token — skip silently
case Some(move) =>
val newBoard = move.castleSide match
case Some(side) => board.withCastle(color, side)
case None => board.withMove(move.from, move.to)._1
val newHistory = history.addMove(move)
(newBoard, newHistory, color.opposite, acc :+ move)
moves
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") ||
token == "*" ||
token == "1-0" ||
token == "0-1" ||
token == "1/2-1/2"
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
notation match
case "O-O" | "O-O+" | "O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
case _ =>
parseRegularMove(notation, board, history, color)
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
val clean = notation
.replace("+", "")
.replace("#", "")
.replace("x", "")
.replaceAll("=[NBRQ]$", "")
// The destination square is always the last two characters
if clean.length < 2 then None
else
val destStr = clean.takeRight(2)
Square.fromAlgebraic(destStr).flatMap: toSquare =>
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
// Determine required piece type: upper-case first char = piece letter; else pawn
val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn)
// Collect the disambiguation hint that remains after stripping the piece letter
val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig // hint is file/rank info or empty
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
// We prefer pieces that can actually reach the target; if none can (positionally illegal
// PGN input), fall back to any piece of the matching type belonging to `color`.
val reachable: Set[Square] =
board.pieces.collect {
case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board, from).contains(toSquare) => from
}.toSet
val candidates: Set[Square] =
if reachable.nonEmpty then reachable
else
// Fallback for positionally-illegal but syntactically valid PGN notation:
// find any piece of `color` with the correct piece type on the board.
board.pieces.collect {
case (from, piece) if piece.color == color => from
}.toSet
// Filter by required piece type
val byPiece = candidates.filter(from =>
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
)
// Apply disambiguation hint (file letter or rank digit)
val disambiguated =
if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint))
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
hint.foldLeft(true): (ok, c) =>
ok && (
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true
)
/** Convert a piece-letter character to a PieceType. */
private def charToPieceType(c: Char): Option[PieceType] =
c match
case 'N' => Some(PieceType.Knight)
case 'B' => Some(PieceType.Bishop)
case 'R' => Some(PieceType.Rook)
case 'Q' => Some(PieceType.Queen)
case 'K' => Some(PieceType.King)
case _ => None