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
@@ -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, GameResult}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
@@ -60,8 +60,9 @@ class GameEngine(
case "draw" =>
if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else
notifyObservers(
InvalidMoveEvent(
@@ -222,13 +223,17 @@ class GameEngine(
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isStalemate(currentContext) then
notifyObservers(StalemateEvent(currentContext))
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
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, GameResult}
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -22,6 +23,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
@@ -41,10 +43,11 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White
engine.context.result shouldBe Some(GameResult.Win(Color.White))
// ── 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 +77,12 @@ 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
engine.context.result shouldBe Some(GameResult.Draw(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 +111,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 +136,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 +189,10 @@ 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
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.FiftyMoveRule))
test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine()
@@ -192,3 +202,22 @@ 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
engine.context.result shouldBe Some(GameResult.Draw(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()