From 4594f415131a6c5d70daec453d4acb95215c2d13 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 1 Apr 2026 11:02:02 +0200 Subject: [PATCH] feat: enhance move history with piece type and capture status --- .../chess/controller/GameController.scala | 5 ++-- .../de/nowchess/chess/logic/GameHistory.scala | 11 +++++--- .../nowchess/chess/notation/PgnExporter.scala | 28 +++++++++++++------ .../nowchess/chess/notation/PgnParser.scala | 10 ++++--- .../chess/notation/PgnExporterTest.scala | 24 ++++++++-------- .../de/nowchess/ui/gui/GUIObserver.scala | 6 ++++ .../de/nowchess/ui/terminal/TerminalUI.scala | 6 ++++ 7 files changed, 59 insertions(+), 31 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index 4e3b47d..a488225 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -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 = diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala index fe52d55..22f9c86 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala @@ -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() diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala index 38a3733..ea4e719 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala @@ -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" diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala index 1a2b170..5d1b771 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala @@ -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 = diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala index 931ffc9..7d453df 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -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 *" diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala index a97bd28..4a2fd9b 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/GUIObserver.scala @@ -43,6 +43,12 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer: case e: PromotionRequiredEvent => boardView.showPromotionDialog(e.from, e.to) + + case e: DrawClaimedEvent => + boardView.updateBoard(e.board, e.turn) + showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.") + case e: FiftyMoveRuleAvailableEvent => + boardView.showMessage("50-move rule available! The game is a draw.") } private def showAlert(alertType: AlertType, titleText: String, content: String): Unit = diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 90bb91d..71cbba2 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -49,6 +49,12 @@ class TerminalUI(engine: GameEngine) extends Observer: case _: PromotionRequiredEvent => println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") synchronized { awaitingPromotion = true } + case _: DrawClaimedEvent => + println("Draw claimed! The game is a draw.") + println() + print(Renderer.render(engine.board)) + case _: FiftyMoveRuleAvailableEvent => + println("50-move rule available! The game is a draw.") /** Start the terminal UI game loop. */ def start(): Unit =