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