This commit is contained in:
@@ -63,3 +63,8 @@ tasks.test {
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user