This commit is contained in:
@@ -63,3 +63,8 @@ tasks.test {
|
|||||||
tasks.reportScoverage {
|
tasks.reportScoverage {
|
||||||
dependsOn(tasks.test)
|
dependsOn(tasks.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.jar {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class GameEngine(
|
|||||||
else initialContext
|
else initialContext
|
||||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentContext: GameContext = contextWithInitialBoard
|
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
|
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||||
|
|
||||||
@@ -137,6 +139,58 @@ class GameEngine(
|
|||||||
/** Redo the last undone move. */
|
/** Redo the last undone move. */
|
||||||
def redo(): Unit = synchronized(performRedo())
|
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
|
/** 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.
|
* 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 Left(err) => Left(err)
|
||||||
case Right(ctx) =>
|
case Right(ctx) =>
|
||||||
replayGame(ctx).map { _ =>
|
replayGame(ctx).map { _ =>
|
||||||
|
pendingDrawOffer = None
|
||||||
notifyObservers(PgnLoadedEvent(currentContext))
|
notifyObservers(PgnLoadedEvent(currentContext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +241,7 @@ class GameEngine(
|
|||||||
if newContext.moves.isEmpty then newContext.copy(initialBoard = newContext.board)
|
if newContext.moves.isEmpty then newContext.copy(initialBoard = newContext.board)
|
||||||
else newContext
|
else newContext
|
||||||
currentContext = contextWithInitialBoard
|
currentContext = contextWithInitialBoard
|
||||||
|
pendingDrawOffer = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
@@ -193,6 +249,7 @@ class GameEngine(
|
|||||||
/** Reset the board to initial position. */
|
/** Reset the board to initial position. */
|
||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentContext = GameContext.initial
|
currentContext = GameContext.initial
|
||||||
|
pendingDrawOffer = None
|
||||||
invoker.clear()
|
invoker.clear()
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ case class PgnLoadedEvent(
|
|||||||
context: GameContext,
|
context: GameContext,
|
||||||
) extends GameEvent
|
) 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. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
trait Observer:
|
trait Observer:
|
||||||
def onGameEvent(event: GameEvent): Unit
|
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