From 4ea3d6d30ddfd99b02aaec1f5a85cada3d8b9d7f Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 28 Mar 2026 18:28:21 +0100 Subject: [PATCH] feat: add FEN piece-placement parser Implements FenParser.parseBoard() to parse FEN piece-placement strings into a Board, with proper None propagation on invalid input. Co-Authored-By: Claude Sonnet 4.6 --- .../nowchess/chess/notation/FenParser.scala | 56 +++++++++++++++++++ .../chess/notation/FenParserTest.scala | 42 ++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala 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 new file mode 100644 index 0000000..e934f0a --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/FenParser.scala @@ -0,0 +1,56 @@ +package de.nowchess.chess.notation + +import de.nowchess.api.board.* + +object FenParser: + + /** 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)) 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 new file mode 100644 index 0000000..9914dce --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala @@ -0,0 +1,42 @@ +package de.nowchess.chess.notation + +import de.nowchess.api.board.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class FenParserTest extends AnyFunSuite with Matchers: + + test("parseBoard: initial position places pieces on correct squares"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" + val board = FenParser.parseBoard(fen) + + board.map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) + board.map(_.pieceAt(Square(File.E, Rank.R7))) shouldBe Some(Some(Piece.BlackPawn)) + board.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Some(Some(Piece.WhiteKing)) + board.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing)) + + test("parseBoard: empty board has no pieces"): + val fen = "8/8/8/8/8/8/8/8" + val board = FenParser.parseBoard(fen) + + board shouldBe defined + board.get.pieces.size shouldBe 0 + + test("parseBoard: returns None for missing rank (only 7 ranks)"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP" + val board = FenParser.parseBoard(fen) + + board shouldBe empty + + test("parseBoard: returns None for invalid piece character"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNX" + val board = FenParser.parseBoard(fen) + + board shouldBe empty + + test("parseBoard: partial position with two kings placed correctly"): + val fen = "8/8/4k3/8/4K3/8/8/8" + val board = FenParser.parseBoard(fen) + + board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing)) + board.map(_.pieceAt(Square(File.E, Rank.R4))) shouldBe Some(Some(Piece.WhiteKing))