feat(rule): Rules as a microservice (#39)
Build & Test (NowChessSystems) TeamCity build finished

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: TeamCity <teamcity@service.local>
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-04-22 10:09:35 +02:00
parent ffeb3ce338
commit 093134d36c
54 changed files with 1655 additions and 76 deletions
@@ -6,3 +6,5 @@ quarkus:
rest-client:
io-service:
url: http://localhost:8081
rule-service:
url: http://localhost:8082
@@ -0,0 +1,51 @@
package de.nowchess.chess.adapter
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 jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import scala.compiletime.uninitialized
@ApplicationScoped
class RuleSetRestAdapter extends RuleSet:
// scalafix:off DisableSyntax.var
@Inject
@RestClient
var client: RuleServiceClient = uninitialized
// scalafix:on DisableSyntax.var
def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
client.candidateMoves(RuleSquareRequest(ctx, sq.toString))
def legalMoves(ctx: GameContext)(sq: Square): List[Move] =
client.legalMoves(RuleSquareRequest(ctx, sq.toString))
def allLegalMoves(ctx: GameContext): List[Move] =
client.allLegalMoves(ctx)
def isCheck(ctx: GameContext): Boolean =
client.isCheck(ctx)
def isCheckmate(ctx: GameContext): Boolean =
client.isCheckmate(ctx)
def isStalemate(ctx: GameContext): Boolean =
client.isStalemate(ctx)
def isInsufficientMaterial(ctx: GameContext): Boolean =
client.isInsufficientMaterial(ctx)
def isFiftyMoveRule(ctx: GameContext): Boolean =
client.isFiftyMoveRule(ctx)
def isThreefoldRepetition(ctx: GameContext): Boolean =
client.isThreefoldRepetition(ctx)
def applyMove(ctx: GameContext)(move: Move): GameContext =
client.applyMove(RuleMoveRequest(ctx, move))
@@ -0,0 +1,74 @@
package de.nowchess.chess.client
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class RuleSquareRequest(context: GameContext, square: String)
case class RuleMoveRequest(context: GameContext, move: Move)
@Path("/api/rules")
@RegisterRestClient(configKey = "rule-service")
trait RuleServiceClient:
@POST
@Path("/candidate-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def candidateMoves(req: RuleSquareRequest): List[Move]
@POST
@Path("/legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def legalMoves(req: RuleSquareRequest): List[Move]
@POST
@Path("/all-legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def allLegalMoves(ctx: GameContext): List[Move]
@POST
@Path("/is-check")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheck(ctx: GameContext): Boolean
@POST
@Path("/is-checkmate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheckmate(ctx: GameContext): Boolean
@POST
@Path("/is-stalemate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isStalemate(ctx: GameContext): Boolean
@POST
@Path("/is-insufficient-material")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isInsufficientMaterial(ctx: GameContext): Boolean
@POST
@Path("/is-fifty-move-rule")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isFiftyMoveRule(ctx: GameContext): Boolean
@POST
@Path("/is-threefold-repetition")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isThreefoldRepetition(ctx: GameContext): Boolean
@POST
@Path("/apply-move")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: RuleMoveRequest): GameContext
@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square
import de.nowchess.api.move.MoveType
import de.nowchess.chess.json.{MoveTypeDeserializer, MoveTypeSerializer, SquareDeserializer, SquareSerializer}
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -17,7 +19,11 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
val squareModule = new SimpleModule()
squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
mapper.registerModule(squareModule)
val mod = new SimpleModule()
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
mapper.registerModule(mod)
@@ -8,8 +8,7 @@ import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.api.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.api.rules.RuleSet
import scala.concurrent.{ExecutionContext, Future}
@@ -18,7 +17,7 @@ import scala.concurrent.{ExecutionContext, Future}
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules,
val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
@@ -0,0 +1,19 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.move.{MoveType, PromotionPiece}
class MoveTypeDeserializer extends JsonDeserializer[MoveType]:
// scalafix:off DisableSyntax.throw
override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType =
val node = p.getCodec.readTree[ObjectNode](p)
node.get("type").asText() match
case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false))
case "castleKingside" => MoveType.CastleKingside
case "castleQueenside" => MoveType.CastleQueenside
case "enPassant" => MoveType.EnPassant
case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText()))
case t => throw new JsonParseException(p, s"Unknown move type: $t")
// scalafix:on DisableSyntax.throw
@@ -0,0 +1,23 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.move.MoveType
class MoveTypeSerializer extends JsonSerializer[MoveType]:
override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeStartObject()
value match
case MoveType.Normal(isCapture) =>
gen.writeStringField("type", "normal")
gen.writeBooleanField("isCapture", isCapture)
case MoveType.CastleKingside =>
gen.writeStringField("type", "castleKingside")
case MoveType.CastleQueenside =>
gen.writeStringField("type", "castleQueenside")
case MoveType.EnPassant =>
gen.writeStringField("type", "enPassant")
case MoveType.Promotion(piece) =>
gen.writeStringField("type", "promotion")
gen.writeStringField("piece", piece.toString)
gen.writeEndObject()
@@ -0,0 +1,9 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.board.Square
class SquareDeserializer extends JsonDeserializer[Square]:
override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
Square.fromAlgebraic(p.getText).orNull
@@ -0,0 +1,9 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareSerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeString(value.toString)
@@ -6,6 +6,7 @@ import de.nowchess.api.dto.*
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.adapter.RuleSetRestAdapter
import de.nowchess.chess.client.IoServiceClient
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.engine.GameEngine
@@ -36,6 +37,9 @@ class GameResource:
@Inject
@RestClient
var ioClient: IoServiceClient = uninitialized
@Inject
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -103,7 +107,7 @@ class GameResource:
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry =
GameEntry(registry.generateId(), GameEngine(initialContext = ctx), white, black)
GameEntry(registry.generateId(), GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter), white, black)
private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
val error = new AtomicReference[Option[String]](None)
@@ -138,6 +142,7 @@ class GameResource:
val black = playerInfoFrom(req.black, DefaultBlack)
val entry = newEntry(GameContext.initial, white, black)
registry.store(entry)
println(s"Created game ${entry.gameId}")
created(toGameFullDto(entry))
@GET