From 9c55d1211d9700e7518e450d2baa66e433a8ef3a Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 20 Apr 2026 19:31:44 +0200 Subject: [PATCH] feat(rule): Rules as a microservice Added rules as a microservice --- modules/rule/build.gradle.kts | 49 ++- modules/rule/src/main/docker/Dockerfile.jvm | 100 ++++++ .../src/main/docker/Dockerfile.legacy-jar | 96 ++++++ .../rule/src/main/docker/Dockerfile.native | 29 ++ .../src/main/docker/Dockerfile.native-micro | 32 ++ .../rule/src/main/resources/application.yml | 5 + .../nowchess/rules/config/JacksonConfig.scala | 11 + .../de/nowchess/rules/dto/DtoMapper.scala | 128 ++++++++ .../scala/de/nowchess/rules/dto/Dtos.scala | 37 +++ .../rules/resource/RuleSetResource.scala | 94 ++++++ .../de/nowchess/rules/dto/DtoMapperTest.scala | 168 ++++++++++ .../rules/resource/RuleSetResourceTest.scala | 294 ++++++++++++++++++ 12 files changed, 1041 insertions(+), 2 deletions(-) create mode 100644 modules/rule/src/main/docker/Dockerfile.jvm create mode 100644 modules/rule/src/main/docker/Dockerfile.legacy-jar create mode 100644 modules/rule/src/main/docker/Dockerfile.native create mode 100644 modules/rule/src/main/docker/Dockerfile.native-micro create mode 100644 modules/rule/src/main/resources/application.yml create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala create mode 100644 modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala create mode 100644 modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts index 093fe12..e1eb832 100644 --- a/modules/rule/build.gradle.kts +++ b/modules/rule/build.gradle.kts @@ -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 { 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 { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().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 +} diff --git a/modules/rule/src/main/docker/Dockerfile.jvm b/modules/rule/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..c3c09fc --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.jvm @@ -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" ] + diff --git a/modules/rule/src/main/docker/Dockerfile.legacy-jar b/modules/rule/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 0000000..8c89666 --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.legacy-jar @@ -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" ] diff --git a/modules/rule/src/main/docker/Dockerfile.native b/modules/rule/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..57defbf --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.native @@ -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"] diff --git a/modules/rule/src/main/docker/Dockerfile.native-micro b/modules/rule/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..9408243 --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.native-micro @@ -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"] diff --git a/modules/rule/src/main/resources/application.yml b/modules/rule/src/main/resources/application.yml new file mode 100644 index 0000000..2c5486b --- /dev/null +++ b/modules/rule/src/main/resources/application.yml @@ -0,0 +1,5 @@ +quarkus: + http: + port: 8081 + application: + name: rule-service diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala new file mode 100644 index 0000000..57c0347 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala b/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala new file mode 100644 index 0000000..986820e --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala new file mode 100644 index 0000000..648275d --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala new file mode 100644 index 0000000..0b7f6c3 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -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)) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala new file mode 100644 index 0000000..60ce133 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala @@ -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") diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala new file mode 100644 index 0000000..d452c9c --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -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)