feat: NCS-14 implemented insufficient moves rule (#30)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #30
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #30.
This commit is contained in:
2026-04-14 21:17:56 +02:00
committed by Janis
parent ec2ab2f365
commit b0399a4e48
160 changed files with 414 additions and 12042 deletions
@@ -1,6 +1,6 @@
package de.nowchess.ui.gui
import scala.compiletime.uninitialized
import java.util.concurrent.atomic.AtomicReference
import scalafx.Includes.*
import scalafx.application.Platform
import scalafx.geometry.{Insets, Pos}
@@ -36,13 +36,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
padding = Insets(10)
}
private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn
private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private val currentBoard = new AtomicReference[Board](engine.board)
private val currentTurn = new AtomicReference[Color](engine.turn)
private val selectedSquare = new AtomicReference[Option[Square]](None)
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized
private val undoButton: Button = 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
}
private val redoButton: Button = 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
}
// Initialize UI
initializeBoard()
@@ -77,23 +87,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
spacing = 10
alignment = Pos.Center
children = Seq(
{
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
}, {
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
},
undoButton,
redoButton,
new Button("Reset") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset()
@@ -160,7 +155,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
squareViews((rank, file)) = square
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
updateBoard(currentBoard, currentTurn)
updateBoard(currentBoard.get(), currentTurn.get())
private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0
@@ -183,42 +178,41 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
square
private def handleSquareClick(rank: Int, file: Int): Unit =
if engine.isPendingPromotion then return // Don't allow moves during promotion
if !engine.isPendingPromotion then
val clickedSquare = Square(File.values(file), Rank.values(rank))
val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare.get() match
case None =>
// First click - select piece if it belongs to current player
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
if piece.color == currentTurn.get() then
selectedSquare.set(Some(clickedSquare))
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
selectedSquare match
case None =>
// First click - select piece if it belongs to current player
currentBoard.pieceAt(clickedSquare).foreach { piece =>
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 =>
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) =>
// Second click - attempt move
if clickedSquare == fromSquare then
// Deselect
selectedSquare = None
updateBoard(currentBoard, currentTurn)
else
// Try to move
val moveStr = s"${fromSquare}$clickedSquare"
engine.processUserInput(moveStr)
selectedSquare = None
case Some(fromSquare) =>
// Second click - attempt move
if clickedSquare == fromSquare then
// Deselect
selectedSquare.set(None)
updateBoard(currentBoard.get(), currentTurn.get())
else
// Try to move
val moveStr = s"${fromSquare}$clickedSquare"
engine.processUserInput(moveStr)
selectedSquare.set(None)
def updateBoard(board: Board, turn: Color): Unit =
currentBoard = board
currentTurn = turn
selectedSquare = None
currentBoard.set(board)
currentTurn.set(turn)
selectedSquare.set(None)
// Update all squares
for
@@ -240,9 +234,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square)
val children = pieceOption match
val children: Seq[scalafx.scene.Node] = pieceOption match
case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
case None =>
Seq(bgRect)
@@ -252,8 +246,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateUndoRedoButtons()
def updateUndoRedoButtons(): Unit =
if undoButton != null then undoButton.disable = !engine.canUndo
if redoButton != null then redoButton.disable = !engine.canRedo
undoButton.disable = !engine.canUndo
redoButton.disable = !engine.canRedo
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
squareViews.get((rank, file)).foreach { stackPane =>
@@ -266,13 +260,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
}
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.pieceAt(square)
val pieceOption = currentBoard.get().pieceAt(square)
stackPane.children = pieceOption match
stackPane.children = (pieceOption match
case Some(piece) =>
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
case None =>
Seq(bgRect)
Seq(bgRect)): Seq[scalafx.scene.Node]
}
def showMessage(msg: String): Unit =
@@ -315,8 +309,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
val selectedFile = fileChooser.showSaveDialog(stage)
if selectedFile != null then
Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile =>
val result = FileSystemGameService.saveGameToFile(
engine.context,
selectedFile.toPath,
@@ -325,6 +318,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
result match
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
}
private def doJsonImport(): Unit =
val fileChooser = new FileChooser {
@@ -333,8 +327,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
val selectedFile = fileChooser.showOpenDialog(stage)
if selectedFile != null then
Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile =>
val result = FileSystemGameService.loadGameFromFile(
selectedFile.toPath,
JsonParser,
@@ -345,6 +338,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
case Left(err) =>
showMessage(s"⚠️ Error: $err")
}
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context)
@@ -368,7 +362,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
area.setPrefRowCount(4)
val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
alert.setTitle(title)
alert.setHeaderText(null)
alert.setHeaderText("")
alert.getDialogPane.setContent(area)
alert.getDialogPane.setPrefWidth(500)
alert.initOwner(stage.delegate)
@@ -386,8 +380,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
javafx.scene.control.ButtonType.CANCEL,
)
dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else null
if bt == javafx.scene.control.ButtonType.OK then area.getText else ""
}
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
if result.isPresent && result.get.nonEmpty then Some(result.get) else None
@@ -31,8 +31,7 @@ class ChessGUIApp extends JFXApplication:
root = boardView
// Load CSS if available
try {
val cssUrl = getClass.getResource("/styles.css")
if cssUrl != null then stylesheets.add(cssUrl.toExternalForm)
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
} catch {
case _: Exception => // CSS is optional
}
@@ -46,12 +45,12 @@ class ChessGUIApp extends JFXApplication:
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
object ChessGUILauncher:
@volatile private var engine: GameEngine = scala.compiletime.uninitialized
private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]()
def getEngine: GameEngine = engine
def getEngine: GameEngine = engineRef.get()
def launch(eng: GameEngine): Unit =
engine = eng
engineRef.set(eng)
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
guiThread.setDaemon(false)
guiThread.setName("ScalaFX-GUI-Thread")
@@ -5,6 +5,7 @@ import scalafx.scene.control.Alert
import scalafx.scene.control.Alert.AlertType
import de.nowchess.chess.observer.{GameEvent, Observer, *}
import de.nowchess.api.board.Board
import de.nowchess.api.game.DrawReason
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
* All UI updates must be done on the JavaFX Application Thread.
@@ -29,9 +30,14 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
case e: StalemateEvent =>
case e: DrawEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
val msg = e.reason match
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.Agreement => "Draw by agreement."
showAlert(AlertType.Information, "Game Over", msg)
case e: InvalidMoveEvent =>
boardView.showMessage(s"⚠️ ${e.reason}")
@@ -43,12 +49,8 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
case e: PromotionRequiredEvent =>
boardView.showPromotionDialog(e.from, e.to)
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.")
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
@@ -6,26 +6,24 @@ import de.nowchess.api.board.{Color, Piece, PieceType}
/** Utility object for loading chess piece sprites. */
object PieceSprites:
private val spriteCache = scala.collection.mutable.Map[String, Image]()
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
/** Load a piece sprite image from resources. Sprites are cached for performance.
*/
def loadPieceImage(piece: Piece, size: Double = 60.0): ImageView =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
val image = spriteCache.getOrElseUpdate(key, loadImage(key))
new ImageView(image) {
fitWidth = size
fitHeight = size
preserveRatio = true
smooth = true
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
new ImageView(image) {
fitWidth = size
fitHeight = size
preserveRatio = true
smooth = true
}
}
private def loadImage(key: String): Image =
val path = s"/sprites/pieces/$key.png"
val stream = getClass.getResourceAsStream(path)
if stream == null then throw new RuntimeException(s"Could not load sprite: $path")
new Image(stream)
private def loadImage(key: String): Option[Image] =
val path = s"/sprites/pieces/$key.png"
Option(getClass.getResourceAsStream(path)).map(new Image(_))
/** Get square colors for the board using theme. */
object SquareColors:
@@ -1,7 +1,9 @@
package de.nowchess.ui.terminal
import java.util.concurrent.atomic.AtomicBoolean
import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer
@@ -10,8 +12,8 @@ import de.nowchess.ui.utils.Renderer
* I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private var running = true
private var awaitingPromotion = false
private val running = new AtomicBoolean(true)
private val awaitingPromotion = new AtomicBoolean(false)
/** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit =
@@ -43,8 +45,13 @@ class TerminalUI(engine: GameEngine) extends Observer:
println()
print(Renderer.render(e.context.board))
case e: StalemateEvent =>
println("Stalemate! The game is a draw.")
case e: DrawEvent =>
val msg = e.reason match
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.Agreement => "Draw by agreement."
println(msg)
println()
print(Renderer.render(e.context.board))
@@ -59,13 +66,9 @@ 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))
awaitingPromotion.set(true)
case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule available! The game is a draw.")
println("50-move rule is now available — type 'draw' to claim.")
case e: PgnLoadedEvent =>
println("PGN loaded successfully.")
@@ -84,22 +87,22 @@ class TerminalUI(engine: GameEngine) extends Observer:
printPrompt(engine.turn)
// Game loop
while running do
while running.get() do
val input = Option(StdIn.readLine()).getOrElse("quit").trim
synchronized {
if awaitingPromotion then
if awaitingPromotion.get() then
input.toLowerCase match
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
case "q" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Queen)
case "r" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Rook)
case "b" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Bishop)
case "n" => awaitingPromotion.set(false); engine.completePromotion(PromotionPiece.Knight)
case _ =>
println("Invalid choice. Enter q, r, b, or n.")
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
else
input.toLowerCase match
case "quit" | "q" =>
running = false
running.set(false)
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)