feat: refactor ChessBoardView to use AtomicReference for state management
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-14 15:19:09 +02:00
parent ec2ab2f365
commit 50ecc0fd4d
154 changed files with 256 additions and 12042 deletions
@@ -0,0 +1,8 @@
package de.nowchess.api.game
/** Reason why a game ended in a draw. */
enum DrawReason:
case Stalemate
case InsufficientMaterial
case FiftyMoveRule
case Agreement
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.api.game.{DrawReason, GameContext}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
@@ -61,7 +61,7 @@ class GameEngine(
case "draw" =>
if currentContext.halfMoveClock >= 100 then
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else
notifyObservers(
InvalidMoveEvent(
@@ -224,11 +224,12 @@ class GameEngine(
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isStalemate(currentContext) then
notifyObservers(StalemateEvent(currentContext))
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
@@ -1,7 +1,7 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.game.{DrawReason, GameContext}
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
*/
@@ -27,9 +27,10 @@ case class CheckmateEvent(
winner: Color,
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
/** Fired when the game ends in a draw. */
case class DrawEvent(
context: GameContext,
reason: DrawReason,
) extends GameEvent
/** Fired when a move is invalid. */
@@ -55,11 +56,6 @@ case class FiftyMoveRuleAvailableEvent(
context: GameContext,
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
context: GameContext,
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent(
context: GameContext,
@@ -14,12 +14,14 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
override def undo(): Boolean = false
override def description: String = "Failing command"
private case class ConditionalFailCommand(
var shouldFailOnUndo: Boolean = false,
var shouldFailOnExecute: Boolean = false,
private class ConditionalFailCommand(
initialShouldFailOnUndo: Boolean = false,
initialShouldFailOnExecute: Boolean = false,
) extends Command:
override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
override def execute(): Boolean = !shouldFailOnExecute.get()
override def undo(): Boolean = !shouldFailOnUndo.get()
override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
@@ -66,7 +68,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true
invoker.undo() shouldBe false
@@ -102,7 +104,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
invoker.execute(redoFailCmd)
invoker.undo()
invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute = true
redoFailCmd.shouldFailOnExecute.set(true)
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
@@ -8,7 +8,6 @@ class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand properties and behavior"):
val cmd = QuitCommand()
cmd shouldNot be(null)
cmd.execute() shouldBe true
cmd.undo() shouldBe false
cmd.description shouldBe "Quit game"
@@ -31,7 +31,7 @@ object EngineTestHelpers:
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
_events.exists(ct.runtimeClass.isInstance(_))
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
_events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
_events.collectFirst { case e: T => e }
override def onGameEvent(event: GameEvent): Unit =
_events += event
@@ -1,8 +1,9 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
import de.nowchess.api.board.Color
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, DrawEvent, GameEvent, Observer}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -25,13 +26,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent]
val event = observer.events.last.asInstanceOf[CheckmateEvent]
val event = observer.events.collectFirst { case e: CheckmateEvent => e }.get
event.winner shouldBe Color.Black
// Board should be reset after checkmate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
@@ -86,12 +83,9 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
observer.events.clear()
engine.processUserInput(moves.last)
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
stalemateEvents.size shouldBe 1
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
val drawEvents = observer.events.collect { case e: DrawEvent => e }
drawEvents.size shouldBe 1
drawEvents.head.reason shouldBe DrawReason.Stalemate
private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -38,7 +38,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.processUserInput("undo")
engine.processUserInput("redo")
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
events.count { case _: InvalidMoveEvent => true; case _ => false } should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine()
@@ -69,7 +69,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine()
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White castles queenside: e1c1
engine.processUserInput("e1c1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
events.clear()
engine.undo()
@@ -68,7 +68,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
// Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
@@ -84,7 +84,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── Bishop underpromotion notation (line 230) ──────────────────────
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
// Extra white pawn on h2 ensures K+B+P vs K — sufficient material, so draw is not triggered
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k6P/7K").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
@@ -105,8 +106,8 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// ── King normal move notation (line 246) ───────────────────────────
test("undo after king move emits MoveUndoneEvent with K notation"):
// White king on e1, no castling rights, black king far away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
// White king on e1, white rook on h1 — K+R vs K ensures sufficient material after the king move
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K2R").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
@@ -117,7 +118,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// King moves e1 -> f1
engine.processUserInput("e1f1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
events.clear()
engine.undo()
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -44,7 +45,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Stalemate ───────────────────────────────────────────────────
test("stalemate ends game with StalemateEvent"):
test("stalemate ends game with DrawEvent(Stalemate)"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -74,9 +75,11 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("c8e6")
observer.hasEvent[StalemateEvent] shouldBe true
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.Stalemate
test("stalemate when king has no moves and no pieces"):
test("stalemate board is not reset after draw"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -105,8 +108,8 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
moves.foreach(engine.processUserInput)
observer.hasEvent[StalemateEvent] shouldBe true
engine.turn shouldBe Color.White
observer.hasEvent[DrawEvent] shouldBe true
engine.turn shouldBe Color.Black
// ── Check detection ────────────────────────────────────────────
@@ -130,7 +133,8 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/4K3 w - - 0 1")
// White has K+N+Q so the position is not insufficient material after Nd4f5
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/3QK3 w - - 0 1")
observer.clear()
engine.processUserInput("d4f5")
@@ -182,7 +186,9 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw")
observer.hasEvent[DrawClaimedEvent] shouldBe true
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.FiftyMoveRule
test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine()
@@ -192,3 +198,21 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true
// ── Insufficient material ──────────────────────────────────────────
test("insufficient material fires DrawEvent(InsufficientMaterial) after capture"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// White Bishop d4 captures Black Rook g7, leaving K+B vs K (insufficient material).
// Black king on g8 can still move (f7/h7 not controlled), so it is not stalemate.
EngineTestHelpers.loadFen(engine, "6k1/6r1/8/8/3B4/8/8/K7 w - - 0 1")
observer.clear()
engine.processUserInput("d4g7")
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.InsufficientMaterial
@@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8")
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be(true)
events.exists { case _: PromotionRequiredEvent => true; case _ => false } should be(true)
events.collect { case e: PromotionRequiredEvent => e }.head.from should be(sq(File.E, Rank.R7))
}
@@ -60,7 +60,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
engine.board.pieceAt(sq(File.E, Rank.R7)) should be(None)
engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.exists { case _: MoveExecutedEvent => true; case _ => false } should be(true)
}
test("completePromotion with rook underpromotion") {
@@ -80,7 +80,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
engine.isPendingPromotion should be(false)
}
@@ -92,7 +92,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true)
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(true)
}
test("completePromotion results in Moved when promotion doesn't give check") {
@@ -105,8 +105,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
events.collect { case e: MoveExecutedEvent => e } should not be empty
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
}
test("completePromotion results in Checkmate when promotion delivers checkmate") {
@@ -118,7 +118,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
events.exists { case _: CheckmateEvent => true; case _ => false } should be(true)
}
test("completePromotion results in Stalemate when promotion creates stalemate") {
@@ -130,7 +130,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
events.exists { case _: DrawEvent => true; case _ => false } should be(true)
}
test("completePromotion with black pawn promotion results in Moved") {
@@ -143,8 +143,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.isPendingPromotion should be(false)
engine.board.pieceAt(sq(File.E, Rank.R1)) should be(Some(Piece(Color.Black, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
events.collect { case e: MoveExecutedEvent => e } should not be empty
events.exists { case _: CheckDetectedEvent => true; case _ => false } should be(false)
}
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
@@ -191,7 +191,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
events.exists { case _: InvalidMoveEvent => true; case _ => false } should be(true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include("Error completing promotion")
}
@@ -122,7 +122,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
engine.processUserInput("draw")
observer.hasEvent[DrawClaimedEvent] shouldBe true
observer.hasEvent[DrawEvent] shouldBe true
// Initial position has no draw available
observer.clear()
@@ -175,7 +175,8 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/8/8 w - - 0 1")
// White rook on h2 keeps material sufficient (K+B+R vs K) after bishop promotion
EngineTestHelpers.loadFen(engine, "8/4P3/4k3/8/8/8/7R/7K w - - 0 1")
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Bishop)
observer.clear()
@@ -122,7 +122,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val context = GameContext.initial
val faultyExporter = new GameContextExport {
def exportGameContext(c: GameContext): String =
throw new RuntimeException("Export failed")
throw new RuntimeException("Export failed") // scalafix:ok DisableSyntax.throw
}
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
@@ -375,19 +375,25 @@ object DefaultRules extends RuleSet:
val blackKingsideRook = Square(File.H, Rank.R8)
val blackQueensideRook = Square(File.A, Rank.R8)
var r = rights
if isKingMove then r = r.revokeColor(color)
else if isRookMove then
if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
val afterRookMove =
if !isRookMove then afterKingMove
else
move.from match
case `whiteKingsideRook` => afterKingMove.revokeKingSide(Color.White)
case `whiteQueensideRook` => afterKingMove.revokeQueenSide(Color.White)
case `blackKingsideRook` => afterKingMove.revokeKingSide(Color.Black)
case `blackQueensideRook` => afterKingMove.revokeQueenSide(Color.Black)
case _ => afterKingMove
// Also revoke if a rook is captured
if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White)
if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White)
if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black)
if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black)
r
move.to match
case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White)
case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White)
case `blackKingsideRook` => afterRookMove.revokeKingSide(Color.Black)
case `blackQueensideRook` => afterRookMove.revokeQueenSide(Color.Black)
case _ => afterRookMove
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
val piece = board.pieceAt(move.from)
@@ -401,13 +407,14 @@ object DefaultRules extends RuleSet:
// ── Insufficient material ──────────────────────────────────────────
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
private def insufficientMaterial(board: Board): Boolean =
val pieces = board.pieces.values.toList.filter(_.pieceType != PieceType.King)
pieces match
case Nil => true
case List(p) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case List(p1, p2)
if p1.pieceType == PieceType.Bishop && p2.pieceType == PieceType.Bishop
&& p1.color != p2.color =>
true
val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King }
nonKings match
case Nil => true
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
// All non-king pieces are bishops: draw only if they all share the same square color
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1
case _ => false
@@ -40,6 +40,11 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns true for king and knight versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/4KN2 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns false for king and rook versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
@@ -206,11 +211,30 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
test("isInsufficientMaterial returns true for opposite color bishops only"):
test("isInsufficientMaterial returns true for two same-square-color bishops (one each side)"):
// White bishop d1 (dark), black bishop g2 (dark) — same square color → draw
val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns false for two different-square-color bishops (one each side)"):
// White bishop d1 (dark), black bishop d2 (light) — different square colors → not a draw
val context = contextFromFen("8/8/8/4k3/8/8/3b4/3BK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe false
test("isInsufficientMaterial returns true for two same-color bishops vs lone king"):
// White bishops on c1 (light) and e3 (light), black king only → draw
val context = contextFromFen("4k3/8/8/8/8/4B3/8/2B1K3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns false for bishop and knight versus king"):
// K+B+N vs K is sufficient material
val context = contextFromFen("4k3/8/8/8/8/8/8/3BKN2 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe false
test("candidateMoves for rook includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
@@ -32,7 +32,7 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
val moves = rules.allLegalMoves(context)
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && m.moveType.isInstanceOf[MoveType.Normal])
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && (m.moveType match { case _: MoveType.Normal => true; case _ => false }))
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
test("pawn cannot move backward"):
@@ -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)