feat(rule): Rules as a microservice
Build & Test (NowChessSystems) TeamCity build failed

Added rules as a microservice
This commit is contained in:
LQ63
2026-04-20 19:31:44 +02:00
parent b52623b8f6
commit a176f9e7ca
12 changed files with 1041 additions and 2 deletions
+47 -2
View File
@@ -1,6 +1,7 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
}
group = "de.nowchess"
@@ -25,6 +26,10 @@ tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") {
@@ -40,20 +45,56 @@ dependencies {
implementation(project(":modules:api"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-hibernate-orm")
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("io.quarkus:quarkus-rest-client")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-arc")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
testImplementation(project(":modules:io"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testImplementation("io.quarkus:quarkus-junit5")
testImplementation("io.rest-assured:rest-assured")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
includeEngines("scalatest", "junit-jupiter")
testLogging {
events("skipped", "failed")
events("passed", "skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -61,3 +102,7 @@ tasks.test {
tasks.reportScoverage {
dependsOn(tasks.test)
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+100
View File
@@ -0,0 +1,100 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./gradlew build
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backcore-jvm .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
# You can find more information about the UBI base runtime images and their configuration here:
# https://rh-openjdk.github.io/redhat-openjdk-containers/
###
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
ENV LANGUAGE='en_US:en'
# We make four distinct layers so if there are application changes the library layers can be re-used
COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 build/quarkus-app/*.jar /deployments/
COPY --chown=185 build/quarkus-app/app/ /deployments/app/
COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
@@ -0,0 +1,96 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.package.jar.type=legacy-jar
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/backcore-legacy-jar .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
# You can find more information about the UBI base runtime images and their configuration here:
# https://rh-openjdk.github.io/redhat-openjdk-containers/
###
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
ENV LANGUAGE='en_US:en'
COPY build/lib/* /deployments/lib/
COPY build/*-runner.jar /deployments/quarkus-run.jar
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
@@ -0,0 +1,29 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.native.enabled=true
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore
#
# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
###
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,32 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
# It uses a micro base image, tuned for Quarkus native executables.
# It reduces the size of the resulting container image.
# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.native.enabled=true
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/backcore .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore
#
# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
###
FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,5 @@
quarkus:
http:
port: 8081
application:
name: rule-service
@@ -0,0 +1,11 @@
package de.nowchess.rules.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(DefaultScalaModule)
@@ -0,0 +1,128 @@
package de.nowchess.rules.dto
import de.nowchess.api.board.{Board, CastlingRights, Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
object DtoMapper:
def toColor(s: String): Either[String, Color] = s match
case "White" => Right(Color.White)
case "Black" => Right(Color.Black)
case other => Left(s"Unknown color: $other")
def toPieceType(s: String): Either[String, PieceType] = s match
case "Pawn" => Right(PieceType.Pawn)
case "Knight" => Right(PieceType.Knight)
case "Bishop" => Right(PieceType.Bishop)
case "Rook" => Right(PieceType.Rook)
case "Queen" => Right(PieceType.Queen)
case "King" => Right(PieceType.King)
case other => Left(s"Unknown piece type: $other")
def toSquare(s: String): Either[String, Square] =
Square.fromAlgebraic(s).toRight(s"Invalid square: $s")
def toMoveType(dto: MoveDto): Either[String, MoveType] = dto.moveType match
case "normal" => Right(MoveType.Normal(isCapture = false))
case "capture" => Right(MoveType.Normal(isCapture = true))
case "castleKingside" => Right(MoveType.CastleKingside)
case "castleQueenside" => Right(MoveType.CastleQueenside)
case "enPassant" => Right(MoveType.EnPassant)
case "promotion" =>
dto.promotionPiece.toRight("Missing promotion piece").flatMap(toPromotionPiece).map(MoveType.Promotion(_))
case other => Left(s"Unknown move type: $other")
def toMove(dto: MoveDto): Either[String, Move] =
for
from <- toSquare(dto.from)
to <- toSquare(dto.to)
moveType <- toMoveType(dto)
yield Move(from, to, moveType)
def toBoard(pieces: List[PieceOnSquareDto]): Either[String, Board] =
sequenceList(pieces.map(toPieceOnSquare)).map(entries => Board(entries.toMap))
def toGameContext(dto: GameContextDto): Either[String, GameContext] =
for
board <- toBoard(dto.board)
turn <- toColor(dto.turn)
epSquare <- sequenceOpt(dto.enPassantSquare.map(toSquare))
moves <- sequenceList(dto.moves.map(toMove))
initialBoard <- toBoard(dto.initialBoard)
yield GameContext(
board = board,
turn = turn,
castlingRights = toCastlingRights(dto.castlingRights),
enPassantSquare = epSquare,
halfMoveClock = dto.halfMoveClock,
moves = moves,
initialBoard = initialBoard,
)
def fromMove(move: Move): MoveDto =
val (moveType, promotionPiece) = fromMoveType(move.moveType)
MoveDto(move.from.toString, move.to.toString, moveType, promotionPiece)
def fromBoard(board: Board): List[PieceOnSquareDto] =
board.pieces.toList.map { case (sq, p) =>
PieceOnSquareDto(sq.toString, p.color.label, p.pieceType.label)
}
def fromGameContext(ctx: GameContext): GameContextDto =
GameContextDto(
board = fromBoard(ctx.board),
turn = ctx.turn.label,
castlingRights = fromCastlingRights(ctx.castlingRights),
enPassantSquare = ctx.enPassantSquare.map(_.toString),
halfMoveClock = ctx.halfMoveClock,
moves = ctx.moves.map(fromMove),
initialBoard = fromBoard(ctx.initialBoard),
)
private def toPromotionPiece(s: String): Either[String, PromotionPiece] = s match
case "Queen" => Right(PromotionPiece.Queen)
case "Rook" => Right(PromotionPiece.Rook)
case "Bishop" => Right(PromotionPiece.Bishop)
case "Knight" => Right(PromotionPiece.Knight)
case other => Left(s"Unknown promotion piece: $other")
private def fromMoveType(mt: MoveType): (String, Option[String]) = mt match
case MoveType.Normal(false) => ("normal", None)
case MoveType.Normal(true) => ("capture", None)
case MoveType.CastleKingside => ("castleKingside", None)
case MoveType.CastleQueenside => ("castleQueenside", None)
case MoveType.EnPassant => ("enPassant", None)
case MoveType.Promotion(pp) => ("promotion", Some(fromPromotionPiece(pp)))
private def fromPromotionPiece(pp: PromotionPiece): String = pp match
case PromotionPiece.Queen => "Queen"
case PromotionPiece.Rook => "Rook"
case PromotionPiece.Bishop => "Bishop"
case PromotionPiece.Knight => "Knight"
private def toCastlingRights(dto: CastlingRightsDto): CastlingRights =
CastlingRights(dto.whiteKingSide, dto.whiteQueenSide, dto.blackKingSide, dto.blackQueenSide)
private def fromCastlingRights(cr: CastlingRights): CastlingRightsDto =
CastlingRightsDto(cr.whiteKingSide, cr.whiteQueenSide, cr.blackKingSide, cr.blackQueenSide)
private def toPieceOnSquare(dto: PieceOnSquareDto): Either[String, (Square, Piece)] =
for
sq <- toSquare(dto.square)
color <- toColor(dto.color)
pieceType <- toPieceType(dto.pieceType)
yield sq -> Piece(color, pieceType)
private def sequenceList[R](list: List[Either[String, R]]): Either[String, List[R]] =
list.foldLeft[Either[String, List[R]]](Right(List.empty)) {
case (Right(acc), Right(v)) => Right(acc :+ v)
case (Left(e), _) => Left(e)
case (_, Left(e)) => Left(e)
}
private def sequenceOpt[R](opt: Option[Either[String, R]]): Either[String, Option[R]] =
opt match
case None => Right(None)
case Some(Right(v)) => Right(Some(v))
case Some(Left(e)) => Left(e)
@@ -0,0 +1,37 @@
package de.nowchess.rules.dto
case class PieceOnSquareDto(square: String, color: String, pieceType: String)
case class CastlingRightsDto(
whiteKingSide: Boolean,
whiteQueenSide: Boolean,
blackKingSide: Boolean,
blackQueenSide: Boolean,
)
case class MoveDto(
from: String,
to: String,
moveType: String,
promotionPiece: Option[String],
)
case class GameContextDto(
board: List[PieceOnSquareDto],
turn: String,
castlingRights: CastlingRightsDto,
enPassantSquare: Option[String],
halfMoveClock: Int,
moves: List[MoveDto],
initialBoard: List[PieceOnSquareDto],
)
case class ContextRequest(context: GameContextDto)
case class ContextSquareRequest(context: GameContextDto, square: String)
case class ContextMoveRequest(context: GameContextDto, move: MoveDto)
case class MovesResponse(moves: List[MoveDto])
case class BooleanResponse(result: Boolean)
@@ -0,0 +1,94 @@
package de.nowchess.rules.resource
import de.nowchess.rules.dto.*
import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
@Path("/api/rules")
@ApplicationScoped
class RuleSetResource:
private val rules = DefaultRules
// scalafix:off DisableSyntax.throw
private def parse[T](e: Either[String, T]): T = e match
case Right(v) => v
case Left(msg) => throw BadRequestException(msg)
// scalafix:on DisableSyntax.throw
@POST
@Path("/candidate-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def candidateMoves(req: ContextSquareRequest): MovesResponse =
val ctx = parse(DtoMapper.toGameContext(req.context))
val sq = parse(DtoMapper.toSquare(req.square))
MovesResponse(rules.candidateMoves(ctx)(sq).map(DtoMapper.fromMove))
@POST
@Path("/legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def legalMoves(req: ContextSquareRequest): MovesResponse =
val ctx = parse(DtoMapper.toGameContext(req.context))
val sq = parse(DtoMapper.toSquare(req.square))
MovesResponse(rules.legalMoves(ctx)(sq).map(DtoMapper.fromMove))
@POST
@Path("/all-legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def allLegalMoves(req: ContextRequest): MovesResponse =
MovesResponse(rules.allLegalMoves(parse(DtoMapper.toGameContext(req.context))).map(DtoMapper.fromMove))
@POST
@Path("/is-check")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheck(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheck(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/is-checkmate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheckmate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheckmate(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/is-stalemate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isStalemate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isStalemate(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/is-insufficient-material")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isInsufficientMaterial(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isInsufficientMaterial(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/is-fifty-move-rule")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isFiftyMoveRule(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isFiftyMoveRule(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/is-threefold-repetition")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isThreefoldRepetition(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isThreefoldRepetition(parse(DtoMapper.toGameContext(req.context))))
@POST
@Path("/apply-move")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: ContextMoveRequest): GameContextDto =
val ctx = parse(DtoMapper.toGameContext(req.context))
val move = parse(DtoMapper.toMove(req.move))
DtoMapper.fromGameContext(rules.applyMove(ctx)(move))
@@ -0,0 +1,168 @@
package de.nowchess.rules.dto
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class DtoMapperTest extends AnyFunSuite with Matchers:
// ── toColor ────────────────────────────────────────────────────────
test("toColor converts White"):
DtoMapper.toColor("White") shouldBe Right(Color.White)
test("toColor converts Black"):
DtoMapper.toColor("Black") shouldBe Right(Color.Black)
test("toColor rejects unknown"):
DtoMapper.toColor("Red") shouldBe a[Left[?, ?]]
// ── toPieceType ────────────────────────────────────────────────────
test("toPieceType converts all piece types"):
DtoMapper.toPieceType("Pawn") shouldBe Right(PieceType.Pawn)
DtoMapper.toPieceType("Knight") shouldBe Right(PieceType.Knight)
DtoMapper.toPieceType("Bishop") shouldBe Right(PieceType.Bishop)
DtoMapper.toPieceType("Rook") shouldBe Right(PieceType.Rook)
DtoMapper.toPieceType("Queen") shouldBe Right(PieceType.Queen)
DtoMapper.toPieceType("King") shouldBe Right(PieceType.King)
test("toPieceType rejects unknown"):
DtoMapper.toPieceType("Dragon") shouldBe a[Left[?, ?]]
// ── toSquare ───────────────────────────────────────────────────────
test("toSquare converts valid algebraic"):
DtoMapper.toSquare("e4") shouldBe Right(Square(File.E, Rank.R4))
test("toSquare rejects invalid"):
DtoMapper.toSquare("z9") shouldBe a[Left[?, ?]]
// ── toMoveType ────────────────────────────────────────────────────
test("toMoveType converts normal non-capture"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "normal", None)) shouldBe Right(MoveType.Normal(false))
test("toMoveType converts capture"):
DtoMapper.toMoveType(MoveDto("e2", "d3", "capture", None)) shouldBe Right(MoveType.Normal(true))
test("toMoveType converts castleKingside"):
DtoMapper.toMoveType(MoveDto("e1", "g1", "castleKingside", None)) shouldBe Right(MoveType.CastleKingside)
test("toMoveType converts castleQueenside"):
DtoMapper.toMoveType(MoveDto("e1", "c1", "castleQueenside", None)) shouldBe Right(MoveType.CastleQueenside)
test("toMoveType converts enPassant"):
DtoMapper.toMoveType(MoveDto("e5", "d6", "enPassant", None)) shouldBe Right(MoveType.EnPassant)
test("toMoveType converts all promotion pieces"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Queen"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Queen),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Rook"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Rook),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Bishop"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Bishop),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Knight"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Knight),
)
test("toMoveType rejects promotion without piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", None)) shouldBe a[Left[?, ?]]
test("toMoveType rejects promotion with unknown piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Pawn"))) shouldBe a[Left[?, ?]]
test("toMoveType rejects unknown type"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "unknown", None)) shouldBe a[Left[?, ?]]
// ── toBoard ───────────────────────────────────────────────────────
test("toBoard builds valid board"):
val pieces = List(
PieceOnSquareDto("e1", "White", "King"),
PieceOnSquareDto("e8", "Black", "King"),
)
val result = DtoMapper.toBoard(pieces)
result.isRight shouldBe true
result.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Right(Some(Piece(Color.White, PieceType.King)))
test("toBoard rejects invalid square"):
DtoMapper.toBoard(List(PieceOnSquareDto("z9", "White", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid color"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "Red", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid piece type"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "White", "Dragon"))) shouldBe a[Left[?, ?]]
test("toBoard with multiple invalid pieces covers all sequenceList branches"):
val pieces = List(
PieceOnSquareDto("z9", "White", "King"),
PieceOnSquareDto("z8", "White", "Queen"),
)
DtoMapper.toBoard(pieces) shouldBe a[Left[?, ?]]
// ── toGameContext ─────────────────────────────────────────────────
test("toGameContext round-trips initial position"):
val ctx = GameContext.initial
val dto = DtoMapper.fromGameContext(ctx)
DtoMapper.toGameContext(dto) shouldBe Right(ctx)
test("toGameContext rejects invalid turn"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid en passant square"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(enPassantSquare = Some("z9"))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid initial board"):
val badBoard = List(PieceOnSquareDto("z9", "White", "King"))
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(initialBoard = badBoard)
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid move"):
val badMove = MoveDto("z9", "e4", "normal", None)
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(moves = List(badMove))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
// ── fromGameContext ───────────────────────────────────────────────
test("fromGameContext includes en passant square when present"):
val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3)))
DtoMapper.fromGameContext(ctx).enPassantSquare shouldBe Some("e3")
// ── fromMove ──────────────────────────────────────────────────────
test("fromMove converts all move types"):
DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))).moveType shouldBe "normal"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true)))
.moveType shouldBe "capture"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
.moveType shouldBe "castleKingside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
.moveType shouldBe "castleQueenside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant))
.moveType shouldBe "enPassant"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
.promotionPiece shouldBe Some("Queen")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
.promotionPiece shouldBe Some("Rook")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
.promotionPiece shouldBe Some("Bishop")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
.promotionPiece shouldBe Some("Knight")
@@ -0,0 +1,294 @@
package de.nowchess.rules.resource
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import io.restassured.http.ContentType
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.Test
@QuarkusTest
class RuleSetResourceTest:
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
private val rules = DefaultRules
private def request() = RestAssured.`given`()
private def toJson(value: AnyRef): String = mapper.writeValueAsString(value)
private def contextBody(ctx: GameContext): String =
toJson(ContextRequest(DtoMapper.fromGameContext(ctx)))
private def contextSquareBody(ctx: GameContext, square: String): String =
toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square))
private def contextMoveBody(ctx: GameContext, move: Move): String =
toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move)))
// ── all-legal-moves ───────────────────────────────────────────────
@Test
def allLegalMoves_initialPositionHas20Moves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/all-legal-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(20))
// ── legal-moves ───────────────────────────────────────────────────
@Test
def legalMoves_e2PawnHas2Moves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextSquareBody(GameContext.initial, "e2"))
.when()
.post("/api/rules/legal-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(2))
// ── candidate-moves ───────────────────────────────────────────────
@Test
def candidateMoves_e2PawnHas2Candidates(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextSquareBody(GameContext.initial, "e2"))
.when()
.post("/api/rules/candidate-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(2))
// ── is-check ──────────────────────────────────────────────────────
@Test
def isCheck_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isCheck_trueWhenKingAttacked(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildCheckContext()))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-checkmate ──────────────────────────────────────────────────
@Test
def isCheckmate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isCheckmate_trueForFoolsMate(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildFoolsMate()))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-stalemate ──────────────────────────────────────────────────
@Test
def isStalemate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isStalemate_trueForStalematePosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildStalemateContext()))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-insufficient-material ──────────────────────────────────────
@Test
def isInsufficientMaterial_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isInsufficientMaterial_trueForKingsOnly(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildKingsOnlyContext()))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-fifty-move-rule ────────────────────────────────────────────
@Test
def isFiftyMoveRule_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isFiftyMoveRule_trueWhenClockAt100(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial.copy(halfMoveClock = 100)))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-threefold-repetition ───────────────────────────────────────
@Test
def isThreefoldRepetition_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildThreefoldContext()))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── apply-move ────────────────────────────────────────────────────
@Test
def applyMove_updatesContext(): Unit =
val move = rules
.legalMoves(GameContext.initial)(Square(File.E, Rank.R2))
.find(_.to == Square(File.E, Rank.R4))
.get
request()
.contentType(ContentType.JSON)
.body(contextMoveBody(GameContext.initial, move))
.when()
.post("/api/rules/apply-move")
.`then`()
.statusCode(200)
.body("turn", is("Black"))
// ── error handling ────────────────────────────────────────────────
@Test
def invalidSquare_returns400(): Unit =
request()
.contentType(ContentType.JSON)
.body(toJson(ContextSquareRequest(DtoMapper.fromGameContext(GameContext.initial), "z9")))
.when()
.post("/api/rules/legal-moves")
.`then`()
.statusCode(400)
// ── position builders ─────────────────────────────────────────────
private def buildCheckContext(): GameContext =
val board = Board(Map(
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.E, Rank.R3) -> Piece(Color.Black, PieceType.Rook),
))
GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board)
private def buildFoolsMate(): GameContext =
val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4"))
moves.foldLeft(GameContext.initial) { (ctx, fromTo) =>
val from = Square.fromAlgebraic(fromTo._1).get
val to = Square.fromAlgebraic(fromTo._2).get
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
}
private def buildStalemateContext(): GameContext =
val board = Board(Map(
Square(File.H, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.F, Rank.R7) -> Piece(Color.White, PieceType.Queen),
Square(File.G, Rank.R6) -> Piece(Color.White, PieceType.King),
))
GameContext(board, Color.Black, CastlingRights.None, None, 0, List.empty, initialBoard = board)
private def buildKingsOnlyContext(): GameContext =
val board = Board(Map(
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
))
GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board)
private def buildThreefoldContext(): GameContext =
val g1 = Square(File.G, Rank.R1)
val f3 = Square(File.F, Rank.R3)
val g8 = Square(File.G, Rank.R8)
val f6 = Square(File.F, Rank.R6)
def mv(ctx: GameContext, from: Square, to: Square): GameContext =
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
val ctx1 = mv(GameContext.initial, g1, f3)
val ctx2 = mv(ctx1, g8, f6)
val ctx3 = mv(ctx2, f3, g1)
val ctx4 = mv(ctx3, f6, g8)
val ctx5 = mv(ctx4, g1, f3)
val ctx6 = mv(ctx5, g8, f6)
val ctx7 = mv(ctx6, f3, g1)
mv(ctx7, f6, g8)