refactor(core): enhance MoveType to support capture flag and update related logic
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-05 17:28:56 +02:00
parent 432385c7b0
commit 1fc5e43e77
12 changed files with 109 additions and 208 deletions
@@ -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 =>
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
/** 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 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