feat: NCS-25 Add linters to keep quality up (#27)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #27 Reviewed-by: Leon Hermann <lq@blackhole.local> Co-authored-by: Janis <janis.e.20@gmx.de> Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #27.
This commit is contained in:
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
|
||||
import scala.util.Try
|
||||
|
||||
/** Service for persisting and loading game states to/from disk.
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer.
|
||||
* Handles both reading and writing game files.
|
||||
*/
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||
*/
|
||||
trait GameFileService:
|
||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
|
||||
()
|
||||
}.fold(
|
||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||
_ => Right(())
|
||||
_ => Right(()),
|
||||
)
|
||||
|
||||
/** Load a game context from a file using the specified importer. */
|
||||
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
|
||||
importer.importGameContext(json)
|
||||
}.fold(
|
||||
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||
result => result
|
||||
result => result,
|
||||
)
|
||||
|
||||
@@ -15,28 +15,22 @@ object FenExporter extends GameContextExport:
|
||||
/** Build the FEN representation for a single rank. */
|
||||
private def buildRankString(board: Board, rank: Rank): String =
|
||||
val rankSquares = File.values.map(file => Square(file, rank))
|
||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
||||
var emptyCount = 0
|
||||
|
||||
for square <- rankSquares do
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
if emptyCount > 0 then
|
||||
rankChars += emptyCount.toString.charAt(0)
|
||||
emptyCount = 0
|
||||
rankChars += pieceToFenChar(piece)
|
||||
case None =>
|
||||
emptyCount += 1
|
||||
|
||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
||||
rankChars.mkString
|
||||
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
|
||||
case ((acc, empty), square) =>
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
val flushed = if empty > 0 then acc + empty.toString else acc
|
||||
(flushed + pieceToFenChar(piece), 0)
|
||||
case None =>
|
||||
(acc, empty + 1)
|
||||
if emptyCount > 0 then result + emptyCount.toString else result
|
||||
|
||||
/** Convert a GameContext to a complete FEN string. */
|
||||
def gameContextToFen(context: GameContext): String =
|
||||
val piecePlacement = boardToFen(context.board)
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val activeColor = if context.turn == Color.White then "w" else "b"
|
||||
val castling = castlingString(context.castlingRights)
|
||||
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
|
||||
val fullMoveNumber = 1 + (context.moves.length / 2)
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||
|
||||
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
|
||||
|
||||
/** Convert castling rights to FEN notation. */
|
||||
private def castlingString(rights: CastlingRights): String =
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val wk = if rights.whiteKingSide then "K" else ""
|
||||
val wq = if rights.whiteQueenSide then "Q" else ""
|
||||
val bk = if rights.blackKingSide then "k" else ""
|
||||
val bq = if rights.blackQueenSide then "q" else ""
|
||||
val result = s"$wk$wq$bk$bq"
|
||||
if result.isEmpty then "-" else result
|
||||
|
||||
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
|
||||
|
||||
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
|
||||
|
||||
object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse a complete FEN string into a GameContext.
|
||||
* Returns Left with error message if the format is invalid. */
|
||||
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
|
||||
*/
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
val parts = fen.trim.split("\\s+")
|
||||
if parts.length != 6 then
|
||||
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
|
||||
else
|
||||
for
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
|
||||
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
|
||||
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
|
||||
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
|
||||
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = activeColor,
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassant,
|
||||
halfMoveClock = halfMoveClock,
|
||||
moves = List.empty
|
||||
moves = List.empty,
|
||||
)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
|
||||
|
||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
|
||||
private def parseCastling(s: String): Option[CastlingRights] =
|
||||
if s == "-" then
|
||||
Some(CastlingRights.None)
|
||||
if s == "-" then Some(CastlingRights.None)
|
||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||
Some(CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
))
|
||||
else
|
||||
None
|
||||
Some(
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
),
|
||||
)
|
||||
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. */
|
||||
/** 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
|
||||
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
|
||||
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. */
|
||||
/** 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
|
||||
|
||||
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
|
||||
case ((idx, true, acc), _) => (idx, true, acc)
|
||||
case ((idx, false, acc), c) =>
|
||||
if idx > 7 then (idx, true, acc)
|
||||
else if c.isDigit then (idx + c.asDigit, false, acc)
|
||||
else
|
||||
charToPiece(c) match
|
||||
case None => (idx, true, acc)
|
||||
case Some(piece) =>
|
||||
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
|
||||
if failed || fileIdx != 8 then None
|
||||
else Some(squares.toList)
|
||||
else Some(squares)
|
||||
|
||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
||||
private def charToPiece(c: Char): Option[Piece] =
|
||||
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
|
||||
case 'k' => Some(PieceType.King)
|
||||
case _ => None
|
||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def pieceChar: Parser[Piece] =
|
||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||
val c = s.head
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
|
||||
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
|
||||
* placement exceeds board bounds.
|
||||
*/
|
||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||
rankTokens >> { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
@@ -45,16 +46,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
|
||||
private def boardParser: Parser[Board] =
|
||||
rankParser(Rank.R8) ~
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ {
|
||||
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def castlingParser: Parser[CastlingRights] =
|
||||
"-" ^^^ CastlingRights.None |
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser: Parser[Option[Square]] =
|
||||
"-" ^^^ Option.empty[Square] |
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
private def fenParser: Parser[GameContext] =
|
||||
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
)
|
||||
}
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def pieceChar(using P[Any]): P[Piece] =
|
||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||
val c = s.head
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
@@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def boardParser(using P[Any]): P[Board] =
|
||||
(rankParser(Rank.R8) ~ sep ~
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
@@ -61,20 +61,20 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def castlingParser(using P[Any]): P[CastlingRights] =
|
||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q'),
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
||||
LiteralStr("-").map(_ => Option.empty[Square]) |
|
||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport:
|
||||
|
||||
private def fenParser(using P[Any]): P[GameContext] =
|
||||
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||
case (board, color, castling, ep, halfMove, _) =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,19 +14,20 @@ private[fen] object FenParserSupport:
|
||||
'n' -> PieceType.Knight,
|
||||
'b' -> PieceType.Bishop,
|
||||
'q' -> PieceType.Queen,
|
||||
'k' -> PieceType.King
|
||||
'k' -> PieceType.King,
|
||||
)
|
||||
|
||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
tokens
|
||||
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
|
||||
@@ -8,31 +8,31 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
import java.time.{LocalDate, ZonedDateTime, ZoneId}
|
||||
import java.time.{LocalDate, ZoneId, ZonedDateTime}
|
||||
|
||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
object JsonExporter extends GameContextExport:
|
||||
private val mapper = createMapper()
|
||||
|
||||
|
||||
private def createMapper(): ObjectMapper =
|
||||
val mapper = new ObjectMapper()
|
||||
.registerModule(DefaultScalaModule)
|
||||
|
||||
|
||||
// Configure pretty printer with custom spacing to match test expectations
|
||||
val indenter = new DefaultIndenter(" ", "\n")
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
printer.indentArraysWith(indenter)
|
||||
printer.indentObjectsWith(indenter)
|
||||
|
||||
|
||||
mapper.setDefaultPrettyPrinter(printer)
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
mapper
|
||||
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
|
||||
formatJson(mapper.writeValueAsString(record))
|
||||
|
||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||
val pgn = try {
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
} catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
val pgn =
|
||||
try
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
JsonGameRecord(
|
||||
metadata = Some(buildMetadata()),
|
||||
gameState = Some(buildGameState(context)),
|
||||
moveHistory = pgn,
|
||||
moves = Some(buildMoves(context.moves)),
|
||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
|
||||
)
|
||||
|
||||
private def buildMetadata(): JsonMetadata =
|
||||
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
|
||||
event = Some("Game"),
|
||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||
date = Some(LocalDate.now().toString),
|
||||
result = Some("*")
|
||||
result = Some("*"),
|
||||
)
|
||||
|
||||
private def buildGameState(context: GameContext): JsonGameState =
|
||||
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
|
||||
turn = Some(context.turn.label),
|
||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||
halfMoveClock = Some(context.halfMoveClock)
|
||||
halfMoveClock = Some(context.halfMoveClock),
|
||||
)
|
||||
|
||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
|
||||
Some(rights.whiteKingSide),
|
||||
Some(rights.whiteQueenSide),
|
||||
Some(rights.blackKingSide),
|
||||
Some(rights.blackQueenSide)
|
||||
Some(rights.blackQueenSide),
|
||||
)
|
||||
|
||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||
@@ -128,12 +129,11 @@ object JsonExporter extends GameContextExport:
|
||||
val captured = Square.all.flatMap { square =>
|
||||
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||
board.pieceAt(square) match
|
||||
case None => Some(initialPiece)
|
||||
case None => Some(initialPiece)
|
||||
case Some(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||
(blackCaptured, whiteCaptured)
|
||||
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
case class JsonMetadata(
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonPiece(
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonCastlingRights(
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None,
|
||||
)
|
||||
|
||||
case class JsonGameState(
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None,
|
||||
)
|
||||
|
||||
case class JsonCapturedPieces(
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None,
|
||||
)
|
||||
|
||||
case class JsonMoveType(
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonMove(
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None,
|
||||
)
|
||||
|
||||
case class JsonGameRecord(
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
|
||||
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
|
||||
import scala.util.Try
|
||||
|
||||
/** Imports a GameContext from JSON format using Jackson.
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
object JsonParser extends GameContextImport:
|
||||
|
||||
private val mapper = new ObjectMapper()
|
||||
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
||||
.left.map(e => "JSON parsing error: " + e.getMessage)
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||
.map(e => "JSON parsing error: " + e.getMessage)
|
||||
.flatMap { data =>
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val rawBoard = gs.board.getOrElse(Nil)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawMoves = data.moves.getOrElse(Nil)
|
||||
|
||||
for
|
||||
board <- parseBoard(rawBoard)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||
moves <- parseMoves(rawMoves)
|
||||
yield GameContext(
|
||||
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = rawHmc,
|
||||
moves = moves
|
||||
moves = moves,
|
||||
)
|
||||
}
|
||||
|
||||
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||
val parsedPieces = pieces.flatMap { p =>
|
||||
for
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
color <- p.color.flatMap(parseColor)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
yield (sq, Piece(color, pt))
|
||||
}
|
||||
Right(Board(parsedPieces.toMap))
|
||||
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
|
||||
|
||||
private def parsePieceType(pt: String): Option[PieceType] =
|
||||
pt match
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Knight" => Some(PieceType.Knight)
|
||||
case "Bishop" => Some(PieceType.Bishop)
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||
CastlingRights(
|
||||
cr.whiteKingSide.getOrElse(false),
|
||||
cr.whiteQueenSide.getOrElse(false),
|
||||
cr.blackKingSide.getOrElse(false),
|
||||
cr.blackQueenSide.getOrElse(false)
|
||||
cr.blackQueenSide.getOrElse(false),
|
||||
)
|
||||
|
||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||
Right(moves.flatMap { m =>
|
||||
for
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
moveType <- m.`type`.flatMap(parseMoveType)
|
||||
yield Move(from, to, moveType)
|
||||
})
|
||||
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
|
||||
Some(MoveType.EnPassant)
|
||||
case Some("promotion") =>
|
||||
val piece = mt.promotionPiece match
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("bishop") => PromotionPiece.Bishop
|
||||
case Some("knight") => PromotionPiece.Knight
|
||||
case _ => PromotionPiece.Queen // default
|
||||
case _ => PromotionPiece.Queen // default
|
||||
Some(MoveType.Promotion(piece))
|
||||
case _ => None
|
||||
|
||||
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*"
|
||||
"Event" -> "?",
|
||||
"White" -> "?",
|
||||
"Black" -> "?",
|
||||
"Result" -> "*",
|
||||
)
|
||||
|
||||
exportGame(headers, context.moves)
|
||||
|
||||
/** Export a game with headers and moves to PGN format. */
|
||||
def exportGame(headers: Map[String, String], moves: List[Move]): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if moves.isEmpty then ""
|
||||
else
|
||||
var ctx = GameContext.initial
|
||||
val sanMoves = moves.map { move =>
|
||||
val algebraic = moveToAlgebraic(move, ctx.board)
|
||||
ctx = DefaultRules.applyMove(ctx)(move)
|
||||
algebraic
|
||||
val headerLines = headers
|
||||
.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}
|
||||
.mkString("\n")
|
||||
|
||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
val moveText =
|
||||
if moves.isEmpty then ""
|
||||
else
|
||||
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
|
||||
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
|
||||
case MoveType.CastleKingside => "O-O"
|
||||
case MoveType.CastleQueenside => "O-O-O"
|
||||
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
case MoveType.Promotion(pp) =>
|
||||
case MoveType.Promotion(pp) =>
|
||||
val promSuffix = pp match
|
||||
case PromotionPiece.Queen => "=Q"
|
||||
case PromotionPiece.Rook => "=R"
|
||||
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
|
||||
case PieceType.Rook => s"R$capStr$dest"
|
||||
case PieceType.Queen => s"Q$capStr$dest"
|
||||
case PieceType.King => s"K$capStr$dest"
|
||||
|
||||
|
||||
|
||||
@@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
/** A parsed PGN game containing headers and the resolved move list. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[Move]
|
||||
headers: Map[String, String],
|
||||
moves: List[Move],
|
||||
)
|
||||
|
||||
object PgnParser extends GameContextImport:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
|
||||
*/
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves.
|
||||
* Returns Right(GameContext) with all moves applied and .moves populated.
|
||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
||||
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
|
||||
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
|
||||
* issue.
|
||||
*/
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
|
||||
}
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
|
||||
* are silently skipped.
|
||||
*/
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
Some(PgnGame(headers, moves))
|
||||
|
||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
|
||||
private def parseMovesText(moveText: String): List[Move] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
val (_, _, moves) = tokens.foldLeft(
|
||||
(GameContext.initial, Color.White, List.empty[Move])
|
||||
(GameContext.initial, Color.White, List.empty[Move]),
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case None => state
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
(nextCtx, color.opposite, acc :+ move)
|
||||
moves
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
Square
|
||||
.fromAlgebraic(destStr)
|
||||
.flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val promotion = extractPromotion(notation)
|
||||
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board.pieceAt(move.from).exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType)
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
// Get all legal moves for this color that reach toSquare
|
||||
val allLegal = DefaultRules.allLegalMoves(ctx)
|
||||
val candidates = allLegal.filter { move =>
|
||||
move.to == toSquare &&
|
||||
ctx.board
|
||||
.pieceAt(move.from)
|
||||
.exists(p =>
|
||||
p.color == color &&
|
||||
requiredPieceType.forall(_ == p.pieceType),
|
||||
) &&
|
||||
(hint.isEmpty || matchesHint(move.from, hint)) &&
|
||||
promotionMatches(move, promotion)
|
||||
}
|
||||
|
||||
candidates.headOption
|
||||
candidates.headOption
|
||||
|
||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
hint.forall(c =>
|
||||
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||
else true
|
||||
else true,
|
||||
)
|
||||
|
||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||
promotion match
|
||||
case None => move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case None =>
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
|
||||
case _ => false
|
||||
case Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[Move]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
|
||||
case (acc, token) =>
|
||||
tokens
|
||||
.foldLeft(
|
||||
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
|
||||
) { case (acc, token) =>
|
||||
acc.flatMap { case (ctx, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
val nextCtx = DefaultRules.applyMove(ctx)(move)
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
}
|
||||
.map(_._3)
|
||||
|
||||
Reference in New Issue
Block a user