perf(core): reduce inter-service HTTP calls from 11 to 4 per move

- Add `postMoveStatus` batch method to `RuleSet` trait (default impl composes
  individual calls; `RuleSetRestAdapter` overrides with single HTTP round-trip)
- Collapse 5 sequential rule checks in `GameEngine.executeMove` into one
  `postMoveStatus` call
- Add `POST /api/rules/post-move-status` endpoint to rule-service
- Add `exportCombined` to `IoServiceClient` and `POST /io/export/combined`
  endpoint to io-service, replacing two separate FEN/PGN HTTP calls
- Fix `statusOf` to pattern-match on `WinReason` from `ctx.result` instead
  of making a redundant `isCheckmate` HTTP call
- Remove duplicate `legalMoves` pre-validation in `GameResource.makeMove`;
  engine already validates and fires `InvalidMoveEvent`
- Add `scalafix:off` guards for pre-existing `var`/`return` usage in
  `DefaultRules` hot-path code
- Apply spotless formatting to previously unformatted files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 20:39:01 +02:00
parent 3706ece936
commit 9a39cd6916
26 changed files with 373 additions and 188 deletions
@@ -4,7 +4,7 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.api.rules.RuleSet
import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
@@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet:
def applyMove(ctx: GameContext)(move: Move): GameContext =
client.applyMove(RuleMoveRequest(ctx, move))
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
client.postMoveStatus(ctx)
@@ -6,6 +6,8 @@ import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CombinedExportResponse(fen: String, pgn: String)
@Path("/io")
@RegisterRestClient(configKey = "io-service")
trait IoServiceClient:
@@ -33,3 +35,9 @@ trait IoServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array("application/x-chess-pgn"))
def exportPgn(ctx: GameContext): String
@POST
@Path("/export/combined")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def exportCombined(ctx: GameContext): CombinedExportResponse
@@ -2,6 +2,7 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.PostMoveStatus
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@@ -72,3 +73,9 @@ trait RuleServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: RuleMoveRequest): GameContext
@POST
@Path("/post-move-status")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def postMoveStatus(ctx: GameContext): PostMoveStatus
@@ -2,7 +2,19 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{BotParticipant, ClockState, CorrespondenceClockState, DrawReason, GameContext, GameResult, Human, LiveClockState, Participant, TimeControl, WinReason}
import de.nowchess.api.game.{
BotParticipant,
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
Human,
LiveClockState,
Participant,
TimeControl,
WinReason,
}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
@@ -378,28 +390,28 @@ class GameEngine(
),
)
val status = ruleSet.postMoveStatus(currentContext)
if currentContext.result.isEmpty then
if ruleSet.isCheckmate(currentContext) then
if status.isCheckmate then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
cancelScheduled()
notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear()
else if ruleSet.isStalemate(currentContext) then
else if status.isStalemate then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then
else if status.isInsufficientMaterial then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
invoker.clear()
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if ruleSet.isThreefoldRepetition(currentContext) then
notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
else requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
@@ -3,7 +3,16 @@ package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.dto.*
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameContext, GameMode, GameResult, LiveClockState, TimeControl}
import de.nowchess.api.game.{
CorrespondenceClockState,
DrawReason,
GameContext,
GameMode,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import java.time.Instant
@@ -56,7 +65,8 @@ class GameResource:
entry.mode match
case GameMode.Open => entry.engine.context.turn
case GameMode.Authenticated =>
val subject = Option(jwt).flatMap(j => Option(j.getSubject))
val subject = Option(jwt)
.flatMap(j => Option(j.getSubject))
.getOrElse(throw ForbiddenException("Authentication required"))
if entry.white.id.value == subject then Color.White
else if entry.black.id.value == subject then Color.Black
@@ -65,8 +75,7 @@ class GameResource:
private def assertIsCurrentPlayer(entry: GameEntry): Unit =
if entry.mode == GameMode.Authenticated then
val color = colorOf(entry)
if color != entry.engine.context.turn then
throw ForbiddenException("Not your turn")
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
// scalafix:on DisableSyntax.throw
@@ -77,10 +86,9 @@ class GameResource:
else
val ctx = entry.engine.context
ctx.result match
case Some(GameResult.Win(_, _)) =>
if entry.resigned then "resign"
else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
else "timeout"
case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
case Some(GameResult.Draw(_)) => "draw"
@@ -128,10 +136,11 @@ class GameResource:
}
private def toGameStateDto(entry: GameEntry): GameStateDto =
val ctx = entry.engine.context
val ctx = entry.engine.context
val exported = ioClient.exportCombined(ctx)
GameStateDto(
fen = ioClient.exportFen(ctx),
pgn = ioClient.exportPgn(ctx),
fen = exported.fen,
pgn = exported.pgn,
turn = ctx.turn.label.toLowerCase,
status = statusOf(entry),
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
@@ -234,13 +243,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
assertIsCurrentPlayer(entry)
val (from, to, promoOpt) = Parser
.parseMove(uci)
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
if Parser.parseMove(uci).isEmpty then
throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
ok(toGameStateDto(entry))
@@ -1,7 +1,15 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.{ClockState, CorrespondenceClockState, DrawReason, GameResult, LiveClockState, TimeControl, WinReason}
import de.nowchess.api.game.{
ClockState,
CorrespondenceClockState,
DrawReason,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
@@ -3,7 +3,8 @@ package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.*
import de.nowchess.api.game.GameContext
import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.chess.client.{CombinedExportResponse, IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.chess.exception.BadRequestException
import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnParser
@@ -44,6 +45,10 @@ class GameResourceIntegrationTest:
)
when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
when(ioClient.exportPgn(any())).thenReturn("1. e4 c5")
when(ioClient.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), ""),
)
when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleSquareRequest](0)
DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get),
@@ -70,6 +75,9 @@ class GameResourceIntegrationTest:
when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)),
)
when(ruleClient.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)),
)
@Test
@DisplayName("createGame returns 201")