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]()