From 9c55d1211d9700e7518e450d2baa66e433a8ef3a Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 20 Apr 2026 19:31:44 +0200 Subject: [PATCH 01/15] 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) -- 2.52.0 From 6eb2419e40f21aceb7d050a7cbe937495ec45f78 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 17:49:59 +0200 Subject: [PATCH 02/15] fix(rule): Rules as a microservice Added rules as a microservice --- .../src/main/scala/de/nowchess/chess/resource/GameResource.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index e5dbf94..c09e0d0 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -138,6 +138,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 -- 2.52.0 From c131bdc139789e6638c4a347bac95b4244cf3333 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 18:08:33 +0200 Subject: [PATCH 03/15] test(rules): Rules as a microservice Added tests to rules --- .../rules/config/JacksonConfigTest.scala | 14 ++ .../de/nowchess/rules/dto/DtoMapperTest.scala | 6 + .../resource/RuleSetResourceUnitTest.scala | 160 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala create mode 100644 modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala diff --git a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala new file mode 100644 index 0000000..9d89e29 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala @@ -0,0 +1,14 @@ +package de.nowchess.rules.config + +import com.fasterxml.jackson.databind.ObjectMapper +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JacksonConfigTest extends AnyFunSuite with Matchers: + + test("customize registers DefaultScalaModule enabling Option serialization"): + val config = new JacksonConfig() + val mapper = new ObjectMapper() + config.customize(mapper) + mapper.writeValueAsString(None) shouldBe "null" + mapper.writeValueAsString(Some("hello")) shouldBe """"hello"""" 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 index 60ce133..4bdaa43 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala @@ -138,6 +138,12 @@ class DtoMapperTest extends AnyFunSuite with Matchers: val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3))) DtoMapper.fromGameContext(ctx).enPassantSquare shouldBe Some("e3") + test("toGameContext round-trips a valid en passant square"): + val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3))) + DtoMapper.toGameContext(DtoMapper.fromGameContext(ctx)).map(_.enPassantSquare) shouldBe Right( + Some(Square(File.E, Rank.R3)), + ) + // ── fromMove ────────────────────────────────────────────────────── test("fromMove converts all move types"): diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala new file mode 100644 index 0000000..fb7235e --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -0,0 +1,160 @@ +package de.nowchess.rules.resource + +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, MoveType} +import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper} +import de.nowchess.rules.sets.DefaultRules +import jakarta.ws.rs.BadRequestException +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: + + private val resource = new RuleSetResource() + private val rules = DefaultRules + + private def ctx(g: GameContext) = ContextRequest(DtoMapper.fromGameContext(g)) + private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(DtoMapper.fromGameContext(g), sq) + private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(DtoMapper.fromGameContext(g), DtoMapper.fromMove(m)) + + // ── position builders ───────────────────────────────────────────── + + private def checkContext(): 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 foolsMate(): GameContext = + val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4")) + moves.foldLeft(GameContext.initial) { (c, ft) => + val from = Square.fromAlgebraic(ft._1).get + val to = Square.fromAlgebraic(ft._2).get + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + } + + private def stalemateContext(): 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 kingsOnlyContext(): 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 threefoldContext(): 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(c: GameContext, from: Square, to: Square): GameContext = + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + val c1 = mv(GameContext.initial, g1, f3) + val c2 = mv(c1, g8, f6) + val c3 = mv(c2, f3, g1) + val c4 = mv(c3, f6, g8) + val c5 = mv(c4, g1, f3) + val c6 = mv(c5, g8, f6) + val c7 = mv(c6, f3, g1) + mv(c7, f6, g8) + + // ── allLegalMoves ───────────────────────────────────────────────── + + test("allLegalMoves returns 20 moves for initial position"): + resource.allLegalMoves(ctx(GameContext.initial)).moves should have size 20 + + // ── legalMoves ──────────────────────────────────────────────────── + + test("legalMoves returns 2 moves for e2 pawn"): + resource.legalMoves(ctxSq(GameContext.initial, "e2")).moves should have size 2 + + test("legalMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.legalMoves(ctxSq(GameContext.initial, "z9")) + + // ── candidateMoves ──────────────────────────────────────────────── + + test("candidateMoves returns moves for e2 pawn"): + resource.candidateMoves(ctxSq(GameContext.initial, "e2")).moves should not be empty + + test("candidateMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.candidateMoves(ctxSq(GameContext.initial, "z9")) + + // ── isCheck ─────────────────────────────────────────────────────── + + test("isCheck returns false for initial position"): + resource.isCheck(ctx(GameContext.initial)).result shouldBe false + + test("isCheck returns true when king is attacked"): + resource.isCheck(ctx(checkContext())).result shouldBe true + + test("isCheck throws BadRequestException for invalid context"): + an[BadRequestException] should be thrownBy + resource.isCheck(ctx(GameContext.initial).copy(context = + DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red"), + )) + + // ── isCheckmate ─────────────────────────────────────────────────── + + test("isCheckmate returns false for initial position"): + resource.isCheckmate(ctx(GameContext.initial)).result shouldBe false + + test("isCheckmate returns true for Fool's mate"): + resource.isCheckmate(ctx(foolsMate())).result shouldBe true + + // ── isStalemate ─────────────────────────────────────────────────── + + test("isStalemate returns false for initial position"): + resource.isStalemate(ctx(GameContext.initial)).result shouldBe false + + test("isStalemate returns true for stalemate position"): + resource.isStalemate(ctx(stalemateContext())).result shouldBe true + + // ── isInsufficientMaterial ──────────────────────────────────────── + + test("isInsufficientMaterial returns false for initial position"): + resource.isInsufficientMaterial(ctx(GameContext.initial)).result shouldBe false + + test("isInsufficientMaterial returns true for kings only"): + resource.isInsufficientMaterial(ctx(kingsOnlyContext())).result shouldBe true + + // ── isFiftyMoveRule ─────────────────────────────────────────────── + + test("isFiftyMoveRule returns false for initial position"): + resource.isFiftyMoveRule(ctx(GameContext.initial)).result shouldBe false + + test("isFiftyMoveRule returns true when halfMoveClock is 100"): + resource.isFiftyMoveRule(ctx(GameContext.initial.copy(halfMoveClock = 100))).result shouldBe true + + // ── isThreefoldRepetition ───────────────────────────────────────── + + test("isThreefoldRepetition returns false for initial position"): + resource.isThreefoldRepetition(ctx(GameContext.initial)).result shouldBe false + + test("isThreefoldRepetition returns true after repeated moves"): + resource.isThreefoldRepetition(ctx(threefoldContext())).result shouldBe true + + // ── applyMove ───────────────────────────────────────────────────── + + test("applyMove returns updated context with switched turn"): + val move = rules + .legalMoves(GameContext.initial)(Square(File.E, Rank.R2)) + .find(_.to == Square(File.E, Rank.R4)) + .get + resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe "Black" + + test("applyMove throws BadRequestException for invalid move"): + val badMove = DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + .copy(moveType = "unknown") + an[BadRequestException] should be thrownBy + resource.applyMove(ContextMoveRequest(DtoMapper.fromGameContext(GameContext.initial), badMove)) -- 2.52.0 From 204b0f04f51cef8ec152b5a74e7659118a02de32 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 18:40:49 +0200 Subject: [PATCH 04/15] fix(rules): Code quality linter corrections --- .../de/nowchess/rules/dto/DtoMapper.scala | 26 +++++------ .../rules/resource/RuleSetResourceTest.scala | 34 ++++++++------ .../resource/RuleSetResourceUnitTest.scala | 45 +++++++++++-------- 3 files changed, 59 insertions(+), 46 deletions(-) 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 index 986820e..2c00fc2 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala @@ -29,7 +29,7 @@ object DtoMapper: case "castleKingside" => Right(MoveType.CastleKingside) case "castleQueenside" => Right(MoveType.CastleQueenside) case "enPassant" => Right(MoveType.EnPassant) - case "promotion" => + case "promotion" => dto.promotionPiece.toRight("Missing promotion piece").flatMap(toPromotionPiece).map(MoveType.Promotion(_)) case other => Left(s"Unknown move type: $other") @@ -51,13 +51,13 @@ object DtoMapper: moves <- sequenceList(dto.moves.map(toMove)) initialBoard <- toBoard(dto.initialBoard) yield GameContext( - board = board, - turn = turn, - castlingRights = toCastlingRights(dto.castlingRights), + board = board, + turn = turn, + castlingRights = toCastlingRights(dto.castlingRights), enPassantSquare = epSquare, - halfMoveClock = dto.halfMoveClock, - moves = moves, - initialBoard = initialBoard, + halfMoveClock = dto.halfMoveClock, + moves = moves, + initialBoard = initialBoard, ) def fromMove(move: Move): MoveDto = @@ -71,13 +71,13 @@ object DtoMapper: def fromGameContext(ctx: GameContext): GameContextDto = GameContextDto( - board = fromBoard(ctx.board), - turn = ctx.turn.label, - castlingRights = fromCastlingRights(ctx.castlingRights), + 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), + halfMoveClock = ctx.halfMoveClock, + moves = ctx.moves.map(fromMove), + initialBoard = fromBoard(ctx.initialBoard), ) private def toPromotionPiece(s: String): Either[String, PromotionPiece] = s match 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 index d452c9c..bb7209a 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -247,11 +247,13 @@ class RuleSetResourceTest: // ── 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), - )) + 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 = @@ -263,18 +265,22 @@ class RuleSetResourceTest: } 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), - )) + 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), - )) + 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 = diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala index fb7235e..b32f55d 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -16,16 +16,18 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: private def ctx(g: GameContext) = ContextRequest(DtoMapper.fromGameContext(g)) private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(DtoMapper.fromGameContext(g), sq) - private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(DtoMapper.fromGameContext(g), DtoMapper.fromMove(m)) + private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(DtoMapper.fromGameContext(g), DtoMapper.fromMove(m)) // ── position builders ───────────────────────────────────────────── private def checkContext(): 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), - )) + 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 foolsMate(): GameContext = @@ -37,18 +39,22 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: } private def stalemateContext(): 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), - )) + 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 kingsOnlyContext(): 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), - )) + 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 threefoldContext(): GameContext = @@ -100,9 +106,9 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: test("isCheck throws BadRequestException for invalid context"): an[BadRequestException] should be thrownBy - resource.isCheck(ctx(GameContext.initial).copy(context = - DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red"), - )) + resource.isCheck( + ctx(GameContext.initial).copy(context = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")), + ) // ── isCheckmate ─────────────────────────────────────────────────── @@ -154,7 +160,8 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe "Black" test("applyMove throws BadRequestException for invalid move"): - val badMove = DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + val badMove = DtoMapper + .fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))) .copy(moveType = "unknown") an[BadRequestException] should be thrownBy resource.applyMove(ContextMoveRequest(DtoMapper.fromGameContext(GameContext.initial), badMove)) -- 2.52.0 From 539a9ee14751e674ac29ac993abda4097c68c637 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 19:10:29 +0200 Subject: [PATCH 05/15] fix(rules): Code quality Fixed problem with dependency --- modules/rule/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts index e1eb832..38982d5 100644 --- a/modules/rule/build.gradle.kts +++ b/modules/rule/build.gradle.kts @@ -45,7 +45,7 @@ dependencies { implementation(project(":modules:api")) - implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation("io.quarkus:quarkus-rest") implementation("io.quarkus:quarkus-hibernate-orm") implementation("io.quarkus:quarkus-rest-client-jackson") -- 2.52.0 From e82010bd22910c868aee2c93ab0edaf08c1fc3de Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 20:42:24 +0200 Subject: [PATCH 06/15] fix(rules): Serializers Added serializers like in IO --- .../nowchess/rules/config/JacksonConfig.scala | 20 +- .../de/nowchess/rules/dto/DtoMapper.scala | 128 ------------- .../scala/de/nowchess/rules/dto/Dtos.scala | 35 +--- .../rules/json/MoveTypeDeserializer.scala | 17 ++ .../rules/json/MoveTypeSerializer.scala | 23 +++ .../rules/json/SquareDeserializer.scala | 9 + .../rules/json/SquareKeyDeserializer.scala | 8 + .../rules/json/SquareKeySerializer.scala | 9 + .../rules/json/SquareSerializer.scala | 9 + .../rules/resource/RuleSetResource.scala | 35 ++-- .../rules/config/JacksonConfigTest.scala | 24 ++- .../de/nowchess/rules/dto/DtoMapperTest.scala | 174 ------------------ .../rules/json/JsonSerializersTest.scala | 98 ++++++++++ .../rules/resource/RuleSetResourceTest.scala | 20 +- .../resource/RuleSetResourceUnitTest.scala | 25 +-- 15 files changed, 251 insertions(+), 383 deletions(-) delete mode 100644 modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala delete mode 100644 modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala create mode 100644 modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala 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 index 57c0347..8dd29b8 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala @@ -1,11 +1,29 @@ package de.nowchess.rules.config +import com.fasterxml.jackson.core.Version 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.rules.json.* import io.quarkus.jackson.ObjectMapperCustomizer import jakarta.inject.Singleton @Singleton class JacksonConfig extends ObjectMapperCustomizer: def customize(mapper: ObjectMapper): Unit = - mapper.registerModule(DefaultScalaModule) + mapper.registerModule(new DefaultScalaModule() { + override def version(): Version = + // scalafix:off DisableSyntax.null + new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala") + // scalafix:on DisableSyntax.null + }) + 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) 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 deleted file mode 100644 index 2c00fc2..0000000 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala +++ /dev/null @@ -1,128 +0,0 @@ -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 index 648275d..d06609c 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala @@ -1,37 +1,14 @@ package de.nowchess.rules.dto -case class PieceOnSquareDto(square: String, color: String, pieceType: String) +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move -case class CastlingRightsDto( - whiteKingSide: Boolean, - whiteQueenSide: Boolean, - blackKingSide: Boolean, - blackQueenSide: Boolean, -) +case class ContextRequest(context: GameContext) -case class MoveDto( - from: String, - to: String, - moveType: String, - promotionPiece: Option[String], -) +case class ContextSquareRequest(context: GameContext, square: String) -case class GameContextDto( - board: List[PieceOnSquareDto], - turn: String, - castlingRights: CastlingRightsDto, - enPassantSquare: Option[String], - halfMoveClock: Int, - moves: List[MoveDto], - initialBoard: List[PieceOnSquareDto], -) +case class ContextMoveRequest(context: GameContext, move: Move) -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 MovesResponse(moves: List[Move]) case class BooleanResponse(result: Boolean) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala new file mode 100644 index 0000000..d0f0c8f --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala @@ -0,0 +1,17 @@ +package de.nowchess.rules.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]: + 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") diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala new file mode 100644 index 0000000..1817586 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala @@ -0,0 +1,23 @@ +package de.nowchess.rules.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() diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala new file mode 100644 index 0000000..0be5f92 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.rules.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 diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala new file mode 100644 index 0000000..4d52c10 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala @@ -0,0 +1,8 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer} +import de.nowchess.api.board.Square + +class SquareKeyDeserializer extends KeyDeserializer: + override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef = + Square.fromAlgebraic(key).orNull diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala new file mode 100644 index 0000000..3ef02a5 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.board.Square + +class SquareKeySerializer extends JsonSerializer[Square]: + override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeFieldName(value.toString) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala new file mode 100644 index 0000000..93aaca9 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.rules.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) 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 index 0b7f6c3..de25463 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -1,5 +1,7 @@ package de.nowchess.rules.resource +import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext import de.nowchess.rules.dto.* import de.nowchess.rules.sets.DefaultRules import jakarta.enterprise.context.ApplicationScoped @@ -12,9 +14,8 @@ 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) + private def parseSquare(s: String): Square = + Square.fromAlgebraic(s).getOrElse(throw new BadRequestException(s"Invalid square: $s")) // scalafix:on DisableSyntax.throw @POST @@ -22,73 +23,67 @@ class RuleSetResource: @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)) + MovesResponse(rules.candidateMoves(req.context)(parseSquare(req.square))) @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)) + MovesResponse(rules.legalMoves(req.context)(parseSquare(req.square))) @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)) + MovesResponse(rules.allLegalMoves(req.context)) @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)))) + BooleanResponse(rules.isCheck(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)))) + BooleanResponse(rules.isCheckmate(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)))) + BooleanResponse(rules.isStalemate(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)))) + BooleanResponse(rules.isInsufficientMaterial(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)))) + BooleanResponse(rules.isFiftyMoveRule(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)))) + BooleanResponse(rules.isThreefoldRepetition(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)) + def applyMove(req: ContextMoveRequest): GameContext = + rules.applyMove(req.context)(req.move) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala index 9d89e29..27c738d 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala @@ -1,14 +1,30 @@ package de.nowchess.rules.config import com.fasterxml.jackson.databind.ObjectMapper +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.move.MoveType import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers class JacksonConfigTest extends AnyFunSuite with Matchers: - test("customize registers DefaultScalaModule enabling Option serialization"): - val config = new JacksonConfig() - val mapper = new ObjectMapper() - config.customize(mapper) + private val mapper: ObjectMapper = + val m = new ObjectMapper() + new JacksonConfig().customize(m) + m + + test("customize enables Option serialization via DefaultScalaModule"): mapper.writeValueAsString(None) shouldBe "null" mapper.writeValueAsString(Some("hello")) shouldBe """"hello"""" + + test("customize registers SquareSerializer"): + mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4"""" + + test("customize registers MoveTypeSerializer"): + mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}""" + + test("customize registers SquareDeserializer"): + mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4) + + test("customize registers MoveTypeDeserializer"): + mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant 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 deleted file mode 100644 index 4bdaa43..0000000 --- a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala +++ /dev/null @@ -1,174 +0,0 @@ -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") - - test("toGameContext round-trips a valid en passant square"): - val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3))) - DtoMapper.toGameContext(DtoMapper.fromGameContext(ctx)).map(_.enPassantSquare) shouldBe Right( - Some(Square(File.E, Rank.R3)), - ) - - // ── 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/json/JsonSerializersTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala new file mode 100644 index 0000000..a14769b --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala @@ -0,0 +1,98 @@ +package de.nowchess.rules.json + +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.{File, Rank, Square} +import de.nowchess.api.move.{MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonSerializersTest extends AnyFunSuite with Matchers: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + val mod = new SimpleModule() + m.registerModule(DefaultScalaModule) + 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()) + m.registerModule(mod) + m + + private val e4 = Square(File.E, Rank.R4) + + // ── SquareKeySerializer ─────────────────────────────────────────── + + test("SquareKeySerializer writes square as map field name"): + mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}""" + + // ── SquareKeyDeserializer ───────────────────────────────────────── + + test("SquareKeyDeserializer returns square for valid key"): + new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4 + + test("SquareKeyDeserializer returns null for invalid key"): + new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null + + // ── SquareSerializer/Deserializer ───────────────────────────────── + + test("SquareSerializer writes square as string"): + mapper.writeValueAsString(e4) shouldBe """"e4"""" + + test("SquareDeserializer reads valid square string"): + mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4 + + test("SquareDeserializer returns null for invalid square string"): + mapper.readValue(""""z9"""", classOf[Square]) shouldBe null + + // ── MoveTypeSerializer ──────────────────────────────────────────── + + test("MoveTypeSerializer serializes Normal non-capture"): + mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}""" + + test("MoveTypeSerializer serializes Normal capture"): + mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}""" + + test("MoveTypeSerializer serializes CastleKingside"): + mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}""" + + test("MoveTypeSerializer serializes CastleQueenside"): + mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}""" + + test("MoveTypeSerializer serializes EnPassant"): + mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}""" + + test("MoveTypeSerializer serializes Promotion"): + mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe + """{"type":"promotion","piece":"Queen"}""" + + // ── MoveTypeDeserializer ────────────────────────────────────────── + + test("MoveTypeDeserializer deserializes normal non-capture"): + mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe + MoveType.Normal(false) + + test("MoveTypeDeserializer deserializes normal capture"): + mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe + MoveType.Normal(true) + + test("MoveTypeDeserializer deserializes castleKingside"): + mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside + + test("MoveTypeDeserializer deserializes castleQueenside"): + mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside + + test("MoveTypeDeserializer deserializes enPassant"): + mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant + + test("MoveTypeDeserializer deserializes promotion"): + mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe + MoveType.Promotion(PromotionPiece.Rook) + + test("MoveTypeDeserializer throws for unknown type"): + an[Exception] should be thrownBy + mapper.readValue("""{"type":"unknown"}""", classOf[MoveType]) 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 index bb7209a..4879b37 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -1,11 +1,11 @@ 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.config.JacksonConfig +import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest} import de.nowchess.rules.sets.DefaultRules import io.quarkus.test.junit.QuarkusTest import io.restassured.RestAssured @@ -16,21 +16,25 @@ import org.junit.jupiter.api.Test @QuarkusTest class RuleSetResourceTest: - private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) - private val rules = DefaultRules + private val mapper: ObjectMapper = + val m = new ObjectMapper() + new JacksonConfig().customize(m) + m + + 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))) + toJson(ContextRequest(ctx)) private def contextSquareBody(ctx: GameContext, square: String): String = - toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square)) + toJson(ContextSquareRequest(ctx, square)) private def contextMoveBody(ctx: GameContext, move: Move): String = - toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move))) + toJson(ContextMoveRequest(ctx, move)) // ── all-legal-moves ─────────────────────────────────────────────── @@ -238,7 +242,7 @@ class RuleSetResourceTest: def invalidSquare_returns400(): Unit = request() .contentType(ContentType.JSON) - .body(toJson(ContextSquareRequest(DtoMapper.fromGameContext(GameContext.initial), "z9"))) + .body(toJson(ContextSquareRequest(GameContext.initial, "z9"))) .when() .post("/api/rules/legal-moves") .`then`() diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala index b32f55d..277e869 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -2,8 +2,8 @@ package de.nowchess.rules.resource 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, MoveType} -import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper} +import de.nowchess.api.move.Move +import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest} import de.nowchess.rules.sets.DefaultRules import jakarta.ws.rs.BadRequestException import org.scalatest.funsuite.AnyFunSuite @@ -14,9 +14,9 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: private val resource = new RuleSetResource() private val rules = DefaultRules - private def ctx(g: GameContext) = ContextRequest(DtoMapper.fromGameContext(g)) - private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(DtoMapper.fromGameContext(g), sq) - private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(DtoMapper.fromGameContext(g), DtoMapper.fromMove(m)) + private def ctx(g: GameContext) = ContextRequest(g) + private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(g, sq) + private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(g, m) // ── position builders ───────────────────────────────────────────── @@ -104,12 +104,6 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: test("isCheck returns true when king is attacked"): resource.isCheck(ctx(checkContext())).result shouldBe true - test("isCheck throws BadRequestException for invalid context"): - an[BadRequestException] should be thrownBy - resource.isCheck( - ctx(GameContext.initial).copy(context = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")), - ) - // ── isCheckmate ─────────────────────────────────────────────────── test("isCheckmate returns false for initial position"): @@ -157,11 +151,4 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: .legalMoves(GameContext.initial)(Square(File.E, Rank.R2)) .find(_.to == Square(File.E, Rank.R4)) .get - resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe "Black" - - test("applyMove throws BadRequestException for invalid move"): - val badMove = DtoMapper - .fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))) - .copy(moveType = "unknown") - an[BadRequestException] should be thrownBy - resource.applyMove(ContextMoveRequest(DtoMapper.fromGameContext(GameContext.initial), badMove)) + resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe Color.Black -- 2.52.0 From 934568e7168c0ef06127f28dc97e663caa0f6fed Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 21:00:36 +0200 Subject: [PATCH 07/15] fix(rules): Serializers Added serializers like in IO --- .../scala/de/nowchess/rules/dto/Dtos.scala | 6 -- .../rules/resource/RuleSetResource.scala | 37 +++++------ .../rules/resource/RuleSetResourceTest.scala | 61 +++++++++---------- .../resource/RuleSetResourceUnitTest.scala | 33 +++++----- 4 files changed, 64 insertions(+), 73 deletions(-) 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 index d06609c..4edf90f 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala @@ -3,12 +3,6 @@ package de.nowchess.rules.dto import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move -case class ContextRequest(context: GameContext) - case class ContextSquareRequest(context: GameContext, square: String) case class ContextMoveRequest(context: GameContext, move: Move) - -case class MovesResponse(moves: List[Move]) - -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 index de25463..d128519 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -2,6 +2,7 @@ package de.nowchess.rules.resource import de.nowchess.api.board.Square import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move import de.nowchess.rules.dto.* import de.nowchess.rules.sets.DefaultRules import jakarta.enterprise.context.ApplicationScoped @@ -22,64 +23,64 @@ class RuleSetResource: @Path("/candidate-moves") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def candidateMoves(req: ContextSquareRequest): MovesResponse = - MovesResponse(rules.candidateMoves(req.context)(parseSquare(req.square))) + def candidateMoves(req: ContextSquareRequest): List[Move] = + rules.candidateMoves(req.context)(parseSquare(req.square)) @POST @Path("/legal-moves") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def legalMoves(req: ContextSquareRequest): MovesResponse = - MovesResponse(rules.legalMoves(req.context)(parseSquare(req.square))) + def legalMoves(req: ContextSquareRequest): List[Move] = + rules.legalMoves(req.context)(parseSquare(req.square)) @POST @Path("/all-legal-moves") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def allLegalMoves(req: ContextRequest): MovesResponse = - MovesResponse(rules.allLegalMoves(req.context)) + def allLegalMoves(ctx: GameContext): List[Move] = + rules.allLegalMoves(ctx) @POST @Path("/is-check") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isCheck(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isCheck(req.context)) + def isCheck(ctx: GameContext): Boolean = + rules.isCheck(ctx) @POST @Path("/is-checkmate") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isCheckmate(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isCheckmate(req.context)) + def isCheckmate(ctx: GameContext): Boolean = + rules.isCheckmate(ctx) @POST @Path("/is-stalemate") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isStalemate(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isStalemate(req.context)) + def isStalemate(ctx: GameContext): Boolean = + rules.isStalemate(ctx) @POST @Path("/is-insufficient-material") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isInsufficientMaterial(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isInsufficientMaterial(req.context)) + def isInsufficientMaterial(ctx: GameContext): Boolean = + rules.isInsufficientMaterial(ctx) @POST @Path("/is-fifty-move-rule") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isFiftyMoveRule(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isFiftyMoveRule(req.context)) + def isFiftyMoveRule(ctx: GameContext): Boolean = + rules.isFiftyMoveRule(ctx) @POST @Path("/is-threefold-repetition") @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) - def isThreefoldRepetition(req: ContextRequest): BooleanResponse = - BooleanResponse(rules.isThreefoldRepetition(req.context)) + def isThreefoldRepetition(ctx: GameContext): Boolean = + rules.isThreefoldRepetition(ctx) @POST @Path("/apply-move") 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 index 4879b37..1e2f9a6 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -5,7 +5,7 @@ import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceTy import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.rules.config.JacksonConfig -import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest} +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} import de.nowchess.rules.sets.DefaultRules import io.quarkus.test.junit.QuarkusTest import io.restassured.RestAssured @@ -27,9 +27,6 @@ class RuleSetResourceTest: private def toJson(value: AnyRef): String = mapper.writeValueAsString(value) - private def contextBody(ctx: GameContext): String = - toJson(ContextRequest(ctx)) - private def contextSquareBody(ctx: GameContext, square: String): String = toJson(ContextSquareRequest(ctx, square)) @@ -42,12 +39,12 @@ class RuleSetResourceTest: def allLegalMoves_initialPositionHas20Moves(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/all-legal-moves") .`then`() .statusCode(200) - .body("moves.size()", is(20)) + .body("size()", is(20)) // ── legal-moves ─────────────────────────────────────────────────── @@ -60,7 +57,7 @@ class RuleSetResourceTest: .post("/api/rules/legal-moves") .`then`() .statusCode(200) - .body("moves.size()", is(2)) + .body("size()", is(2)) // ── candidate-moves ─────────────────────────────────────────────── @@ -73,7 +70,7 @@ class RuleSetResourceTest: .post("/api/rules/candidate-moves") .`then`() .statusCode(200) - .body("moves.size()", is(2)) + .body("size()", is(2)) // ── is-check ────────────────────────────────────────────────────── @@ -81,23 +78,23 @@ class RuleSetResourceTest: def isCheck_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-check") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isCheck_trueWhenKingAttacked(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(buildCheckContext())) + .body(toJson(buildCheckContext())) .when() .post("/api/rules/is-check") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── is-checkmate ────────────────────────────────────────────────── @@ -105,23 +102,23 @@ class RuleSetResourceTest: def isCheckmate_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-checkmate") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isCheckmate_trueForFoolsMate(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(buildFoolsMate())) + .body(toJson(buildFoolsMate())) .when() .post("/api/rules/is-checkmate") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── is-stalemate ────────────────────────────────────────────────── @@ -129,23 +126,23 @@ class RuleSetResourceTest: def isStalemate_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-stalemate") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isStalemate_trueForStalematePosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(buildStalemateContext())) + .body(toJson(buildStalemateContext())) .when() .post("/api/rules/is-stalemate") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── is-insufficient-material ────────────────────────────────────── @@ -153,23 +150,23 @@ class RuleSetResourceTest: def isInsufficientMaterial_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-insufficient-material") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isInsufficientMaterial_trueForKingsOnly(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(buildKingsOnlyContext())) + .body(toJson(buildKingsOnlyContext())) .when() .post("/api/rules/is-insufficient-material") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── is-fifty-move-rule ──────────────────────────────────────────── @@ -177,23 +174,23 @@ class RuleSetResourceTest: def isFiftyMoveRule_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-fifty-move-rule") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isFiftyMoveRule_trueWhenClockAt100(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial.copy(halfMoveClock = 100))) + .body(toJson(GameContext.initial.copy(halfMoveClock = 100))) .when() .post("/api/rules/is-fifty-move-rule") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── is-threefold-repetition ─────────────────────────────────────── @@ -201,23 +198,23 @@ class RuleSetResourceTest: def isThreefoldRepetition_falseForInitialPosition(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(GameContext.initial)) + .body(toJson(GameContext.initial)) .when() .post("/api/rules/is-threefold-repetition") .`then`() .statusCode(200) - .body("result", is(false)) + .body(is("false")) @Test def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit = request() .contentType(ContentType.JSON) - .body(contextBody(buildThreefoldContext())) + .body(toJson(buildThreefoldContext())) .when() .post("/api/rules/is-threefold-repetition") .`then`() .statusCode(200) - .body("result", is(true)) + .body(is("true")) // ── apply-move ──────────────────────────────────────────────────── diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala index 277e869..4159df5 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -3,7 +3,7 @@ package de.nowchess.rules.resource 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} +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} import de.nowchess.rules.sets.DefaultRules import jakarta.ws.rs.BadRequestException import org.scalatest.funsuite.AnyFunSuite @@ -14,7 +14,6 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: private val resource = new RuleSetResource() private val rules = DefaultRules - private def ctx(g: GameContext) = ContextRequest(g) private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(g, sq) private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(g, m) @@ -76,12 +75,12 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: // ── allLegalMoves ───────────────────────────────────────────────── test("allLegalMoves returns 20 moves for initial position"): - resource.allLegalMoves(ctx(GameContext.initial)).moves should have size 20 + resource.allLegalMoves(GameContext.initial) should have size 20 // ── legalMoves ──────────────────────────────────────────────────── test("legalMoves returns 2 moves for e2 pawn"): - resource.legalMoves(ctxSq(GameContext.initial, "e2")).moves should have size 2 + resource.legalMoves(ctxSq(GameContext.initial, "e2")) should have size 2 test("legalMoves throws BadRequestException for invalid square"): an[BadRequestException] should be thrownBy @@ -90,7 +89,7 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: // ── candidateMoves ──────────────────────────────────────────────── test("candidateMoves returns moves for e2 pawn"): - resource.candidateMoves(ctxSq(GameContext.initial, "e2")).moves should not be empty + resource.candidateMoves(ctxSq(GameContext.initial, "e2")) should not be empty test("candidateMoves throws BadRequestException for invalid square"): an[BadRequestException] should be thrownBy @@ -99,50 +98,50 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: // ── isCheck ─────────────────────────────────────────────────────── test("isCheck returns false for initial position"): - resource.isCheck(ctx(GameContext.initial)).result shouldBe false + resource.isCheck(GameContext.initial) shouldBe false test("isCheck returns true when king is attacked"): - resource.isCheck(ctx(checkContext())).result shouldBe true + resource.isCheck(checkContext()) shouldBe true // ── isCheckmate ─────────────────────────────────────────────────── test("isCheckmate returns false for initial position"): - resource.isCheckmate(ctx(GameContext.initial)).result shouldBe false + resource.isCheckmate(GameContext.initial) shouldBe false test("isCheckmate returns true for Fool's mate"): - resource.isCheckmate(ctx(foolsMate())).result shouldBe true + resource.isCheckmate(foolsMate()) shouldBe true // ── isStalemate ─────────────────────────────────────────────────── test("isStalemate returns false for initial position"): - resource.isStalemate(ctx(GameContext.initial)).result shouldBe false + resource.isStalemate(GameContext.initial) shouldBe false test("isStalemate returns true for stalemate position"): - resource.isStalemate(ctx(stalemateContext())).result shouldBe true + resource.isStalemate(stalemateContext()) shouldBe true // ── isInsufficientMaterial ──────────────────────────────────────── test("isInsufficientMaterial returns false for initial position"): - resource.isInsufficientMaterial(ctx(GameContext.initial)).result shouldBe false + resource.isInsufficientMaterial(GameContext.initial) shouldBe false test("isInsufficientMaterial returns true for kings only"): - resource.isInsufficientMaterial(ctx(kingsOnlyContext())).result shouldBe true + resource.isInsufficientMaterial(kingsOnlyContext()) shouldBe true // ── isFiftyMoveRule ─────────────────────────────────────────────── test("isFiftyMoveRule returns false for initial position"): - resource.isFiftyMoveRule(ctx(GameContext.initial)).result shouldBe false + resource.isFiftyMoveRule(GameContext.initial) shouldBe false test("isFiftyMoveRule returns true when halfMoveClock is 100"): - resource.isFiftyMoveRule(ctx(GameContext.initial.copy(halfMoveClock = 100))).result shouldBe true + resource.isFiftyMoveRule(GameContext.initial.copy(halfMoveClock = 100)) shouldBe true // ── isThreefoldRepetition ───────────────────────────────────────── test("isThreefoldRepetition returns false for initial position"): - resource.isThreefoldRepetition(ctx(GameContext.initial)).result shouldBe false + resource.isThreefoldRepetition(GameContext.initial) shouldBe false test("isThreefoldRepetition returns true after repeated moves"): - resource.isThreefoldRepetition(ctx(threefoldContext())).result shouldBe true + resource.isThreefoldRepetition(threefoldContext()) shouldBe true // ── applyMove ───────────────────────────────────────────────────── -- 2.52.0 From 80d315a67e3040a44b52cf86ccbd146c0601b841 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 21:05:10 +0200 Subject: [PATCH 08/15] fix(rules): Serializers Added Native Reflection Config --- .../rules/config/NativeReflectionConfig.scala | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala new file mode 100644 index 0000000..2d6430d --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala @@ -0,0 +1,28 @@ +package de.nowchess.rules.config + +import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.{DrawReason, GameContext, GameResult} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} +import io.quarkus.runtime.annotations.RegisterForReflection + +@RegisterForReflection( + targets = Array( + classOf[ContextSquareRequest], + classOf[ContextMoveRequest], + classOf[GameContext], + classOf[GameResult], + classOf[DrawReason], + classOf[Color], + classOf[Piece], + classOf[PieceType], + classOf[CastlingRights], + classOf[Square], + classOf[File], + classOf[Rank], + classOf[Move], + classOf[MoveType], + classOf[PromotionPiece], + ), +) +class NativeReflectionConfig -- 2.52.0 From df76b53714dd8c9882b0915c4046bcc932dad3a5 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 21:15:14 +0200 Subject: [PATCH 09/15] fix(rules): Serializers Added small changes --- .../scala/de/nowchess/rules/json/MoveTypeDeserializer.scala | 2 ++ .../scala/de/nowchess/rules/json/JsonSerializersTest.scala | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala index d0f0c8f..00cee39 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala @@ -6,6 +6,7 @@ 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 @@ -15,3 +16,4 @@ class MoveTypeDeserializer extends JsonDeserializer[MoveType]: 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 diff --git a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala index a14769b..e832fcb 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala @@ -32,11 +32,13 @@ class JsonSerializersTest extends AnyFunSuite with Matchers: // ── SquareKeyDeserializer ───────────────────────────────────────── + // scalafix:off DisableSyntax.null test("SquareKeyDeserializer returns square for valid key"): new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4 test("SquareKeyDeserializer returns null for invalid key"): new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null + // scalafix:on DisableSyntax.null // ── SquareSerializer/Deserializer ───────────────────────────────── @@ -46,8 +48,10 @@ class JsonSerializersTest extends AnyFunSuite with Matchers: test("SquareDeserializer reads valid square string"): mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4 + // scalafix:off DisableSyntax.null test("SquareDeserializer returns null for invalid square string"): mapper.readValue(""""z9"""", classOf[Square]) shouldBe null + // scalafix:on DisableSyntax.null // ── MoveTypeSerializer ──────────────────────────────────────────── -- 2.52.0 From 48ce1fbdb2a53824cdda82df4238fc55e60292d3 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 21 Apr 2026 23:33:52 +0200 Subject: [PATCH 10/15] feat(core): core dependecy to rules Added communication between core and rules via REST --- .../de/nowchess/api}/rules/RuleSet.scala | 4 +- .../de/nowchess/bot/bots/ClassicalBot.scala | 2 +- .../de/nowchess/bot/bots/HybridBot.scala | 2 +- .../scala/de/nowchess/bot/bots/NNUEBot.scala | 2 +- .../nowchess/bot/logic/AlphaBetaSearch.scala | 2 +- .../de/nowchess/bot/AlphaBetaSearchTest.scala | 2 +- .../de/nowchess/bot/ClassicalBotTest.scala | 2 +- .../scala/de/nowchess/bot/HybridBotTest.scala | 2 +- modules/core/build.gradle.kts | 2 +- .../core/src/main/resources/application.yml | 2 + .../chess/adapter/RuleSetRestAdapter.scala | 51 +++++++++++++ .../chess/client/RuleServiceClient.scala | 74 +++++++++++++++++++ .../nowchess/chess/config/JacksonConfig.scala | 14 +++- .../de/nowchess/chess/engine/GameEngine.scala | 5 +- .../chess/json/MoveTypeDeserializer.scala | 19 +++++ .../chess/json/MoveTypeSerializer.scala | 23 ++++++ .../chess/json/SquareDeserializer.scala | 11 +++ .../chess/json/SquareSerializer.scala | 9 +++ .../chess/resource/GameResource.scala | 6 +- .../chess/engine/EngineTestHelpers.scala | 2 +- .../engine/GameEngineDrawOfferTest.scala | 41 +++++----- .../engine/GameEngineGameEndingTest.scala | 7 +- .../engine/GameEngineIntegrationTest.scala | 24 +++--- .../chess/engine/GameEngineLoadGameTest.scala | 12 ++- .../chess/engine/GameEngineNotationTest.scala | 9 ++- .../engine/GameEnginePromotionTest.scala | 4 +- .../chess/engine/GameEngineResignTest.scala | 11 +-- .../chess/registry/GameRegistryImplTest.scala | 7 +- .../GameResourceIntegrationTest.scala | 35 ++++++++- .../rule/src/main/resources/application.yml | 2 +- .../de/nowchess/rules/sets/DefaultRules.scala | 2 +- 31 files changed, 316 insertions(+), 74 deletions(-) rename modules/{rule/src/main/scala/de/nowchess => api/src/main/scala/de/nowchess/api}/rules/RuleSet.scala (98%) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala create mode 100644 modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala similarity index 98% rename from modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala rename to modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala index f2622bd..535b655 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala +++ b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala @@ -1,7 +1,7 @@ -package de.nowchess.rules +package de.nowchess.api.rules -import de.nowchess.api.game.GameContext import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move /** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala index 0573110..a52e5b9 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala @@ -7,7 +7,7 @@ import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class ClassicalBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index 3a9147a..fd95d0d 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -9,7 +9,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class HybridBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala index 094989d..bfa2c6f 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala @@ -7,7 +7,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.{PolyglotBook, ZobristHash} import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class NNUEBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index 8a58b21..cf41d70 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.util.ZobristHash -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import java.util.concurrent.atomic.{AtomicInteger, AtomicLong} diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala index 609f977..da826bd 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala @@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import de.nowchess.rules.sets.DefaultRules diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala index 30b0214..bf53263 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala @@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.api.move.MoveType import de.nowchess.bot.bots.ClassicalBot -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import de.nowchess.rules.sets.DefaultRules diff --git a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala index b1e7e81..bf806c2 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala @@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.HybridBot import de.nowchess.bot.util.{PolyglotBook, PolyglotHash} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index d057a24..8596d8e 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -48,7 +48,6 @@ dependencies { } implementation(project(":modules:api")) - implementation(project(":modules:rule")) implementation(project(":modules:bot")) @@ -69,6 +68,7 @@ dependencies { testImplementation(project(":modules:io")) + testImplementation(project(":modules:rule")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml index 1663bc0..bbc7bff 100644 --- a/modules/core/src/main/resources/application.yml +++ b/modules/core/src/main/resources/application.yml @@ -6,3 +6,5 @@ quarkus: rest-client: io-service: url: http://localhost:8081 + rule-service: + url: http://localhost:8082 diff --git a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala new file mode 100644 index 0000000..a281095 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala @@ -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)) diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala new file mode 100644 index 0000000..b213ef4 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala @@ -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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala index 1393880..87b8ba7 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala @@ -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) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 0759048..21903ca 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -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")), diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala new file mode 100644 index 0000000..969d614 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala @@ -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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala new file mode 100644 index 0000000..8d90eba --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala @@ -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() diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala new file mode 100644 index 0000000..a14b84f --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala @@ -0,0 +1,11 @@ +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]: + // scalafix:off DisableSyntax.null + override def deserialize(p: JsonParser, ctx: DeserializationContext): Square = + Square.fromAlgebraic(p.getText).orNull + // scalafix:on DisableSyntax.null diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala new file mode 100644 index 0000000..98240ac --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala @@ -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) diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index c09e0d0..8ab682e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala index 3a32668..7c49e73 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala @@ -13,7 +13,7 @@ object EngineTestHelpers: new GameEngine(ruleSet = DefaultRules) def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine = - GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) + GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn), ruleSet = DefaultRules) def loadFen(engine: GameEngine, fen: String): Unit = engine.loadGame(FenParser, fen) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala index 03b39a6..872ce56 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -1,5 +1,6 @@ package de.nowchess.chess.engine +import de.nowchess.rules.sets.DefaultRules import scala.collection.mutable import de.nowchess.api.board.Color import de.nowchess.api.game.{DrawReason, GameResult} @@ -18,7 +19,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: test("White offers draw"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -32,7 +33,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferEvent, but got $other") test("Black accepts White's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -49,7 +50,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("Black declines White's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -65,7 +66,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferDeclinedEvent, but got $other") test("Black offers draw"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -79,7 +80,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferEvent, but got $other") test("White accepts Black's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -96,7 +97,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("Cannot accept draw when no offer pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -110,7 +111,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot decline draw when no offer pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -124,7 +125,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot offer draw when game is already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -147,7 +148,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot accept your own draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -163,7 +164,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot decline your own draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -179,7 +180,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot make second draw offer when one is already pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -195,7 +196,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Draw offer is cleared when game ends by resignation (accept)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -215,7 +216,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Draw offer is cleared when game ends by resignation (decline)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -235,22 +236,22 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("pendingDrawOfferBy returns None initially"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.pendingDrawOfferBy shouldBe None test("pendingDrawOfferBy returns White after White offers"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.offerDraw(Color.White) engine.pendingDrawOfferBy shouldBe Some(Color.White) test("pendingDrawOfferBy returns None after draw is accepted"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.offerDraw(Color.White) engine.acceptDraw(Color.Black) engine.pendingDrawOfferBy shouldBe None test("applyDraw sets draw result when game not over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) engine.applyDraw(DrawReason.Agreement) @@ -263,7 +264,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("applyDraw does nothing when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // End the game with checkmate @@ -276,7 +277,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: observer.events should have length 0 test("claimDraw with fifty-move rule when at half-move 100"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // Play moves to reach fifty-move rule claim @@ -288,7 +289,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: // This is hard to do naturally; skip for now if not critical test("claimDraw when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // End the game with checkmate diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala index 9e491e8..4bb9207 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala @@ -1,5 +1,6 @@ package de.nowchess.chess.engine +import de.nowchess.rules.sets.DefaultRules import scala.collection.mutable import de.nowchess.api.board.Color import de.nowchess.api.game.DrawReason @@ -11,7 +12,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineGameEndingTest extends AnyFunSuite with Matchers: test("GameEngine handles Checkmate (Fool's Mate)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) @@ -31,7 +32,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: fail(s"Expected CheckmateEvent, but got $other") test("GameEngine handles check detection"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) @@ -53,7 +54,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: // Wait, let's just use Sam Loyd's 10-move stalemate: // 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6 test("GameEngine handles Stalemate via 10-move known sequence"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index e07336b..5411007 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer} import de.nowchess.api.io.GameContextImport -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -21,7 +21,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: events test("accessors expose redo availability and command history"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.canRedo shouldBe false engine.commandHistory shouldBe empty @@ -30,7 +30,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.commandHistory.nonEmpty shouldBe true test("processUserInput handles undo redo empty and malformed commands"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("") @@ -44,7 +44,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: } should be >= 3 test("processUserInput emits Illegal move for syntactically valid but illegal target"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("e2e5") @@ -56,14 +56,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: test("loadGame returns Left when importer fails"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val failingImporter = new GameContextImport: def importGameContext(input: String): Either[String, GameContext] = Left("boom") engine.loadGame(failingImporter, "ignored") shouldBe Left("boom") test("loadPosition replaces context clears history and notifies reset"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("e2e4") @@ -78,7 +78,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: } shouldBe true test("redo event includes captured piece description when replaying a capture"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1") @@ -145,13 +145,13 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: test("loadGame replay executes non-promotion moves through default replay branch"): val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal()) - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.replayMoves(List(normalMove), engine.context) shouldBe Right(()) engine.context.moves.lastOption shouldBe Some(normalMove) test("replayMoves skips later moves after the first move triggers an error"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val saved = engine.context val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) val trailingMove = Move(sq("e2"), sq("e4")) @@ -160,19 +160,19 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.context shouldBe saved test("normalMoveNotation handles missing source piece"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false) result shouldBe "e4" test("pieceNotation default branch returns empty string"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val result = engine.pieceNotation(PieceType.Pawn) result shouldBe "" test("observerCount reflects subscribe and unsubscribe operations"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new Observer: def onGameEvent(event: GameEvent): Unit = () diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala index 91ac2b1..42c45df 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala @@ -1,5 +1,11 @@ package de.nowchess.chess.engine +import de.nowchess.rules.sets.DefaultRules +import scala.collection.mutable +import de.nowchess.api.board.{Board, Color} +import de.nowchess.api.game.GameContext +import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent} +import de.nowchess.io.pgn.PgnParser import de.nowchess.chess.observer.{GameEvent, Observer} import de.nowchess.io.fen.FenParser import de.nowchess.io.pgn.{PgnExporter, PgnParser} @@ -11,7 +17,7 @@ import scala.collection.mutable class GameEngineLoadGameTest extends AnyFunSuite with Matchers: test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val pgn = "[Event \"Test\"]\n\n1. e4 e5\n" val result = engine.loadGame(PgnParser, pgn) result shouldBe Right(()) @@ -19,7 +25,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: engine.canUndo shouldBe true test("loadGame with FenParser: loads position without replaying moves"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val result = engine.loadGame(FenParser, fen) result shouldBe Right(()) @@ -27,7 +33,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: engine.canUndo shouldBe false test("exportGame with PgnExporter: exports current game as PGN"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.processUserInput("e2e4") engine.processUserInput("e7e5") val pgn = engine.exportGame(PgnExporter) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala index e1c1877..6482b35 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Rank, Square} +import de.nowchess.rules.sets.DefaultRules import de.nowchess.api.game.GameContext import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* @@ -37,7 +38,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(castlingRights) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // White castles queenside: e1c1 @@ -65,7 +66,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withEnPassantSquare(epSquare) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // White pawn on e5 captures en passant to d6 @@ -96,7 +97,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) engine.processUserInput("e7e8b") @@ -117,7 +118,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // King moves e1 -> f1 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 4c86f17..edc74d7 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -14,7 +14,7 @@ import de.nowchess.chess.observer.{ MoveExecutedEvent, Observer, } -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: events private def engineWith(board: Board, turn: Color = Color.White): GameEngine = - new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) + new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn), ruleSet = DefaultRules) test("processUserInput without promotion suffix fires InvalidMoveEvent when pawn reaches back rank") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala index cf96cdf..0ee4395 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala @@ -1,5 +1,6 @@ package de.nowchess.chess.engine +import de.nowchess.rules.sets.DefaultRules import scala.collection.mutable import de.nowchess.api.board.Color import de.nowchess.api.game.GameResult @@ -10,7 +11,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineResignTest extends AnyFunSuite with Matchers: test("White resigns"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -25,7 +26,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected ResignEvent, but got $other") test("Black resigns"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -40,7 +41,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected ResignEvent, but got $other") test("Cannot resign when game is already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -64,7 +65,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("resign() without color resigns side to move"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -73,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: engine.context.result shouldBe Some(GameResult.Win(Color.Black)) test("resign() without color does nothing when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala index 6bea0c0..f027e53 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.registry import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.rules.sets.DefaultRules import de.nowchess.chess.engine.GameEngine import io.quarkus.test.junit.QuarkusTest import jakarta.inject.Inject @@ -20,14 +21,14 @@ class GameRegistryImplTest: @Test @DisplayName("store saves entry") def testStore(): Unit = - val entry = GameEntry("g1", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry("g1", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) registry.store(entry) assertTrue(registry.get("g1").isDefined) @Test @DisplayName("get returns stored entry") def testGet(): Unit = - val entry = GameEntry("g2", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry("g2", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) registry.store(entry) val retrieved = registry.get("g2") assertTrue(retrieved.isDefined) @@ -41,7 +42,7 @@ class GameRegistryImplTest: @Test @DisplayName("update modifies existing entry") def testUpdate(): Unit = - val entry = GameEntry("g3", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry("g3", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) registry.store(entry) val updated = entry.copy(resigned = true) registry.update(updated) diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala index 1ee458e..abea233 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala @@ -1,11 +1,13 @@ package de.nowchess.chess.resource +import de.nowchess.api.board.Square import de.nowchess.api.dto.* import de.nowchess.api.game.GameContext -import de.nowchess.chess.client.IoServiceClient +import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest} import de.nowchess.chess.exception.BadRequestException import de.nowchess.io.fen.FenExporter import de.nowchess.io.pgn.PgnParser +import de.nowchess.rules.sets.DefaultRules import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest import jakarta.inject.Inject @@ -14,6 +16,7 @@ import org.junit.jupiter.api.{BeforeEach, DisplayName, Test} import org.junit.jupiter.api.Assertions.* import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.when +import org.mockito.invocation.InvocationOnMock import scala.compiletime.uninitialized @@ -29,6 +32,10 @@ class GameResourceIntegrationTest: @RestClient var ioClient: IoServiceClient = uninitialized + @InjectMock + @RestClient + var ruleClient: RuleServiceClient = uninitialized + @BeforeEach def setupMocks(): Unit = when(ioClient.importFen(any())).thenReturn(GameContext.initial) @@ -37,6 +44,32 @@ class GameResourceIntegrationTest: ) when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial)) when(ioClient.exportPgn(any())).thenReturn("1. e4 c5") + when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) => + val req = inv.getArgument[RuleSquareRequest](0) + DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get), + ) + when(ruleClient.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.allLegalMoves(inv.getArgument[GameContext](0)), + ) + when(ruleClient.applyMove(any())).thenAnswer((inv: InvocationOnMock) => + val req = inv.getArgument[RuleMoveRequest](0) + DefaultRules.applyMove(req.context)(req.move), + ) + when(ruleClient.isCheck(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isCheck(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isCheckmate(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isStalemate(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isStalemate(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isInsufficientMaterial(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)), + ) @Test @DisplayName("createGame returns 201") diff --git a/modules/rule/src/main/resources/application.yml b/modules/rule/src/main/resources/application.yml index 2c5486b..98b81ce 100644 --- a/modules/rule/src/main/resources/application.yml +++ b/modules/rule/src/main/resources/application.yml @@ -1,5 +1,5 @@ quarkus: http: - port: 8081 + port: 8082 application: name: rule-service diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index 036f00d..ed3bde2 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -3,7 +3,7 @@ package de.nowchess.rules.sets import de.nowchess.api.board.* import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import scala.annotation.tailrec -- 2.52.0 From 4ae360c228f352eb7d623112d9cb2b03d114592a Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 22 Apr 2026 00:02:02 +0200 Subject: [PATCH 11/15] fix(core): core dependecy to rules small fixes --- .../chess/json/SquareDeserializer.scala | 2 -- .../chess/registry/GameRegistryImplTest.scala | 21 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala index a14b84f..5641a8f 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala @@ -5,7 +5,5 @@ import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer} import de.nowchess.api.board.Square class SquareDeserializer extends JsonDeserializer[Square]: - // scalafix:off DisableSyntax.null override def deserialize(p: JsonParser, ctx: DeserializationContext): Square = Square.fromAlgebraic(p.getText).orNull - // scalafix:on DisableSyntax.null diff --git a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala index f027e53..53d1362 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala @@ -21,14 +21,24 @@ class GameRegistryImplTest: @Test @DisplayName("store saves entry") def testStore(): Unit = - val entry = GameEntry("g1", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g1", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) assertTrue(registry.get("g1").isDefined) @Test @DisplayName("get returns stored entry") def testGet(): Unit = - val entry = GameEntry("g2", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g2", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) val retrieved = registry.get("g2") assertTrue(retrieved.isDefined) @@ -42,7 +52,12 @@ class GameRegistryImplTest: @Test @DisplayName("update modifies existing entry") def testUpdate(): Unit = - val entry = GameEntry("g3", GameEngine(ruleSet = DefaultRules), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g3", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) val updated = entry.copy(resigned = true) registry.update(updated) -- 2.52.0 From 9a40054f439978de3cbdd81f0bfad6de8a0be7d0 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 22 Apr 2026 00:23:35 +0200 Subject: [PATCH 12/15] test(core): core dependecy to rules Added tests --- .../adapter/RuleSetRestAdapterTest.scala | 109 ++++++++++++++++++ .../chess/json/JsonSerializersTest.scala | 87 ++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala diff --git a/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala new file mode 100644 index 0000000..27147e2 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala @@ -0,0 +1,109 @@ +package de.nowchess.chess.adapter + +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType} +import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest} +import io.quarkus.test.InjectMock +import io.quarkus.test.junit.QuarkusTest +import jakarta.inject.Inject +import org.eclipse.microprofile.rest.client.inject.RestClient +import org.junit.jupiter.api.{BeforeEach, DisplayName, Test} +import org.junit.jupiter.api.Assertions.* +import org.mockito.Mockito.{verify, when} + +import scala.compiletime.uninitialized + +// scalafix:off +@QuarkusTest +@DisplayName("RuleSetRestAdapter") +class RuleSetRestAdapterTest: + + @Inject + var adapter: RuleSetRestAdapter = uninitialized + + @InjectMock + @RestClient + var client: RuleServiceClient = uninitialized + + private val ctx = GameContext.initial + private val sq = Square(File.E, Rank.R2) + private val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)) + + @BeforeEach + def setup(): Unit = + when(client.candidateMoves(RuleSquareRequest(ctx, sq.toString))).thenReturn(List(move)) + when(client.legalMoves(RuleSquareRequest(ctx, sq.toString))).thenReturn(List(move)) + when(client.allLegalMoves(ctx)).thenReturn(List(move)) + when(client.isCheck(ctx)).thenReturn(false) + when(client.isCheckmate(ctx)).thenReturn(false) + when(client.isStalemate(ctx)).thenReturn(false) + when(client.isInsufficientMaterial(ctx)).thenReturn(false) + when(client.isFiftyMoveRule(ctx)).thenReturn(false) + when(client.isThreefoldRepetition(ctx)).thenReturn(false) + when(client.applyMove(RuleMoveRequest(ctx, move))).thenReturn(ctx) + + @Test + @DisplayName("candidateMoves delegates to client") + def testCandidateMoves(): Unit = + val result = adapter.candidateMoves(ctx)(sq) + assertEquals(List(move), result) + verify(client).candidateMoves(RuleSquareRequest(ctx, sq.toString)) + + @Test + @DisplayName("legalMoves delegates to client") + def testLegalMoves(): Unit = + val result = adapter.legalMoves(ctx)(sq) + assertEquals(List(move), result) + verify(client).legalMoves(RuleSquareRequest(ctx, sq.toString)) + + @Test + @DisplayName("allLegalMoves delegates to client") + def testAllLegalMoves(): Unit = + val result = adapter.allLegalMoves(ctx) + assertEquals(List(move), result) + verify(client).allLegalMoves(ctx) + + @Test + @DisplayName("isCheck delegates to client") + def testIsCheck(): Unit = + assertFalse(adapter.isCheck(ctx)) + verify(client).isCheck(ctx) + + @Test + @DisplayName("isCheckmate delegates to client") + def testIsCheckmate(): Unit = + assertFalse(adapter.isCheckmate(ctx)) + verify(client).isCheckmate(ctx) + + @Test + @DisplayName("isStalemate delegates to client") + def testIsStalemate(): Unit = + assertFalse(adapter.isStalemate(ctx)) + verify(client).isStalemate(ctx) + + @Test + @DisplayName("isInsufficientMaterial delegates to client") + def testIsInsufficientMaterial(): Unit = + assertFalse(adapter.isInsufficientMaterial(ctx)) + verify(client).isInsufficientMaterial(ctx) + + @Test + @DisplayName("isFiftyMoveRule delegates to client") + def testIsFiftyMoveRule(): Unit = + assertFalse(adapter.isFiftyMoveRule(ctx)) + verify(client).isFiftyMoveRule(ctx) + + @Test + @DisplayName("isThreefoldRepetition delegates to client") + def testIsThreefoldRepetition(): Unit = + assertFalse(adapter.isThreefoldRepetition(ctx)) + verify(client).isThreefoldRepetition(ctx) + + @Test + @DisplayName("applyMove delegates to client") + def testApplyMove(): Unit = + val result = adapter.applyMove(ctx)(move) + assertEquals(ctx, result) + verify(client).applyMove(RuleMoveRequest(ctx, move)) +// scalafix:on diff --git a/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala new file mode 100644 index 0000000..a834aa1 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala @@ -0,0 +1,87 @@ +package de.nowchess.chess.json + +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.{File, Rank, Square} +import de.nowchess.api.move.{MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonSerializersTest extends AnyFunSuite with Matchers: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + val mod = new SimpleModule() + m.registerModule(DefaultScalaModule) + mod.addSerializer(classOf[Square], new SquareSerializer()) + mod.addDeserializer(classOf[Square], new SquareDeserializer()) + mod.addSerializer(classOf[MoveType], new MoveTypeSerializer()) + mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer()) + m.registerModule(mod) + m + + private val e4 = Square(File.E, Rank.R4) + + // ── SquareSerializer ────────────────────────────────────────────── + + test("SquareSerializer writes square as string"): + mapper.writeValueAsString(e4) shouldBe """"e4"""" + + // ── SquareDeserializer ──────────────────────────────────────────── + + test("SquareDeserializer reads valid square string"): + mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4 + + // scalafix:off DisableSyntax.null + test("SquareDeserializer returns null for invalid square string"): + mapper.readValue(""""z9"""", classOf[Square]) shouldBe null + // scalafix:on DisableSyntax.null + + // ── MoveTypeSerializer ──────────────────────────────────────────── + + test("MoveTypeSerializer serializes Normal non-capture"): + mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}""" + + test("MoveTypeSerializer serializes Normal capture"): + mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}""" + + test("MoveTypeSerializer serializes CastleKingside"): + mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}""" + + test("MoveTypeSerializer serializes CastleQueenside"): + mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}""" + + test("MoveTypeSerializer serializes EnPassant"): + mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}""" + + test("MoveTypeSerializer serializes Promotion"): + mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe + """{"type":"promotion","piece":"Queen"}""" + + // ── MoveTypeDeserializer ────────────────────────────────────────── + + test("MoveTypeDeserializer deserializes normal non-capture"): + mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe + MoveType.Normal(false) + + test("MoveTypeDeserializer deserializes normal capture"): + mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe + MoveType.Normal(true) + + test("MoveTypeDeserializer deserializes castleKingside"): + mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside + + test("MoveTypeDeserializer deserializes castleQueenside"): + mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside + + test("MoveTypeDeserializer deserializes enPassant"): + mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant + + test("MoveTypeDeserializer deserializes promotion"): + mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe + MoveType.Promotion(PromotionPiece.Rook) + + test("MoveTypeDeserializer throws for unknown type"): + an[Exception] should be thrownBy + mapper.readValue("""{"type":"unknown"}""", classOf[MoveType]) -- 2.52.0 From 9e9d34ed165c772b5351ad5c111156c4f3894114 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 22 Apr 2026 09:07:53 +0200 Subject: [PATCH 13/15] test(core): core dependecy to rules Added Quarkus file into ignore list --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 39ce424..5127534 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ val coverageExclusions = listOf( "**/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala", // JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration "**/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala", + //RuleSetRestAdapter - Quarkus integration of rule into core, only testable with Quarkus tests + "**/core/src/main/de/nowchess/chess/adapter/RuleSetRestAdapter.scala" ) // Converts a Sonar-style glob to a scoverage regex (matched against full source path). -- 2.52.0 From 32af755ba3b2cb1e0552eae1dddab91b153ae65c Mon Sep 17 00:00:00 2001 From: TeamCity Date: Wed, 22 Apr 2026 07:10:42 +0000 Subject: [PATCH 14/15] ci: bump version with Build-47 --- modules/api/CHANGELOG.md | 14 ++++++++++++++ modules/api/versions.env | 2 +- modules/core/CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ modules/core/versions.env | 2 +- modules/io/CHANGELOG.md | 16 ++++++++++++++++ modules/io/versions.env | 2 +- modules/rule/CHANGELOG.md | 13 +++++++++++++ modules/rule/versions.env | 2 +- 8 files changed, 78 insertions(+), 4 deletions(-) diff --git a/modules/api/CHANGELOG.md b/modules/api/CHANGELOG.md index 58834ef..8375b08 100644 --- a/modules/api/CHANGELOG.md +++ b/modules/api/CHANGELOG.md @@ -94,3 +94,17 @@ ### Bug Fixes * IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) +## (2026-04-22) + +### Features + +* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16)) +* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613)) +* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388)) +* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2)) +* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([5ad5efb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5ad5efb41e9df9e3dccb48f96a69f06217ab98e1)) +* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d)) + +### Bug Fixes + +* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) diff --git a/modules/api/versions.env b/modules/api/versions.env index 064746c..93254ff 100644 --- a/modules/api/versions.env +++ b/modules/api/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=11 +MINOR=12 PATCH=0 diff --git a/modules/core/CHANGELOG.md b/modules/core/CHANGELOG.md index 735ef58..dc07d33 100644 --- a/modules/core/CHANGELOG.md +++ b/modules/core/CHANGELOG.md @@ -431,3 +431,34 @@ * IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) * update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189)) * update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762)) +## (2026-04-22) + +### Features + +* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3)) +* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364)) +* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e)) +* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254)) +* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c)) +* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d)) +* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16)) +* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613)) +* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a)) +* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287)) +* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388)) +* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2)) +* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([5ad5efb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5ad5efb41e9df9e3dccb48f96a69f06217ab98e1)) +* NCS-40 Rework Draw System ([#34](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/34)) ([0091d50](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0091d50467e9f955f23570128b96c977c01bc51b)) +* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d)) +* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([9b51852](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9b5185298e9e721e6103ea8372ca29073913775c)) +* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07)) +* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747)) +* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c)) + +### Bug Fixes + +* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032)) +* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150)) +* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) +* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189)) +* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762)) diff --git a/modules/core/versions.env b/modules/core/versions.env index 79fed79..3a71f0b 100644 --- a/modules/core/versions.env +++ b/modules/core/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=19 +MINOR=20 PATCH=0 diff --git a/modules/io/CHANGELOG.md b/modules/io/CHANGELOG.md index e98eb05..07a2806 100644 --- a/modules/io/CHANGELOG.md +++ b/modules/io/CHANGELOG.md @@ -115,3 +115,19 @@ ### Bug Fixes * IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) +## (2026-04-22) + +### Features + +* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613)) +* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2)) +* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e)) +* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36)) +* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac)) +* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([5ad5efb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5ad5efb41e9df9e3dccb48f96a69f06217ab98e1)) +* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d)) +* NCS-53 changed IO to MicroService for easier scaling ([#37](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/37)) ([9b51852](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9b5185298e9e721e6103ea8372ca29073913775c)) + +### Bug Fixes + +* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([fb5c61d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fb5c61de63292e5d70c06304cba2193686aa1607)) diff --git a/modules/io/versions.env b/modules/io/versions.env index 05bdbf6..df11dba 100644 --- a/modules/io/versions.env +++ b/modules/io/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=13 +MINOR=14 PATCH=0 diff --git a/modules/rule/CHANGELOG.md b/modules/rule/CHANGELOG.md index 3cc3ac3..325426a 100644 --- a/modules/rule/CHANGELOG.md +++ b/modules/rule/CHANGELOG.md @@ -118,3 +118,16 @@ ### Bug Fixes * NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7)) +## (2026-04-22) + +### Features + +* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16)) +* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613)) +* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2)) +* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([5ad5efb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5ad5efb41e9df9e3dccb48f96a69f06217ab98e1)) +* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d)) + +### Bug Fixes + +* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7)) diff --git a/modules/rule/versions.env b/modules/rule/versions.env index 4906625..6a43e5f 100644 --- a/modules/rule/versions.env +++ b/modules/rule/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=8 +MINOR=9 PATCH=0 -- 2.52.0 From 80a06dcf1cfd712330303b5b6affb8f03607983e Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 22 Apr 2026 10:08:58 +0200 Subject: [PATCH 15/15] chore: update version numbers in versions.env --- .idea/scala_compiler.xml | 2 +- modules/api/CHANGELOG.md | 14 -------------- modules/api/versions.env | 2 +- modules/core/CHANGELOG.md | 31 ------------------------------- modules/core/versions.env | 2 +- modules/io/CHANGELOG.md | 16 ---------------- modules/io/versions.env | 2 +- modules/rule/CHANGELOG.md | 13 ------------- modules/rule/versions.env | 2 +- 9 files changed, 5 insertions(+), 79 deletions(-) diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml index 5c60cfe..f2eb6d2 100644 --- a/.idea/scala_compiler.xml +++ b/.idea/scala_compiler.xml @@ -5,7 +5,7 @@