refactor(core): replace GameHistory with HistoryMove in PGN export logic

This commit is contained in:
2026-04-05 17:08:54 +02:00
parent a1b7cc7f4a
commit 432385c7b0
13 changed files with 110 additions and 218 deletions
@@ -1,51 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.board.{PieceType, Square}
import de.nowchess.api.move.{Move, PromotionPiece}
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[String] = 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.
*
* @param moves moves played so far, oldest first
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
*
* Deprecated: Use GameContext instead. This exists for compatibility during migration.
*/
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
/** Add a raw HistoryMove record. Clock increments by 1.
* Use the coordinate overload when you know whether the move is a pawn move or capture.
*/
def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move, halfMoveClock + 1)
/** Add a move by coordinates.
*
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
*
* If neither flag is set the clock increments by 1.
*/
def addMove(
from: Square,
to: Square,
castleSide: Option[String] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: 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, pieceType, wasCapture), newClock)
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -0,0 +1,15 @@
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
)
@@ -1,105 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.{GameHistory, HistoryMove}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameHistoryTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("GameHistory starts empty"):
val history = GameHistory.empty
history.moves shouldBe empty
test("GameHistory can add a move"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
history.moves should have length 1
history.moves.head.from shouldBe sq(File.E, Rank.R2)
history.moves.head.to shouldBe sq(File.E, Rank.R4)
history.moves.head.castleSide shouldBe None
test("GameHistory can add multiple moves in order"):
val h1 = GameHistory.empty
val h2 = h1.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val h3 = h2.addMove(sq(File.C, Rank.R7), sq(File.C, Rank.R5))
h3.moves should have length 2
h3.moves(0).from shouldBe sq(File.E, Rank.R2)
h3.moves(1).from shouldBe sq(File.C, Rank.R7)
test("GameHistory can add a castle move"):
val history = GameHistory.empty.addMove(
sq(File.E, Rank.R1),
sq(File.G, Rank.R1),
Some("Kingside")
)
history.moves.head.castleSide shouldBe Some("Kingside")
test("GameHistory.addMove with two arguments uses None for castleSide default"):
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
history.moves should have length 1
history.moves.head.castleSide shouldBe None
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("addMove with promotion stores promotionPiece"):
val history = GameHistory.empty
val newHistory = history.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
newHistory.moves should have length 1
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
test("addMove with castleSide only uses promotionPiece default (None)"):
val history = GameHistory.empty
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some("Kingside"))
newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (Some("Kingside"))
newHistory.moves.head.promotionPiece should be (None)
test("addMove using named parameters with only promotion, using castleSide default"):
val history = GameHistory.empty
val newHistory = history.addMove(from = sq(File.E, Rank.R7), to = sq(File.E, Rank.R8), promotionPiece = Some(PromotionPiece.Queen))
newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (None)
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
// ──── half-move clock ────────────────────────────────────────────────
test("halfMoveClock starts at 0"):
GameHistory.empty.halfMoveClock shouldBe 0
test("halfMoveClock increments on a non-pawn non-capture move"):
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
h.halfMoveClock shouldBe 1
test("halfMoveClock resets to 0 on a pawn move"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 on a capture"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock carries across multiple moves"):
val h = GameHistory.empty
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
h.halfMoveClock shouldBe 1
test("GameHistory can be initialised with a non-zero halfMoveClock"):
val h = GameHistory(halfMoveClock = 42)
h.halfMoveClock shouldBe 42
@@ -0,0 +1,48 @@
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
@@ -2,7 +2,7 @@ package de.nowchess.io.pgn
import de.nowchess.api.board.*
import de.nowchess.api.move.{PromotionPiece, MoveType}
import de.nowchess.api.game.{GameHistory, HistoryMove, GameContext}
import de.nowchess.api.game.{GameContext, HistoryMove}
import de.nowchess.io.GameContextExport
import de.nowchess.rules.sets.DefaultRules
@@ -42,18 +42,17 @@ object PgnExporter extends GameContextExport:
historyMoves += HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture)
ctx = DefaultRules.applyMove(ctx, move)
val history = GameHistory(historyMoves.toList, context.halfMoveClock)
exportGame(headers, history)
exportGame(headers, historyMoves.toList)
/** Export a game with headers and history to PGN format. */
def exportGame(headers: Map[String, String], history: GameHistory): String =
/** Export a game with headers and history moves to PGN format. */
def exportGame(headers: Map[String, String], moves: List[HistoryMove]): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if history.moves.isEmpty then ""
val moveText = if moves.isEmpty then ""
else
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
val groupedMoves = moves.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("")
@@ -2,8 +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.{GameHistory, HistoryMove, GameContext}
import de.nowchess.io.pgn.PgnParser
import de.nowchess.api.game.{GameContext, HistoryMove}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -11,8 +10,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
test("export empty game") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val history = GameHistory.empty
val pgn = PgnExporter.exportGame(headers, history)
val pgn = PgnExporter.exportGame(headers, List.empty)
pgn.contains("[Event \"Test\"]") shouldBe true
pgn.contains("[White \"A\"]") shouldBe true
@@ -21,97 +19,87 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
test("export single move") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(headers, history)
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("1. e4") shouldBe true
}
test("export castling") {
val headers = Map("Event" -> "Test")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside")))
val pgn = PgnExporter.exportGame(headers, history)
val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside")))
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("O-O") shouldBe true
}
test("export game sequence") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0")
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, pieceType = PieceType.Knight))
val pgn = PgnExporter.exportGame(headers, history)
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)
)
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("1. e4 c5") shouldBe true
pgn.contains("2. Nf3") shouldBe true
}
test("export game with no headers returns only move text") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn shouldBe "1. e4 *"
}
test("export queenside castling") {
val headers = Map("Event" -> "Test")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside")))
val pgn = PgnExporter.exportGame(headers, history)
val moves = List(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside")))
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("O-O-O") shouldBe true
}
test("exportGame encodes promotion to Queen as =Q suffix") {
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)
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
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)
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
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)
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
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)
val moves = List(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
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)
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e4")
pgn should not include ("=")
pgn should not include "="
}
test("exportGame uses Result header as termination marker"):
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
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 history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
val moves = List(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn shouldBe "1. e4 *"
test("exportGameContext: moves are preserved in output") {
@@ -2,7 +2,6 @@ package de.nowchess.io.pgn
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.{GameHistory, HistoryMove}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -11,7 +11,6 @@ import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.{GameHistory, HistoryMove}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.command.{MoveCommand, MoveResult}
import de.nowchess.chess.engine.GameEngine
@@ -178,7 +177,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
if piece.color == currentTurn then
selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
@@ -289,7 +288,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
private def doPgnImport(): Unit =
doImport(PgnParser, "PGN")
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context)
showCopyDialog(s"$formatName Export", exported)
@@ -3,8 +3,8 @@ package de.nowchess.ui.terminal
import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.view.Renderer
import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern.
* Subscribes to GameEngine and receives state change events.
@@ -1,4 +1,4 @@
package de.nowchess.chess.view
package de.nowchess.ui.utils
import de.nowchess.api.board.{Color, Piece, PieceType}
@@ -1,6 +1,6 @@
package de.nowchess.chess.view
package de.nowchess.ui.utils
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
import de.nowchess.api.board.*
object Renderer:
@@ -1,4 +1,4 @@
package de.nowchess.chess.view
package de.nowchess.ui.utils
import de.nowchess.api.board.Piece
import org.scalatest.funsuite.AnyFunSuite
@@ -1,6 +1,6 @@
package de.nowchess.chess.view
package de.nowchess.ui.utils
import de.nowchess.api.board.{Board, File, Piece, Rank, Square}
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers