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