feat: refactor ChessBoardView to use AtomicReference for state management
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -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,
|
||||
|
||||
+9
-7
@@ -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
|
||||
|
||||
+7
-13
@@ -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]()
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
-11
@@ -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()
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+25
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user