From 3d41bc23e544376647ac81c7850531aca8c26c83 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 19 Apr 2026 21:40:41 +0200 Subject: [PATCH 1/4] feat: NCS-40 Rework Draw System --- modules/core/build.gradle.kts | 5 + .../de/nowchess/chess/engine/GameEngine.scala | 59 +++++- .../de/nowchess/chess/observer/Observer.scala | 18 ++ .../engine/GameEngineDrawOfferTest.scala | 192 ++++++++++++++++++ .../chess/engine/GameEngineResignTest.scala | 66 ++++++ 5 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index 2946b0f..f72b4d1 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -63,3 +63,8 @@ tasks.test { tasks.reportScoverage { dependsOn(tasks.test) } + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 5c6c12e..91bc584 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -31,7 +31,9 @@ class GameEngine( else initialContext @SuppressWarnings(Array("DisableSyntax.var")) private var currentContext: GameContext = contextWithInitialBoard - private val invoker = new CommandInvoker() + @SuppressWarnings(Array("DisableSyntax.var")) + private var pendingDrawOffer: Option[Color] = None + private val invoker = new CommandInvoker() private implicit val ec: ExecutionContext = ExecutionContext.global @@ -137,6 +139,58 @@ class GameEngine( /** Redo the last undone move. */ def redo(): Unit = synchronized(performRedo()) + /** 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.")) + else + currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite))) + pendingDrawOffer = None + invoker.clear() + notifyObservers(ResignEvent(currentContext, color)) + } + + /** Offer a draw. */ + def offerDraw(color: Color): Unit = synchronized { + if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over.")) + else + pendingDrawOffer match + case Some(_) => + notifyObservers(InvalidMoveEvent(currentContext, "A draw offer is already pending.")) + case None => + pendingDrawOffer = Some(color) + notifyObservers(DrawOfferEvent(currentContext, color)) + } + + /** Accept a pending draw offer. */ + def acceptDraw(color: Color): Unit = synchronized { + if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over.")) + else + pendingDrawOffer match + case None => + notifyObservers(InvalidMoveEvent(currentContext, "No draw offer to accept.")) + case Some(offerer) if offerer == color => + notifyObservers(InvalidMoveEvent(currentContext, "Cannot accept your own draw offer.")) + case Some(_) => + currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement))) + pendingDrawOffer = None + invoker.clear() + notifyObservers(DrawEvent(currentContext, DrawReason.Agreement)) + } + + /** Decline a pending draw offer. */ + def declineDraw(color: Color): Unit = synchronized { + if currentContext.result.isDefined then notifyObservers(InvalidMoveEvent(currentContext, "Game is already over.")) + else + pendingDrawOffer match + case None => + notifyObservers(InvalidMoveEvent(currentContext, "No draw offer to decline.")) + case Some(offerer) if offerer == color => + notifyObservers(InvalidMoveEvent(currentContext, "Cannot decline your own draw offer.")) + case Some(_) => + pendingDrawOffer = None + notifyObservers(DrawOfferDeclinedEvent(currentContext, color)) + } + /** Load a game using the provided importer. If the imported context has moves, they are replayed through the command * system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success. */ @@ -145,6 +199,7 @@ class GameEngine( case Left(err) => Left(err) case Right(ctx) => replayGame(ctx).map { _ => + pendingDrawOffer = None notifyObservers(PgnLoadedEvent(currentContext)) } } @@ -186,6 +241,7 @@ class GameEngine( if newContext.moves.isEmpty then newContext.copy(initialBoard = newContext.board) else newContext currentContext = contextWithInitialBoard + pendingDrawOffer = None invoker.clear() notifyObservers(BoardResetEvent(currentContext)) } @@ -193,6 +249,7 @@ class GameEngine( /** Reset the board to initial position. */ def reset(): Unit = synchronized { currentContext = GameContext.initial + pendingDrawOffer = None invoker.clear() notifyObservers(BoardResetEvent(currentContext)) } diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 117742e..26e52cc 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -74,6 +74,24 @@ case class PgnLoadedEvent( context: GameContext, ) extends GameEvent +/** Fired when a player resigns. The opponent wins. */ +case class ResignEvent( + context: GameContext, + resignedColor: Color, +) extends GameEvent + +/** Fired when a player offers a draw. Waiting for opponent to accept or decline. */ +case class DrawOfferEvent( + context: GameContext, + offeredBy: Color, +) extends GameEvent + +/** Fired when the opponent declines a draw offer. */ +case class DrawOfferDeclinedEvent( + context: GameContext, + declinedBy: Color, +) extends GameEvent + /** Observer trait: implement to receive game state updates. */ trait Observer: def onGameEvent(event: GameEvent): Unit diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala new file mode 100644 index 0000000..1e4bf0b --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -0,0 +1,192 @@ +package de.nowchess.chess.engine + +import scala.collection.mutable +import de.nowchess.api.board.Color +import de.nowchess.api.game.{DrawReason, GameResult} +import de.nowchess.chess.observer.{ + DrawEvent, + DrawOfferDeclinedEvent, + DrawOfferEvent, + GameEvent, + InvalidMoveEvent, + Observer, +} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: + + test("White offers draw"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + + observer.events should have length 1 + observer.events.head match + case event: DrawOfferEvent => + event.offeredBy shouldBe Color.White + case other => + fail(s"Expected DrawOfferEvent, but got $other") + + test("Black accepts White's draw offer"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.acceptDraw(Color.Black) + + observer.events should have length 1 + observer.events.head match + case event: DrawEvent => + event.reason shouldBe DrawReason.Agreement + event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement)) + case other => + fail(s"Expected DrawEvent, but got $other") + + test("Black declines White's draw offer"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.declineDraw(Color.Black) + + observer.events should have length 1 + observer.events.head match + case event: DrawOfferDeclinedEvent => + event.declinedBy shouldBe Color.Black + case other => + fail(s"Expected DrawOfferDeclinedEvent, but got $other") + + test("Black offers draw"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.Black) + + observer.events should have length 1 + observer.events.head match + case event: DrawOfferEvent => + event.offeredBy shouldBe Color.Black + case other => + fail(s"Expected DrawOfferEvent, but got $other") + + test("White accepts Black's draw offer"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.Black) + observer.events.clear() + engine.acceptDraw(Color.White) + + observer.events should have length 1 + observer.events.head match + case event: DrawEvent => + event.reason shouldBe DrawReason.Agreement + event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement)) + case other => + fail(s"Expected DrawEvent, but got $other") + + test("Cannot accept draw when no offer pending"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.acceptDraw(Color.Black) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Cannot decline draw when no offer pending"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.declineDraw(Color.Black) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Cannot offer draw when game is already over"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + // End the game with checkmate + engine.processUserInput("f2f3") + engine.processUserInput("e7e5") + engine.processUserInput("g2g4") + observer.events.clear() + engine.processUserInput("d8h4") + + // Try to offer draw + observer.events.clear() + engine.offerDraw(Color.White) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Cannot accept your own draw offer"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.acceptDraw(Color.White) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Cannot decline your own draw offer"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.declineDraw(Color.White) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Cannot make second draw offer when one is already pending"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.offerDraw(Color.Black) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + + test("Draw offer is cleared when game ends by resignation"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.resign(Color.Black) + + // Try to accept the now-cleared draw offer + observer.events.clear() + engine.acceptDraw(Color.White) + + observer.events should have length 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + +private class DrawOfferMockObserver extends Observer: + val events = mutable.ListBuffer[GameEvent]() + + override def onGameEvent(event: GameEvent): Unit = + events += event diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala new file mode 100644 index 0000000..3b6098d --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala @@ -0,0 +1,66 @@ +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 org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameEngineResignTest extends AnyFunSuite with Matchers: + + test("White resigns"): + val engine = new GameEngine() + val observer = new ResignMockObserver() + engine.subscribe(observer) + + engine.resign(Color.White) + + observer.events should have length 1 + observer.events.head match + case event: ResignEvent => + event.resignedColor shouldBe Color.White + event.context.result shouldBe Some(GameResult.Win(Color.Black)) + case other => + fail(s"Expected ResignEvent, but got $other") + + test("Black resigns"): + val engine = new GameEngine() + val observer = new ResignMockObserver() + engine.subscribe(observer) + + engine.resign(Color.Black) + + observer.events should have length 1 + observer.events.head match + case event: ResignEvent => + event.resignedColor shouldBe Color.Black + event.context.result shouldBe Some(GameResult.Win(Color.White)) + case other => + fail(s"Expected ResignEvent, but got $other") + + test("Cannot resign when game is already over"): + val engine = new GameEngine() + val observer = new ResignMockObserver() + engine.subscribe(observer) + + // End the game with checkmate + engine.processUserInput("f2f3") + engine.processUserInput("e7e5") + engine.processUserInput("g2g4") + observer.events.clear() + engine.processUserInput("d8h4") + + // Try to resign + observer.events.clear() + engine.resign(Color.White) + + // Should get InvalidMoveEvent + observer.events.length shouldBe 1 + observer.events.head.getClass.getSimpleName shouldBe "InvalidMoveEvent" + +private class ResignMockObserver extends Observer: + val events = mutable.ListBuffer[GameEvent]() + + override def onGameEvent(event: GameEvent): Unit = + events += event -- 2.52.0 From a5beb3e1de877d045c56f70af5de70866207c75a Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 19 Apr 2026 22:04:55 +0200 Subject: [PATCH 2/4] feat: NCS-40 Rework Draw System --- .../de/nowchess/chess/engine/GameEngine.scala | 46 ++++++++++--------- .../chess/observer/InvalidMoveReason.scala | 21 +++++++++ .../de/nowchess/chess/observer/Observer.scala | 2 +- .../engine/GameEngineDrawOfferTest.scala | 43 ++++++++++++++--- .../engine/GameEngineIntegrationTest.scala | 6 +-- .../engine/GameEnginePromotionTest.scala | 17 +++++-- .../chess/engine/GameEngineResignTest.scala | 10 ++-- 7 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 91bc584..872098d 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -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)) diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala new file mode 100644 index 0000000..dce32ab --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala @@ -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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala index 26e52cc..56e4995 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala @@ -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. */ diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala index 1e4bf0b..177d7a2 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -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]() diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index 193170d..371f2dd 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -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"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index ffb64aa..4c86f17 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -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 } diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala index 3b6098d..75bd76f 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala @@ -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]() -- 2.52.0 From c29c1e64803dc5dfd33db78503b8f977bc603e3a Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 19 Apr 2026 22:17:12 +0200 Subject: [PATCH 3/4] feat: update draw offer test to include draw decline scenario --- .../engine/GameEngineDrawOfferTest.scala | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala index 177d7a2..2eef599 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -194,7 +194,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: 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 (accept)"): val engine = new GameEngine() val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -214,6 +214,26 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: case other => fail(s"Expected InvalidMoveEvent, but got $other") + test("Draw offer is cleared when game ends by resignation"): + val engine = new GameEngine() + val observer = new DrawOfferMockObserver() + engine.subscribe(observer) + + engine.offerDraw(Color.White) + observer.events.clear() + engine.resign(Color.Black) + + // Try to accept the now-cleared draw offer + observer.events.clear() + engine.declineDraw(Color.White) + + observer.events should have length 1 + 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]() -- 2.52.0 From c26acc93c752782be955d44174e0e04b44aa96c1 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 19 Apr 2026 22:18:35 +0200 Subject: [PATCH 4/4] feat: update draw offer test to include draw decline scenario --- .../de/nowchess/chess/engine/GameEngineDrawOfferTest.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala index 2eef599..f1c2c80 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -214,7 +214,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: 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 (decline)"): val engine = new GameEngine() val observer = new DrawOfferMockObserver() engine.subscribe(observer) -- 2.52.0