From 219b83240ec022f8fb9d19c507fdc82950b5b04b 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 | 19 +++++ .../nowchess/chess/notation/FenParser.scala | 47 +++++++++++++ .../chess/notation/FenExporterTest.scala | 69 +++++++++++++++++++ .../chess/notation/FenParserTest.scala | 40 +++++++++++ 4 files changed, 175 insertions(+) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala 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 f5aba5b..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,6 +1,8 @@ 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: @@ -29,6 +31,23 @@ object FenExporter: 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 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/FenExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala new file mode 100644 index 0000000..b14ff69 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala @@ -0,0 +1,69 @@ +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 + +class FenExporterTest extends AnyFunSuite with Matchers: + + test("export initial position to FEN"): + val gameState = GameState.initial + val fen = FenExporter.gameStateToFen(gameState) + fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + test("export position after e4"): + val gameState = GameState( + piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR", + activeColor = Color.Black, + castlingWhite = CastlingRights.Both, + castlingBlack = CastlingRights.Both, + enPassantTarget = Some(Square(File.E, Rank.R3)), + halfMoveClock = 0, + fullMoveNumber = 1, + status = GameStatus.InProgress + ) + val fen = FenExporter.gameStateToFen(gameState) + fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" + + test("export position with no castling"): + val gameState = GameState( + piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + activeColor = Color.White, + castlingWhite = CastlingRights.None, + castlingBlack = CastlingRights.None, + enPassantTarget = None, + halfMoveClock = 0, + fullMoveNumber = 1, + status = GameStatus.InProgress + ) + val fen = FenExporter.gameStateToFen(gameState) + fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" + + test("export position with partial castling"): + val gameState = GameState( + piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", + activeColor = Color.White, + castlingWhite = CastlingRights(kingSide = true, queenSide = false), + castlingBlack = CastlingRights(kingSide = false, queenSide = true), + enPassantTarget = None, + halfMoveClock = 5, + fullMoveNumber = 3, + status = GameStatus.InProgress + ) + val fen = FenExporter.gameStateToFen(gameState) + fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3" + + test("export position with en passant and move counts"): + val gameState = GameState( + piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR", + activeColor = Color.White, + castlingWhite = CastlingRights.Both, + castlingBlack = CastlingRights.Both, + enPassantTarget = Some(Square(File.C, Rank.R6)), + halfMoveClock = 2, + fullMoveNumber = 3, + status = GameStatus.InProgress + ) + val fen = FenExporter.gameStateToFen(gameState) + fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" 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