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