refactor(core): enhance GameEngine and GUI for undo/redo functionality and integrate FEN/PGN export/import
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-04 21:02:39 +02:00
parent 9b3bbfbcb7
commit fa5cdf4e9c
5 changed files with 135 additions and 26 deletions
@@ -15,8 +15,8 @@ import de.nowchess.rules.sets.DefaultRules
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
class GameEngine(
initialContext: GameContext = GameContext.initial,
ruleSet: RuleSet = DefaultRules
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules
) extends Observable:
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
@@ -59,7 +59,6 @@ class GameEngine(
case "draw" =>
if currentContext.halfMoveClock >= 100 then
currentContext = GameContext.initial
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
else
@@ -202,10 +201,8 @@ class GameEngine(
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
currentContext = GameContext.initial
notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then
currentContext = GameContext.initial
notifyObservers(StalemateEvent(currentContext))
else if ruleSet.isCheck(currentContext) then
notifyObservers(CheckDetectedEvent(currentContext))
+8
View File
@@ -1,3 +1,6 @@
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.jvm.tasks.Jar
plugins {
id("scala")
id("org.scoverage")
@@ -38,6 +41,10 @@ tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
tasks.named<Jar>("jar") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
@@ -54,6 +61,7 @@ dependencies {
implementation(project(":modules:core"))
implementation(project(":modules:rule"))
implementation(project(":modules:api"))
implementation(project(":modules:io"))
// ScalaFX dependencies
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
@@ -1,5 +1,6 @@
package de.nowchess.ui.gui
import scala.compiletime.uninitialized
import scalafx.Includes.*
import scalafx.application.Platform
import scalafx.geometry.{Insets, Pos}
@@ -10,7 +11,9 @@ import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square, File, Rank}
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
/** ScalaFX chess board view that displays the game state.
@@ -33,6 +36,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized
// Initialize UI
initializeBoard()
@@ -66,15 +72,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
spacing = 10
alignment = Pos.Center
children = Seq(
new Button("Undo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canUndo then engine.undo()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
{
undoButton = new Button("Undo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canUndo then engine.undo()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
disable = !engine.canUndo
}
undoButton
},
new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
{
redoButton = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
disable = !engine.canRedo
}
redoButton
},
new Button("Reset") {
font = Font.font(comicSansFontFamily, 12)
@@ -161,12 +175,12 @@ 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)
// TODO: Update legal move highlighting to use new RuleSet interface from modules/rule
// val legalDests = GameRules.legalMoves(currentBoard, engine.history, currentTurn)
// .collect { case (`clickedSquare`, to) => to }
// legalDests.foreach { sq =>
// highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
// }
val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
case Some(fromSquare) =>
@@ -215,6 +229,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
stackPane.children = children
}
updateUndoRedoButtons()
def updateUndoRedoButtons(): Unit =
if undoButton != null then undoButton.disable = !engine.canUndo
if redoButton != null then redoButton.disable = !engine.canRedo
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
squareViews.get((rank, file)).foreach { stackPane =>
val bgRect = new Rectangle {
@@ -256,17 +276,33 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit =
// TODO: Update FEN export to use GameContext instead of GameHistory and calculator helpers
showMessage("FEN export temporarily disabled during NCS-22 refactoring")
val fen = de.nowchess.io.fen.FenExporter.gameContextToFen(engine.context)
showCopyDialog("FEN Export", fen)
private def doFenImport(): Unit =
showMessage("FEN import temporarily disabled during NCS-22 refactoring")
showInputDialog("FEN Import", rows = 1).foreach { fen =>
de.nowchess.io.fen.FenParser.parseFen(fen) match
case Some(gameContext) =>
engine.loadPosition(gameContext)
case None =>
showMessage("⚠️ Invalid FEN string")
}
private def doPgnExport(): Unit =
showMessage("PGN export temporarily disabled during NCS-22 refactoring")
val pgn = de.nowchess.io.pgn.PgnExporter.exportGame(
Map("Event" -> "NowChess Game", "Date" -> "2026.04.04"),
exportableGameHistory()
)
showCopyDialog("PGN Export", pgn)
private def doPgnImport(): Unit =
showMessage("PGN import temporarily disabled during NCS-22 refactoring")
showInputDialog("PGN Import", rows = 5).foreach { pgn =>
engine.loadPgn(pgn) match
case Right(_) =>
showMessage("✓ PGN loaded successfully!")
case Left(err) =>
showMessage(s"⚠️ PGN Error: $err")
}
private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content)
@@ -298,3 +334,34 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
private def exportableGameHistory(): GameHistory =
val moveCommands = engine.commandHistory.collect { case moveCmd: MoveCommand => moveCmd }
val activeMoveCount = engine.context.moves.length
val historyMoves = moveCommands.take(activeMoveCount).flatMap: moveCmd =>
moveCmd.previousContext.flatMap: previousContext =>
moveCmd.moveResult.collect:
case MoveResult.Successful(_, captured) =>
val movingPiece = previousContext.board.pieceAt(moveCmd.from)
val pieceType = movingPiece.map(_.pieceType).getOrElse(PieceType.Pawn)
val castleSide = moveCmd.notation match
case "O-O" => Some("Kingside")
case "O-O-O" => Some("Queenside")
case _ => None
val promotionPiece = moveCmd.notation.split("=").lastOption.flatMap:
case "Q" => Some(PromotionPiece.Queen)
case "R" => Some(PromotionPiece.Rook)
case "B" => Some(PromotionPiece.Bishop)
case "N" => Some(PromotionPiece.Knight)
case _ => None
HistoryMove(
from = moveCmd.from,
to = moveCmd.to,
castleSide = castleSide,
promotionPiece = promotionPiece,
pieceType = pieceType,
isCapture = captured.isDefined
)
GameHistory(historyMoves.toList)
@@ -47,8 +47,27 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
case e: DrawClaimedEvent =>
boardView.updateBoard(e.context.board, e.context.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.")
case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: MoveRedoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
else
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("✓ PGN loaded successfully!")
boardView.updateUndoRedoButtons()
}
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
@@ -24,6 +24,18 @@ class TerminalUI(engine: GameEngine) extends Observer:
println(s"Captured: $cap on ${e.toSquare}")
printPrompt(e.context.turn)
case e: MoveUndoneEvent =>
println(s"Undo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: MoveRedoneEvent =>
println(s"Redo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: CheckDetectedEvent =>
println(s"${e.context.turn.label} is in check!")
@@ -57,6 +69,12 @@ class TerminalUI(engine: GameEngine) extends Observer:
case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule available! The game is a draw.")
case e: PgnLoadedEvent =>
println("PGN loaded successfully.")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
/** Start the terminal UI game loop. */
def start(): Unit =
// Register as observer