feat: enhance move history with piece type and capture status

This commit is contained in:
2026-04-01 11:02:02 +02:00
parent 98ff39890f
commit 4594f41513
7 changed files with 59 additions and 31 deletions
@@ -92,9 +92,10 @@ object GameController:
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val pieceType = board.pieceAt(from).map(_.pieceType).getOrElse(PieceType.Pawn)
val wasPawnMove = pieceType == PieceType.Pawn
val wasCapture = captured.isDefined
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
@@ -1,6 +1,6 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
import de.nowchess.api.board.{PieceType, Square}
import de.nowchess.api.move.PromotionPiece
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
@@ -8,7 +8,9 @@ case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[CastleSide],
promotionPiece: Option[PromotionPiece] = None
promotionPiece: Option[PromotionPiece] = None,
pieceType: PieceType = PieceType.Pawn,
isCapture: Boolean = false
)
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
@@ -37,10 +39,11 @@ case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
wasCapture: Boolean = false,
pieceType: PieceType = PieceType.Pawn
): GameHistory =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece, pieceType, wasCapture), newClock)
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -1,6 +1,6 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.board.{PieceType, *}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
@@ -29,16 +29,26 @@ object PgnExporter:
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
/** Convert a HistoryMove to algebraic notation. */
/** Convert a HistoryMove to Standard Algebraic Notation. */
private def moveToAlgebraic(move: HistoryMove): String =
move.castleSide match
case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O"
case None =>
val base = s"${move.from}${move.to}"
move.promotionPiece match
case Some(PromotionPiece.Queen) => s"$base=Q"
case Some(PromotionPiece.Rook) => s"$base=R"
case Some(PromotionPiece.Bishop) => s"$base=B"
case Some(PromotionPiece.Knight) => s"$base=N"
case None => base
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
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"
@@ -79,11 +79,11 @@ object PgnParser:
notation match
case "O-O" | "O-O+" | "O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside), pieceType = PieceType.King))
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside), pieceType = PieceType.King))
case _ =>
parseRegularMove(notation, board, history, color)
@@ -143,8 +143,10 @@ object PgnParser:
if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint))
val promotion = extractPromotion(notation)
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
val promotion = extractPromotion(notation)
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
val moveIsCapture = notation.contains('x')
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
@@ -1,6 +1,6 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.board.{PieceType, *}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
import org.scalatest.funsuite.AnyFunSuite
@@ -24,7 +24,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("1. e2e4") shouldBe true
pgn.contains("1. e4") shouldBe true
}
test("export castling") {
@@ -41,11 +41,11 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
.addMove(HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None))
.addMove(HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None))
.addMove(HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None, pieceType = PieceType.Knight))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("1. e2e4 c7c5") shouldBe true
pgn.contains("2. g1f3") shouldBe true
pgn.contains("1. e4 c5") shouldBe true
pgn.contains("2. Nf3") shouldBe true
}
test("export game with no headers returns only move text") {
@@ -53,7 +53,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn shouldBe "1. e2e4 *"
pgn shouldBe "1. e4 *"
}
test("export queenside castling") {
@@ -69,35 +69,35 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=Q")
pgn should include ("e8=Q")
}
test("exportGame encodes promotion to Rook as =R suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=R")
pgn should include ("e8=R")
}
test("exportGame encodes promotion to Bishop as =B suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=B")
pgn should include ("e8=B")
}
test("exportGame encodes promotion to Knight as =N suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=N")
pgn should include ("e8=N")
}
test("exportGame does not add suffix for normal moves") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e2e4")
pgn should include ("e4")
pgn should not include ("=")
}
@@ -111,4 +111,4 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn shouldBe "1. e2e4 *"
pgn shouldBe "1. e4 *"