refactor(core): enhance MoveType to support capture flag and update related logic
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{PieceType, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
/** A single move recorded in game history and PGN workflows. */
|
||||
case class HistoryMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[String] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
pieceType: PieceType = PieceType.Pawn,
|
||||
isCapture: Boolean = false
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ enum PromotionPiece:
|
||||
/** Classifies special move semantics beyond a plain quiet move or capture. */
|
||||
enum MoveType:
|
||||
/** A normal move or capture with no special rule. */
|
||||
case Normal
|
||||
case Normal(isCapture: Boolean = false)
|
||||
/** Kingside castling (O-O). */
|
||||
case CastleKingside
|
||||
/** Queenside castling (O-O-O). */
|
||||
@@ -29,5 +29,5 @@ enum MoveType:
|
||||
final case class Move(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveType: MoveType = MoveType.Normal
|
||||
moveType: MoveType = MoveType.Normal()
|
||||
)
|
||||
|
||||
@@ -11,7 +11,12 @@ class MoveTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Move defaults moveType to Normal") {
|
||||
val m = Move(e2, e4)
|
||||
m.moveType shouldBe MoveType.Normal
|
||||
m.moveType shouldBe MoveType.Normal()
|
||||
}
|
||||
|
||||
test("MoveType.Normal supports capture flag") {
|
||||
val m = Move(e2, e4, MoveType.Normal(isCapture = true))
|
||||
m.moveType shouldBe MoveType.Normal(true)
|
||||
}
|
||||
|
||||
test("Move stores from and to squares") {
|
||||
|
||||
@@ -242,8 +242,7 @@ class GameEngine(
|
||||
case PromotionPiece.Bishop => "B"
|
||||
case PromotionPiece.Knight => "N"
|
||||
s"${move.to}=$ppChar"
|
||||
case MoveType.Normal =>
|
||||
val isCapture = boardBefore.pieceAt(move.to).isDefined
|
||||
case MoveType.Normal(isCapture) =>
|
||||
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||
case Some(PieceType.Pawn) =>
|
||||
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||
|
||||
@@ -158,7 +158,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
def legalMoves(context: GameContext, square: Square): List[Move] =
|
||||
DefaultRules.legalMoves(context, square).map { m =>
|
||||
m.moveType match
|
||||
case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal)
|
||||
case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal())
|
||||
case _ => m
|
||||
}
|
||||
def allLegalMoves(context: GameContext): List[Move] =
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.game.HistoryMove
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class HistoryMoveTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("Move with promotion records the promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
|
||||
move.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
test("Normal move has no promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), None, None)
|
||||
move.promotionPiece should be (None)
|
||||
|
||||
test("HistoryMove default values are compatible with normal pawn move"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
move.castleSide shouldBe None
|
||||
move.promotionPiece shouldBe None
|
||||
move.pieceType shouldBe PieceType.Pawn
|
||||
move.isCapture shouldBe false
|
||||
|
||||
test("HistoryMove stores castle side and capture marker"):
|
||||
val move = HistoryMove(
|
||||
from = sq(File.E, Rank.R1),
|
||||
to = sq(File.G, Rank.R1),
|
||||
castleSide = Some("Kingside"),
|
||||
pieceType = PieceType.King
|
||||
)
|
||||
move.castleSide shouldBe Some("Kingside")
|
||||
move.pieceType shouldBe PieceType.King
|
||||
|
||||
test("HistoryMove stores explicit piece and capture flags"):
|
||||
val move = HistoryMove(
|
||||
from = sq(File.G, Rank.R1),
|
||||
to = sq(File.F, Rank.R3),
|
||||
pieceType = PieceType.Knight,
|
||||
isCapture = true
|
||||
)
|
||||
move.pieceType shouldBe PieceType.Knight
|
||||
move.isCapture shouldBe true
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{PromotionPiece, MoveType}
|
||||
import de.nowchess.api.game.{GameContext, HistoryMove}
|
||||
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 by replaying all moves and reconstructing HistoryMove records. */
|
||||
/** Export a GameContext to PGN format. */
|
||||
def exportGameContext(context: GameContext): String =
|
||||
val headers = Map(
|
||||
"Event" -> "?",
|
||||
@@ -17,46 +17,28 @@ object PgnExporter extends GameContextExport:
|
||||
"Result" -> "*"
|
||||
)
|
||||
|
||||
// Replay all moves to reconstruct HistoryMove records with full info
|
||||
val historyMoves = scala.collection.mutable.ListBuffer[HistoryMove]()
|
||||
var ctx = GameContext.initial
|
||||
for move <- context.moves do
|
||||
val color = ctx.turn
|
||||
val pieceType = ctx.board.pieceAt(move.from)
|
||||
.map(_.pieceType)
|
||||
.getOrElse {
|
||||
val fromStr = move.from.toString
|
||||
val boardStr = ctx.board.pieces.keys.map(_.toString).mkString(",")
|
||||
throw new IllegalStateException(
|
||||
s"Invariant violation: no piece at $fromStr during PGN export replay. Board squares: $boardStr"
|
||||
)
|
||||
}
|
||||
val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant
|
||||
val castleSide = move.moveType match
|
||||
case MoveType.CastleKingside => Some("Kingside")
|
||||
case MoveType.CastleQueenside => Some("Queenside")
|
||||
case _ => None
|
||||
val promotionPiece = move.moveType match
|
||||
case MoveType.Promotion(pp) => Some(pp)
|
||||
case _ => None
|
||||
historyMoves += HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture)
|
||||
ctx = DefaultRules.applyMove(ctx, move)
|
||||
exportGame(headers, context.moves)
|
||||
|
||||
exportGame(headers, historyMoves.toList)
|
||||
|
||||
/** Export a game with headers and history moves to PGN format. */
|
||||
def exportGame(headers: Map[String, String], moves: List[HistoryMove]): String =
|
||||
/** 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
|
||||
val groupedMoves = moves.zipWithIndex.groupBy(_._2 / 2)
|
||||
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(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
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"
|
||||
|
||||
@@ -67,28 +49,32 @@ object PgnExporter extends GameContextExport:
|
||||
else if moveText.isEmpty then headerLines
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a HistoryMove to Standard Algebraic Notation. */
|
||||
def moveToAlgebraic(move: HistoryMove): String =
|
||||
move.castleSide match
|
||||
case Some("Kingside") => "O-O"
|
||||
case Some("Queenside") => "O-O-O"
|
||||
case Some(_) | None =>
|
||||
/** 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 move.isCapture then "x" else ""
|
||||
val promSuffix = move.promotionPiece match
|
||||
case Some(PromotionPiece.Queen) => "=Q"
|
||||
case Some(PromotionPiece.Rook) => "=R"
|
||||
case Some(PromotionPiece.Bishop) => "=B"
|
||||
case Some(PromotionPiece.Knight) => "=N"
|
||||
case None => ""
|
||||
move.pieceType match
|
||||
val capStr = if isCapture then "x" else ""
|
||||
boardBefore.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) match
|
||||
case PieceType.Pawn =>
|
||||
if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix"
|
||||
else s"$dest$promSuffix"
|
||||
case PieceType.Knight => s"N$capStr$dest$promSuffix"
|
||||
case PieceType.Bishop => s"B$capStr$dest$promSuffix"
|
||||
case PieceType.Rook => s"R$capStr$dest$promSuffix"
|
||||
case PieceType.Queen => s"Q$capStr$dest$promSuffix"
|
||||
case PieceType.King => s"K$capStr$dest$promSuffix"
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.{GameContext, HistoryMove}
|
||||
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[HistoryMove]
|
||||
moves: List[Move]
|
||||
)
|
||||
|
||||
object PgnParser extends GameContextImport:
|
||||
@@ -29,24 +29,7 @@ object PgnParser extends GameContextImport:
|
||||
* Returns Left(error message) if validation fails or move replay encounters an issue. */
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
validatePgn(input).flatMap { game =>
|
||||
// Replay moves to populate GameContext.moves via DefaultRules.applyMove
|
||||
val (finalCtx, errors) = game.moves.foldLeft((GameContext.initial, Option.empty[String])) {
|
||||
case ((ctx, Some(err)), _) => (ctx, Some(err)) // Already failed, stop
|
||||
case ((ctx, None), histMove) =>
|
||||
val moveOpt = parseAlgebraicMove(
|
||||
s"${histMove.from}${histMove.to}",
|
||||
ctx,
|
||||
ctx.turn
|
||||
)
|
||||
moveOpt match
|
||||
case None => (ctx, Some(s"Failed to parse move ${histMove.from}${histMove.to}"))
|
||||
case Some(move) =>
|
||||
val nextCtx = DefaultRules.applyMove(ctx, move)
|
||||
(nextCtx, None)
|
||||
}
|
||||
errors match
|
||||
case Some(err) => Left(err)
|
||||
case None => Right(finalCtx)
|
||||
Right(game.moves.foldLeft(GameContext.initial)(DefaultRules.applyMove))
|
||||
}
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
@@ -64,11 +47,11 @@ object PgnParser extends GameContextImport:
|
||||
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 HistoryMoves. */
|
||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
||||
/** 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[HistoryMove])
|
||||
(GameContext.initial, Color.White, List.empty[Move])
|
||||
):
|
||||
case (state @ (ctx, color, acc), token) =>
|
||||
if isMoveNumberOrResult(token) then state
|
||||
@@ -76,24 +59,10 @@ object PgnParser extends GameContextImport:
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => state
|
||||
case Some(move) =>
|
||||
val histMove = toHistoryMove(move, ctx, color)
|
||||
val nextCtx = DefaultRules.applyMove(ctx, move)
|
||||
(nextCtx, color.opposite, acc :+ histMove)
|
||||
(nextCtx, color.opposite, acc :+ move)
|
||||
moves
|
||||
|
||||
/** Convert an api.move.Move to a HistoryMove given the board context before the move. */
|
||||
private def toHistoryMove(move: Move, ctx: GameContext, color: Color): HistoryMove =
|
||||
val castleSide = move.moveType match
|
||||
case MoveType.CastleKingside => Some("Kingside")
|
||||
case MoveType.CastleQueenside => Some("Queenside")
|
||||
case _ => None
|
||||
val promotionPiece = move.moveType match
|
||||
case MoveType.Promotion(pp) => Some(pp)
|
||||
case _ => None
|
||||
val pieceType = ctx.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
||||
val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant
|
||||
HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture)
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
@@ -167,8 +136,9 @@ object PgnParser extends GameContextImport:
|
||||
|
||||
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
|
||||
promotion match
|
||||
case None => move.moveType == MoveType.Normal || move.moveType == MoveType.EnPassant ||
|
||||
move.moveType == MoveType.CastleKingside || move.moveType == MoveType.CastleQueenside
|
||||
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. */
|
||||
@@ -196,9 +166,9 @@ object PgnParser extends GameContextImport:
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
||||
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[HistoryMove])): Either[String, (GameContext, Color, List[HistoryMove])]) {
|
||||
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))
|
||||
@@ -206,9 +176,8 @@ object PgnParser extends GameContextImport:
|
||||
parseAlgebraicMove(token, ctx, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val histMove = toHistoryMove(move, ctx, color)
|
||||
val nextCtx = DefaultRules.applyMove(ctx, move)
|
||||
Right((nextCtx, color.opposite, moves :+ histMove))
|
||||
Right((nextCtx, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._3)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.{PieceType, *}
|
||||
import de.nowchess.api.move.{PromotionPiece, Move, MoveType}
|
||||
import de.nowchess.api.game.{GameContext, HistoryMove}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -19,7 +19,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export single move") {
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
|
||||
val pgn = PgnExporter.exportGame(headers, moves)
|
||||
|
||||
pgn.contains("1. e4") shouldBe true
|
||||
@@ -27,7 +27,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export castling") {
|
||||
val headers = Map("Event" -> "Test")
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside")))
|
||||
val moves = List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
|
||||
val pgn = PgnExporter.exportGame(headers, moves)
|
||||
|
||||
pgn.contains("O-O") shouldBe true
|
||||
@@ -36,9 +36,9 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
test("export game sequence") {
|
||||
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0")
|
||||
val moves = List(
|
||||
HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None),
|
||||
HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None),
|
||||
HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None, pieceType = PieceType.Knight)
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)),
|
||||
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal(false)),
|
||||
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal(false))
|
||||
)
|
||||
val pgn = PgnExporter.exportGame(headers, moves)
|
||||
|
||||
@@ -47,7 +47,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("export game with no headers returns only move text") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
|
||||
pgn shouldBe "1. e4 *"
|
||||
@@ -55,57 +55,57 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export queenside castling") {
|
||||
val headers = Map("Event" -> "Test")
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside")))
|
||||
val moves = List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
|
||||
val pgn = PgnExporter.exportGame(headers, moves)
|
||||
|
||||
pgn.contains("O-O-O") shouldBe true
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Queen as =Q suffix") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
|
||||
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn should include ("e8=Q")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Rook as =R suffix") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
|
||||
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn should include ("e8=R")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Bishop as =B suffix") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
|
||||
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn should include ("e8=B")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Knight as =N suffix") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
|
||||
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn should include ("e8=N")
|
||||
}
|
||||
|
||||
test("exportGame does not add suffix for normal moves") {
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn should include ("e4")
|
||||
pgn should not include "="
|
||||
}
|
||||
|
||||
test("exportGame uses Result header as termination marker"):
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), moves)
|
||||
pgn should endWith("1/2-1/2")
|
||||
|
||||
test("exportGame with no Result header still uses * as default"):
|
||||
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, moves)
|
||||
pgn shouldBe "1. e4 *"
|
||||
|
||||
test("exportGameContext: moves are preserved in output") {
|
||||
val moves = List(
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal),
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal)
|
||||
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)),
|
||||
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal(false))
|
||||
)
|
||||
val ctx = GameContext.initial.copy(moves = moves)
|
||||
val exported = PgnExporter.exportGameContext(ctx)
|
||||
|
||||
@@ -73,7 +73,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.from shouldBe Square(File.E, Rank.R1)
|
||||
lastMove.to shouldBe Square(File.G, Rank.R1)
|
||||
lastMove.castleSide.isDefined shouldBe true
|
||||
lastMove.moveType shouldBe MoveType.CastleKingside
|
||||
}
|
||||
|
||||
test("parse PGN empty moves") {
|
||||
@@ -98,7 +98,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val blackCastle = game.get.moves.last
|
||||
blackCastle.castleSide shouldBe Some("Kingside")
|
||||
blackCastle.moveType shouldBe MoveType.CastleKingside
|
||||
blackCastle.from shouldBe Square(File.E, Rank.R8)
|
||||
blackCastle.to shouldBe Square(File.G, Rank.R8)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.castleSide shouldBe Some("Queenside")
|
||||
lastMove.moveType shouldBe MoveType.CastleQueenside
|
||||
lastMove.from shouldBe Square(File.E, Rank.R1)
|
||||
lastMove.to shouldBe Square(File.C, Rank.R1)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.castleSide shouldBe Some("Queenside")
|
||||
lastMove.moveType shouldBe MoveType.CastleQueenside
|
||||
lastMove.from shouldBe Square(File.E, Rank.R8)
|
||||
lastMove.to shouldBe Square(File.C, Rank.R8)
|
||||
}
|
||||
@@ -326,7 +326,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
||||
test("parseAlgebraicMove preserves promotion to Queen") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
|
||||
result.isDefined should be (true)
|
||||
@@ -371,7 +371,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
|
||||
// Exercises the promotion piece type branches in PgnParser.parseMovesText / toHistoryMove
|
||||
// Exercises the promotion piece type branches in PgnParser.parseMovesText
|
||||
// White pawn advances via capture chain (a4xb5, b5xc6 e.p., c6-c7, c7xd8) and promotes
|
||||
val baseSequence = "1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8="
|
||||
for (piece, expected) <- List(
|
||||
@@ -384,7 +384,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
game.get.moves should not be empty
|
||||
game.get.moves.last.promotionPiece shouldBe Some(expected)
|
||||
game.get.moves.last.moveType shouldBe MoveType.Promotion(expected)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
|
||||
@@ -398,7 +398,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
||||
lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Bishop through full PGN parse") {
|
||||
@@ -411,7 +411,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop)
|
||||
lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Knight through full PGN parse") {
|
||||
@@ -424,7 +424,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight)
|
||||
lastMove.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
}
|
||||
|
||||
test("extractPromotion returns None for invalid promotion letter") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.pgn
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.api.move.MoveType
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -70,7 +70,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) =>
|
||||
game.moves.last.castleSide shouldBe Some("Kingside")
|
||||
game.moves.last.moveType shouldBe MoveType.CastleKingside
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: castling when not legal returns Left"):
|
||||
@@ -92,7 +92,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
||||
"""
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Right(game) =>
|
||||
game.moves.last.castleSide shouldBe Some("Queenside")
|
||||
game.moves.last.moveType shouldBe MoveType.CastleQueenside
|
||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||
|
||||
test("validatePgn: disambiguation with two rooks is accepted"):
|
||||
|
||||
@@ -85,7 +85,7 @@ object DefaultRules extends RuleSet:
|
||||
case Some(next) =>
|
||||
board.pieceAt(next) match
|
||||
case None => loop(next, Move(from, next) :: acc)
|
||||
case Some(p) if p.color != color => Move(from, next) :: acc
|
||||
case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
|
||||
case Some(_) => acc
|
||||
loop(from, Nil).reverse
|
||||
|
||||
@@ -100,7 +100,8 @@ object DefaultRules extends RuleSet:
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case _ => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +116,8 @@ object DefaultRules extends RuleSet:
|
||||
from.offset(df, dr).flatMap { to =>
|
||||
context.board.pieceAt(to) match
|
||||
case Some(p) if p.color == color => None
|
||||
case _ => Some(Move(from, to))
|
||||
case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
|
||||
case None => Some(Move(from, to))
|
||||
}
|
||||
}
|
||||
steps ++ castlingCandidates(context, from, color)
|
||||
@@ -220,17 +222,17 @@ object DefaultRules extends RuleSet:
|
||||
}
|
||||
}
|
||||
|
||||
def toMoves(dest: Square): List[Move] =
|
||||
def toMoves(dest: Square, isCapture: Boolean): List[Move] =
|
||||
if dest.rank.ordinal == promoRank then
|
||||
List(
|
||||
PromotionPiece.Queen, PromotionPiece.Rook,
|
||||
PromotionPiece.Bishop, PromotionPiece.Knight
|
||||
).map(pt => Move(from, dest, MoveType.Promotion(pt)))
|
||||
else List(Move(from, dest))
|
||||
else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
|
||||
|
||||
val stepSquares = single.toList ++ double.toList
|
||||
val stepMoves = stepSquares.flatMap(toMoves)
|
||||
val captureMoves = diagonalCaptures.flatMap(toMoves)
|
||||
val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
|
||||
val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
|
||||
stepMoves ++ captureMoves ++ epCaptures
|
||||
|
||||
// ── Check detection ────────────────────────────────────────────────
|
||||
@@ -287,11 +289,14 @@ object DefaultRules extends RuleSet:
|
||||
case MoveType.CastleQueenside => applyCastle(board, color, kingside = false)
|
||||
case MoveType.EnPassant => applyEnPassant(board, move, color)
|
||||
case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp)
|
||||
case MoveType.Normal => board.applyMove(move)
|
||||
case MoveType.Normal(_) => board.applyMove(move)
|
||||
|
||||
val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color)
|
||||
val newEnPassantSquare = computeEnPassantSquare(board, move, color)
|
||||
val isCapture = board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant
|
||||
val isCapture = move.moveType match
|
||||
case MoveType.Normal(capture) => capture
|
||||
case MoveType.EnPassant => true
|
||||
case _ => board.pieceAt(move.to).isDefined
|
||||
val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn)
|
||||
val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user