feat: enhance move history with piece type and capture status
This commit is contained in:
@@ -92,9 +92,10 @@ object GameController:
|
|||||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||||
else (b, cap)
|
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 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)
|
toMoveResult(newBoard, newHistory, captured, turn)
|
||||||
|
|
||||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.logic
|
package de.nowchess.chess.logic
|
||||||
|
|
||||||
import de.nowchess.api.board.Square
|
import de.nowchess.api.board.{PieceType, Square}
|
||||||
import de.nowchess.api.move.PromotionPiece
|
import de.nowchess.api.move.PromotionPiece
|
||||||
|
|
||||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
/** 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,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
castleSide: Option[CastleSide],
|
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.
|
/** 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,
|
castleSide: Option[CastleSide] = None,
|
||||||
promotionPiece: Option[PromotionPiece] = None,
|
promotionPiece: Option[PromotionPiece] = None,
|
||||||
wasPawnMove: Boolean = false,
|
wasPawnMove: Boolean = false,
|
||||||
wasCapture: Boolean = false
|
wasCapture: Boolean = false,
|
||||||
|
pieceType: PieceType = PieceType.Pawn
|
||||||
): GameHistory =
|
): GameHistory =
|
||||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
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:
|
object GameHistory:
|
||||||
val empty: GameHistory = GameHistory()
|
val empty: GameHistory = GameHistory()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.notation
|
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.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
||||||
|
|
||||||
@@ -29,16 +29,26 @@ object PgnExporter:
|
|||||||
else if moveText.isEmpty then headerLines
|
else if moveText.isEmpty then headerLines
|
||||||
else s"$headerLines\n\n$moveText"
|
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 =
|
private def moveToAlgebraic(move: HistoryMove): String =
|
||||||
move.castleSide match
|
move.castleSide match
|
||||||
case Some(CastleSide.Kingside) => "O-O"
|
case Some(CastleSide.Kingside) => "O-O"
|
||||||
case Some(CastleSide.Queenside) => "O-O-O"
|
case Some(CastleSide.Queenside) => "O-O-O"
|
||||||
case None =>
|
case None =>
|
||||||
val base = s"${move.from}${move.to}"
|
val dest = move.to.toString
|
||||||
move.promotionPiece match
|
val capStr = if move.isCapture then "x" else ""
|
||||||
case Some(PromotionPiece.Queen) => s"$base=Q"
|
val promSuffix = move.promotionPiece match
|
||||||
case Some(PromotionPiece.Rook) => s"$base=R"
|
case Some(PromotionPiece.Queen) => "=Q"
|
||||||
case Some(PromotionPiece.Bishop) => s"$base=B"
|
case Some(PromotionPiece.Rook) => "=R"
|
||||||
case Some(PromotionPiece.Knight) => s"$base=N"
|
case Some(PromotionPiece.Bishop) => "=B"
|
||||||
case None => base
|
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
|
notation match
|
||||||
case "O-O" | "O-O+" | "O-O#" =>
|
case "O-O" | "O-O+" | "O-O#" =>
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
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#" =>
|
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
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 _ =>
|
case _ =>
|
||||||
parseRegularMove(notation, board, history, color)
|
parseRegularMove(notation, board, history, color)
|
||||||
@@ -143,8 +143,10 @@ object PgnParser:
|
|||||||
if hint.isEmpty then byPiece
|
if hint.isEmpty then byPiece
|
||||||
else byPiece.filter(from => matchesHint(from, hint))
|
else byPiece.filter(from => matchesHint(from, hint))
|
||||||
|
|
||||||
val promotion = extractPromotion(notation)
|
val promotion = extractPromotion(notation)
|
||||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
|
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). */
|
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.chess.notation
|
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.api.move.PromotionPiece
|
||||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
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))
|
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||||
val pgn = PgnExporter.exportGame(headers, history)
|
val pgn = PgnExporter.exportGame(headers, history)
|
||||||
|
|
||||||
pgn.contains("1. e2e4") shouldBe true
|
pgn.contains("1. e4") shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export castling") {
|
test("export castling") {
|
||||||
@@ -41,11 +41,11 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
.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.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)
|
val pgn = PgnExporter.exportGame(headers, history)
|
||||||
|
|
||||||
pgn.contains("1. e2e4 c7c5") shouldBe true
|
pgn.contains("1. e4 c5") shouldBe true
|
||||||
pgn.contains("2. g1f3") shouldBe true
|
pgn.contains("2. Nf3") shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export game with no headers returns only move text") {
|
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))
|
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||||
|
|
||||||
pgn shouldBe "1. e2e4 *"
|
pgn shouldBe "1. e4 *"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("export queenside castling") {
|
test("export queenside castling") {
|
||||||
@@ -69,35 +69,35 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
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") {
|
test("exportGame encodes promotion to Rook as =R suffix") {
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
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") {
|
test("exportGame encodes promotion to Bishop as =B suffix") {
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
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") {
|
test("exportGame encodes promotion to Knight as =N suffix") {
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
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") {
|
test("exportGame does not add suffix for normal moves") {
|
||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
|
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||||
pgn should include ("e2e4")
|
pgn should include ("e4")
|
||||||
pgn should not include ("=")
|
pgn should not include ("=")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,4 +111,4 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
|||||||
val history = GameHistory()
|
val history = GameHistory()
|
||||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||||
pgn shouldBe "1. e2e4 *"
|
pgn shouldBe "1. e4 *"
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
|
|||||||
|
|
||||||
case e: PromotionRequiredEvent =>
|
case e: PromotionRequiredEvent =>
|
||||||
boardView.showPromotionDialog(e.from, e.to)
|
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 =
|
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
case _: PromotionRequiredEvent =>
|
case _: PromotionRequiredEvent =>
|
||||||
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
||||||
synchronized { awaitingPromotion = true }
|
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. */
|
/** Start the terminal UI game loop. */
|
||||||
def start(): Unit =
|
def start(): Unit =
|
||||||
|
|||||||
Reference in New Issue
Block a user