From 8747fad28276574a88eff00e2c59caa74da600bc Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 28 Mar 2026 18:38:45 +0100 Subject: [PATCH] feat: add full FEN parsing with GameState support Implements parseFen() in FenParser and gameStateToFen() in FenExporter, covering all 6 FEN fields (piece placement, active color, castling, en passant, half-move clock, full-move number). Co-Authored-By: Claude Sonnet 4.6 --- .../nowchess/chess/notation/FenExporter.scala | 6 --- .../nowchess/chess/notation/FenParser.scala | 47 +++++++++++++++++++ .../chess/notation/FenParserTest.scala | 40 ++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala index f228347..e300dd1 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala @@ -1,11 +1,8 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* -<<<<<<< HEAD import de.nowchess.api.game.{CastlingRights, GameState} import de.nowchess.api.board.Color -======= ->>>>>>> cc62cd2 (feat: add FEN exporter and round-trip tests) object FenExporter: @@ -34,7 +31,6 @@ object FenExporter: if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0) rankChars.mkString -<<<<<<< HEAD /** Convert a GameState to a complete FEN string. */ def gameStateToFen(state: GameState): String = val piecePlacement = state.piecePlacement @@ -52,8 +48,6 @@ object FenExporter: val result = s"$wk$wq$bk$bq" if result.isEmpty then "-" else result -======= ->>>>>>> cc62cd2 (feat: add FEN exporter and round-trip tests) /** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */ private def pieceToPgnChar(piece: Piece): Char = val base = piece.pieceType match diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala index e934f0a..94b7244 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala @@ -1,9 +1,56 @@ 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] = diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala index ce77a54..ab2fc8d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* +import de.nowchess.api.game.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -61,3 +62,42 @@ class FenParserTest extends AnyFunSuite with Matchers: val exportedFen = board.map(FenExporter.boardToFen) exportedFen shouldBe Some(originalFen) + + test("parse full FEN - initial position"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + val gameState = FenParser.parseFen(fen) + + gameState.isDefined shouldBe true + gameState.get.activeColor shouldBe Color.White + gameState.get.castlingWhite.kingSide shouldBe true + gameState.get.castlingWhite.queenSide shouldBe true + gameState.get.castlingBlack.kingSide shouldBe true + gameState.get.castlingBlack.queenSide shouldBe true + gameState.get.enPassantTarget shouldBe None + gameState.get.halfMoveClock shouldBe 0 + gameState.get.fullMoveNumber shouldBe 1 + + test("parse full FEN - after e4"): + val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" + val gameState = FenParser.parseFen(fen) + + gameState.get.activeColor shouldBe Color.Black + gameState.get.enPassantTarget shouldBe Some(Square(File.E, Rank.R3)) + + test("parse full FEN - invalid parts count"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq" + val gameState = FenParser.parseFen(fen) + + gameState.isDefined shouldBe false + + test("parse full FEN - invalid color"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1" + val gameState = FenParser.parseFen(fen) + + gameState.isDefined shouldBe false + + test("parse full FEN - invalid castling"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1" + val gameState = FenParser.parseFen(fen) + + gameState.isDefined shouldBe false