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 aff04eb..f7e20f4 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 @@ -38,9 +38,10 @@ class GameEngine( private implicit val ec: ExecutionContext = ExecutionContext.global // Synchronized accessors for current state - def board: Board = synchronized(currentContext.board) - def turn: Color = synchronized(currentContext.turn) - def context: GameContext = synchronized(currentContext) + def board: Board = synchronized(currentContext.board) + def turn: Color = synchronized(currentContext.turn) + def context: GameContext = synchronized(currentContext) + def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer) /** Check if undo is available. */ def canUndo: Boolean = synchronized(invoker.canUndo) @@ -67,21 +68,7 @@ class GameEngine( performRedo() case "draw" => - 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, - ), - ) + claimDraw() case "" => notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput)) @@ -195,6 +182,22 @@ class GameEngine( 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 * system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success. */ diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala index 74c240e..7dd09fb 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala @@ -9,6 +9,5 @@ final case class GameEntry( engine: GameEngine, white: PlayerInfo, black: PlayerInfo, - drawOfferedBy: Option[Color] = None, resigned: Boolean = false, ) diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index 356da6e..ae8241f 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -31,7 +31,7 @@ class GameResource(@Inject val registry: GameRegistry, @Inject val objectMapper: // ── mapping ────────────────────────────────────────────────────────────── private def statusOf(entry: GameEntry): String = - if entry.drawOfferedBy.isDefined then "drawOffered" + if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered" else val ctx = entry.engine.context ctx.result match @@ -104,7 +104,7 @@ class GameResource(@Inject val registry: GameRegistry, @Inject val objectMapper: val error = new AtomicReference[Option[String]](None) val obs = new Observer: def onGameEvent(e: GameEvent): Unit = e match - case InvalidMoveEvent(_, reason) => error.set(Some(reason)) + case InvalidMoveEvent(_, reason) => error.set(Some(reason.toString)) case _ => () engine.subscribe(obs) 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") action match case "offer" => - registry.update(entry.copy(drawOfferedBy = Some(entry.engine.context.turn))) + entry.engine.offerDraw(entry.engine.context.turn) ok(OkResponseDto()) case "accept" => - entry.drawOfferedBy match - case None => - 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()) + entry.engine.acceptDraw(entry.engine.context.turn) + ok(OkResponseDto()) case "decline" => - if entry.drawOfferedBy.isEmpty then throw BadRequestException("NO_DRAW_OFFER", "No draw offer to decline") - registry.update(entry.copy(drawOfferedBy = None)) + entry.engine.declineDraw(entry.engine.context.turn) ok(OkResponseDto()) case "claim" => - if entry.engine.context.halfMoveClock < 100 then - throw BadRequestException("CLAIM_NOT_AVAILABLE", "Fifty-move rule draw is not available") - entry.engine.applyDraw(DrawReason.FiftyMoveRule) + entry.engine.claimDraw() ok(OkResponseDto()) case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))