feat: NCS-40 Rework Draw System
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-19 21:40:41 +02:00
parent 2e4c7549b5
commit 3d41bc23e5
5 changed files with 339 additions and 1 deletions
+5
View File
@@ -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