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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,56 +1,9 @@
|
||||
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] =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -41,94 +40,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
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))
|
||||
|
||||
test("testRoundTripInitialPosition"):
|
||||
val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
val exportedFen = board.map(FenExporter.boardToFen)
|
||||
|
||||
exportedFen shouldBe Some(originalFen)
|
||||
|
||||
test("testRoundTripEmptyBoard"):
|
||||
val originalFen = "8/8/8/8/8/8/8/8"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
val exportedFen = board.map(FenExporter.boardToFen)
|
||||
|
||||
exportedFen shouldBe Some(originalFen)
|
||||
|
||||
test("testRoundTripPartialPosition"):
|
||||
val originalFen = "8/8/4k3/8/4K3/8/8/8"
|
||||
val board = FenParser.parseBoard(originalFen)
|
||||
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
|
||||
|
||||
test("parseFen: castling '-' produces CastlingRights.None for both sides"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
|
||||
val gameState = FenParser.parseFen(fen)
|
||||
|
||||
gameState.isDefined shouldBe true
|
||||
gameState.get.castlingWhite.kingSide shouldBe false
|
||||
gameState.get.castlingWhite.queenSide shouldBe false
|
||||
gameState.get.castlingBlack.kingSide shouldBe false
|
||||
gameState.get.castlingBlack.queenSide shouldBe false
|
||||
|
||||
test("parseBoard: returns None when a rank has too many files (overflow beyond 8)"):
|
||||
// "9" alone would advance fileIdx to 9, exceeding 8 → None
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN9"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: returns None when a rank fails to parse (invalid middle rank)"):
|
||||
// Invalid character 'X' in rank 4 should cause failure
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/XXXXXXXX/8/PPPPPPPP/RNBQKBNR"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
test("parseBoard: returns None when a rank has 9 piece characters (fileIdx > 7)"):
|
||||
// 9 pawns in one rank triggers fileIdx > 7 guard (line 78)
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/PPPPPPPPP"
|
||||
val board = FenParser.parseBoard(fen)
|
||||
|
||||
board shouldBe empty
|
||||
|
||||
Reference in New Issue
Block a user