feat: Rework draw handling in GameEngine and GameResource

This commit is contained in:
2026-04-19 22:56:57 +02:00
committed by Janis
parent 3849885c66
commit fa828bf453
3 changed files with 28 additions and 36 deletions
@@ -38,9 +38,10 @@ class GameEngine(
private implicit val ec: ExecutionContext = ExecutionContext.global private implicit val ec: ExecutionContext = ExecutionContext.global
// Synchronized accessors for current state // Synchronized accessors for current state
def board: Board = synchronized(currentContext.board) def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn) def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext) def context: GameContext = synchronized(currentContext)
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
/** Check if undo is available. */ /** Check if undo is available. */
def canUndo: Boolean = synchronized(invoker.canUndo) def canUndo: Boolean = synchronized(invoker.canUndo)
@@ -67,21 +68,7 @@ class GameEngine(
performRedo() performRedo()
case "draw" => case "draw" =>
if currentContext.halfMoveClock >= 100 then claimDraw()
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else
notifyObservers(
InvalidMoveEvent(
currentContext,
InvalidMoveReason.DrawCannotBeClaimed,
),
)
case "" => case "" =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput)) notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput))
@@ -195,6 +182,22 @@ class GameEngine(
notifyObservers(DrawOfferDeclinedEvent(currentContext, color)) notifyObservers(DrawOfferDeclinedEvent(currentContext, color))
} }
/** Claim a draw by fifty-move rule or threefold repetition. */
def claimDraw(): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
}
/** 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.
*/ */
@@ -9,6 +9,5 @@ final case class GameEntry(
engine: GameEngine, engine: GameEngine,
white: PlayerInfo, white: PlayerInfo,
black: PlayerInfo, black: PlayerInfo,
drawOfferedBy: Option[Color] = None,
resigned: Boolean = false, resigned: Boolean = false,
) )
@@ -31,7 +31,7 @@ class GameResource(@Inject val registry: GameRegistry, @Inject val objectMapper:
// ── mapping ────────────────────────────────────────────────────────────── // ── mapping ──────────────────────────────────────────────────────────────
private def statusOf(entry: GameEntry): String = private def statusOf(entry: GameEntry): String =
if entry.drawOfferedBy.isDefined then "drawOffered" if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
else else
val ctx = entry.engine.context val ctx = entry.engine.context
ctx.result match ctx.result match
@@ -104,7 +104,7 @@ class GameResource(@Inject val registry: GameRegistry, @Inject val objectMapper:
val error = new AtomicReference[Option[String]](None) val error = new AtomicReference[Option[String]](None)
val obs = new Observer: val obs = new Observer:
def onGameEvent(e: GameEvent): Unit = e match def onGameEvent(e: GameEvent): Unit = e match
case InvalidMoveEvent(_, reason) => error.set(Some(reason)) case InvalidMoveEvent(_, reason) => error.set(Some(reason.toString))
case _ => () case _ => ()
engine.subscribe(obs) engine.subscribe(obs)
engine.processUserInput(uci) engine.processUserInput(uci)
@@ -230,26 +230,16 @@ class GameResource(@Inject val registry: GameRegistry, @Inject val objectMapper:
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over") if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
action match action match
case "offer" => case "offer" =>
registry.update(entry.copy(drawOfferedBy = Some(entry.engine.context.turn))) entry.engine.offerDraw(entry.engine.context.turn)
ok(OkResponseDto()) ok(OkResponseDto())
case "accept" => case "accept" =>
entry.drawOfferedBy match entry.engine.acceptDraw(entry.engine.context.turn)
case None => ok(OkResponseDto())
throw BadRequestException("NO_DRAW_OFFER", "No draw offer to accept")
case Some(offerer) if offerer == entry.engine.context.turn =>
throw BadRequestException("CANNOT_ACCEPT_OWN_OFFER", "Cannot accept your own draw offer")
case _ =>
entry.engine.applyDraw(DrawReason.Agreement)
registry.update(entry.copy(drawOfferedBy = None))
ok(OkResponseDto())
case "decline" => case "decline" =>
if entry.drawOfferedBy.isEmpty then throw BadRequestException("NO_DRAW_OFFER", "No draw offer to decline") entry.engine.declineDraw(entry.engine.context.turn)
registry.update(entry.copy(drawOfferedBy = None))
ok(OkResponseDto()) ok(OkResponseDto())
case "claim" => case "claim" =>
if entry.engine.context.halfMoveClock < 100 then entry.engine.claimDraw()
throw BadRequestException("CLAIM_NOT_AVAILABLE", "Fifty-move rule draw is not available")
entry.engine.applyDraw(DrawReason.FiftyMoveRule)
ok(OkResponseDto()) ok(OkResponseDto())
case _ => case _ =>
throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action")) throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))