feat: NCS-6 Implementing FEN & PGN #7
@@ -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
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user