refactor: NCS-22 NCS-23 reworked modules and tests (#17)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
trait GameContextExport:
|
||||
|
||||
def exportGameContext(context: GameContext): String
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
trait GameContextImport:
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext]
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
|
||||
object FenExporter extends GameContextExport:
|
||||
|
||||
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
|
||||
def boardToFen(board: Board): String =
|
||||
Rank.values.reverse
|
||||
.map(rank => buildRankString(board, rank))
|
||||
.mkString("/")
|
||||
|
||||
/** 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
|
||||
|
||||
/** 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 fullMoveNumber = 1 + (context.moves.length / 2)
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
|
||||
|
||||
def exportGameContext(context: GameContext): String = gameContextToFen(context)
|
||||
|
||||
/** 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 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 pieceToFenChar(piece: Piece): Char =
|
||||
val base = piece.pieceType match
|
||||
case PieceType.Pawn => 'p'
|
||||
case PieceType.Knight => 'n'
|
||||
case PieceType.Bishop => 'b'
|
||||
case PieceType.Rook => 'r'
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
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. */
|
||||
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}")
|
||||
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')")
|
||||
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)")
|
||||
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
|
||||
_ <- 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
|
||||
)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
|
||||
/** 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 unified castling rights. */
|
||||
private def parseCastling(s: String): Option[CastlingRights] =
|
||||
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
|
||||
|
||||
/** 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] =
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
object PgnExporter extends GameContextExport:
|
||||
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"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 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
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a Move to Standard Algebraic Notation using the board state before the move. */
|
||||
private def moveToAlgebraic(move: Move, boardBefore: Board): String =
|
||||
move.moveType match
|
||||
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) =>
|
||||
val promSuffix = pp match
|
||||
case PromotionPiece.Queen => "=Q"
|
||||
case PromotionPiece.Rook => "=R"
|
||||
case PromotionPiece.Bishop => "=B"
|
||||
case PromotionPiece.Knight => "=N"
|
||||
val isCapture = boardBefore.pieceAt(move.to).isDefined
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}$promSuffix"
|
||||
else s"${move.to}$promSuffix"
|
||||
case MoveType.Normal(isCapture) =>
|
||||
val dest = move.to.toString
|
||||
val capStr = if isCapture then "x" else ""
|
||||
boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
|
||||
case PieceType.Pawn =>
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x$dest"
|
||||
else dest
|
||||
case PieceType.Knight => s"N$capStr$dest"
|
||||
case PieceType.Bishop => s"B$capStr$dest"
|
||||
case PieceType.Rook => s"R$capStr$dest"
|
||||
case PieceType.Queen => s"Q$capStr$dest"
|
||||
case PieceType.King => s"K$capStr$dest"
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
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]
|
||||
)
|
||||
|
||||
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. */
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
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. */
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
Right(game.moves.foldLeft(GameContext.initial)(DefaultRules.applyMove))
|
||||
}
|
||||
|
||||
/** 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 (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
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"]. */
|
||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*]$""".r
|
||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
||||
|
||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved Moves. */
|
||||
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])
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case Some(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"
|
||||
|
||||
/** Parse a single algebraic notation token into a Move, given the current game context. */
|
||||
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside)
|
||||
Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
|
||||
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside)
|
||||
Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
|
||||
|
||||
case _ =>
|
||||
parseRegularMove(notation, ctx, color)
|
||||
|
||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
||||
private def parseRegularMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(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 hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 Some(pp) => move.moveType == MoveType.Promotion(pp)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
private[pgn] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||
val promotionPattern = """=([A-Z])""".r
|
||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
||||
m.group(1) match
|
||||
case "Q" => Some(PromotionPiece.Queen)
|
||||
case "R" => Some(PromotionPiece.Rook)
|
||||
case "B" => Some(PromotionPiece.Bishop)
|
||||
case "N" => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
}
|
||||
|
||||
/** Convert a piece-letter character to a PieceType. */
|
||||
private def charToPieceType(c: Char): Option[PieceType] =
|
||||
c match
|
||||
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
|
||||
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** 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) =>
|
||||
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 Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx, move)
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user