feat: NCS-40 Rework Draw System
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-19 22:04:55 +02:00
parent 3d41bc23e5
commit a5beb3e1de
7 changed files with 106 additions and 39 deletions
@@ -79,12 +79,12 @@ class GameEngine(
notifyObservers(
InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.",
InvalidMoveReason.DrawCannotBeClaimed,
),
)
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput))
case moveInput =>
Parser.parseMove(moveInput) match
@@ -92,7 +92,7 @@ class GameEngine(
notifyObservers(
InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
InvalidMoveReason.InvalidMoveFormat,
),
)
case Some((from, to, promotionPiece: Option[PromotionPiece])) =>
@@ -102,26 +102,26 @@ class GameEngine(
private def handleParsedMove(from: Square, to: Square, promotionPiece: Option[PromotionPiece]): Unit =
currentContext.board.pieceAt(from) match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoSourcePiece))
case Some(piece) if piece.color != currentContext.turn =>
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NotYourPiece))
case Some(piece) =>
val legal = ruleSet.legalMoves(currentContext)(from)
// Find all legal moves going to `to`
val candidates = legal.filter(_.to == to)
candidates match
case Nil =>
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.IllegalMove))
case _ if isPromotionMove(piece, to) =>
if promotionPiece.isEmpty then
notifyObservers(
InvalidMoveEvent(currentContext, "Promotion piece required: append q, r, b, or n to the move."),
InvalidMoveEvent(currentContext, InvalidMoveReason.PromotionPieceRequired),
)
else
candidates.find(_.moveType == MoveType.Promotion(promotionPiece.get)) match
case None =>
notifyObservers(
InvalidMoveEvent(currentContext, "Error completing promotion: no matching legal move."),
InvalidMoveEvent(currentContext, InvalidMoveReason.PromotionPieceInvalid),
)
case Some(move) => executeMove(move)
case move :: _ =>
@@ -141,7 +141,8 @@ class GameEngine(
/** Resign from the game. The opponent wins. */
def resign(color: Color): Unit = synchronized {
if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over."))
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite)))
pendingDrawOffer = None
@@ -151,11 +152,12 @@ class GameEngine(
/** Offer a draw. */
def offerDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over."))
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case Some(_) =>
notifyObservers(InvalidMoveEvent(currentContext, "A draw offer is already pending."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawOfferPending))
case None =>
pendingDrawOffer = Some(color)
notifyObservers(DrawOfferEvent(currentContext, color))
@@ -163,13 +165,14 @@ class GameEngine(
/** Accept a pending draw offer. */
def acceptDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over."))
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No draw offer to accept."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoDrawOfferToAccept))
case Some(offerer) if offerer == color =>
notifyObservers(InvalidMoveEvent(currentContext, "Cannot accept your own draw offer."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotAcceptOwnDrawOffer))
case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None
@@ -179,13 +182,14 @@ class GameEngine(
/** Decline a pending draw offer. */
def declineDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over."))
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No draw offer to decline."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoDrawOfferToDecline))
case Some(offerer) if offerer == color =>
notifyObservers(InvalidMoveEvent(currentContext, "Cannot decline your own draw offer."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotDeclineOwnDrawOffer))
case Some(_) =>
pendingDrawOffer = None
notifyObservers(DrawOfferDeclinedEvent(currentContext, color))
@@ -380,9 +384,9 @@ class GameEngine(
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) => executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, "Bot move has invalid source square"))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
}
private def handleBotNoMove(): Unit =
@@ -401,7 +405,7 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
private def performRedo(): Unit =
if invoker.canRedo then
@@ -421,4 +425,4 @@ class GameEngine(
capturedDesc,
),
)
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
@@ -0,0 +1,21 @@
package de.nowchess.chess.observer
enum InvalidMoveReason:
case GameAlreadyOver
case NoSourcePiece
case NotYourPiece
case IllegalMove
case PromotionPieceRequired
case PromotionPieceInvalid
case InvalidMoveFormat
case EmptyInput
case DrawCannotBeClaimed
case NothingToUndo
case NothingToRedo
case BotMoveIllegal
case BotMoveInvalidSource
case DrawOfferPending
case NoDrawOfferToAccept
case CannotAcceptOwnDrawOffer
case NoDrawOfferToDecline
case CannotDeclineOwnDrawOffer
@@ -36,7 +36,7 @@ case class DrawEvent(
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
context: GameContext,
reason: String,
reason: InvalidMoveReason,
) extends GameEvent
/** Fired when the board is reset. */
@@ -9,6 +9,7 @@ import de.nowchess.chess.observer.{
DrawOfferEvent,
GameEvent,
InvalidMoveEvent,
InvalidMoveReason,
Observer,
}
import org.scalatest.funsuite.AnyFunSuite
@@ -102,7 +103,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.acceptDraw(Color.Black)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.NoDrawOfferToAccept
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline draw when no offer pending"):
val engine = new GameEngine()
@@ -112,7 +117,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.declineDraw(Color.Black)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.NoDrawOfferToDecline
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot offer draw when game is already over"):
val engine = new GameEngine()
@@ -131,7 +140,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot accept your own draw offer"):
val engine = new GameEngine()
@@ -143,7 +156,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.acceptDraw(Color.White)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.CannotAcceptOwnDrawOffer
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline your own draw offer"):
val engine = new GameEngine()
@@ -155,7 +172,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.declineDraw(Color.White)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.CannotDeclineOwnDrawOffer
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot make second draw offer when one is already pending"):
val engine = new GameEngine()
@@ -167,7 +188,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.Black)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.DrawOfferPending
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Draw offer is cleared when game ends by resignation"):
val engine = new GameEngine()
@@ -183,7 +208,11 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.acceptDraw(Color.White)
observer.events should have length 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
private class DrawOfferMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -50,8 +50,8 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.processUserInput("e2e5")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
case _ => false
case InvalidMoveEvent(_, InvalidMoveReason.IllegalMove) => true
case _ => false
} shouldBe true
test("loadGame returns Left when importer fails"):
@@ -4,7 +4,16 @@ import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square
import de.nowchess.api.game.{DrawReason, GameContext}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.*
import de.nowchess.chess.observer.{
CheckDetectedEvent,
CheckmateEvent,
DrawEvent,
GameEvent,
InvalidMoveEvent,
InvalidMoveReason,
MoveExecutedEvent,
Observer,
}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
@@ -30,8 +39,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
case _ => false
case InvalidMoveEvent(_, InvalidMoveReason.PromotionPieceRequired) => true
case _ => false
} should be(true)
}
@@ -198,5 +207,5 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
case _ => false
} should be(true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include("Error completing promotion")
invalidEvt.reason shouldBe InvalidMoveReason.PromotionPieceInvalid
}
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameResult
import de.nowchess.chess.observer.{GameEvent, Observer, ResignEvent}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -55,9 +55,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
observer.events.clear()
engine.resign(Color.White)
// Should get InvalidMoveEvent
// Should get InvalidMoveEvent with GameAlreadyOver reason
observer.events.length shouldBe 1
observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent"
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
private class ResignMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()