feat: NCS-37 Quarkus integration (#35)

Reviewed-on: #35
Reviewed-by: Leon Hermann <lq@blackhole.local>
This commit is contained in:
2026-04-21 12:35:20 +02:00
parent 8a1cf909d4
commit f088c4e9ff
105 changed files with 2063 additions and 1255 deletions
+4 -1
View File
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
@@ -19,6 +21,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedFiles.set(scoverageExcluded)
}
configurations.scoverage {
@@ -31,7 +34,7 @@ configurations.scoverage {
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
@@ -0,0 +1,3 @@
package de.nowchess.api.dto
final case class ApiErrorDto(code: String, message: String, field: Option[String])
@@ -0,0 +1,6 @@
package de.nowchess.api.dto
final case class CreateGameRequestDto(
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
)
@@ -0,0 +1,6 @@
package de.nowchess.api.dto
final case class ErrorEventDto(`type`: String, error: ApiErrorDto)
object ErrorEventDto:
def apply(error: ApiErrorDto): ErrorEventDto = ErrorEventDto("error", error)
@@ -0,0 +1,8 @@
package de.nowchess.api.dto
final case class GameFullDto(
gameId: String,
white: PlayerInfoDto,
black: PlayerInfoDto,
state: GameStateDto,
)
@@ -0,0 +1,6 @@
package de.nowchess.api.dto
final case class GameFullEventDto(`type`: String, game: GameFullDto)
object GameFullEventDto:
def apply(game: GameFullDto): GameFullEventDto = GameFullEventDto("gameFull", game)
@@ -0,0 +1,12 @@
package de.nowchess.api.dto
final case class GameStateDto(
fen: String,
pgn: String,
turn: String,
status: String,
winner: Option[String],
moves: List[String],
undoAvailable: Boolean,
redoAvailable: Boolean,
)
@@ -0,0 +1,6 @@
package de.nowchess.api.dto
final case class GameStateEventDto(`type`: String, state: GameStateDto)
object GameStateEventDto:
def apply(state: GameStateDto): GameStateEventDto = GameStateEventDto("gameState", state)
@@ -0,0 +1,7 @@
package de.nowchess.api.dto
final case class ImportFenRequestDto(
fen: String,
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
)
@@ -0,0 +1,3 @@
package de.nowchess.api.dto
final case class ImportPgnRequestDto(pgn: String)
@@ -0,0 +1,9 @@
package de.nowchess.api.dto
final case class LegalMoveDto(
from: String,
to: String,
uci: String,
moveType: String,
promotion: Option[String],
)
@@ -0,0 +1,3 @@
package de.nowchess.api.dto
final case class LegalMovesResponseDto(moves: List[LegalMoveDto])
@@ -0,0 +1,3 @@
package de.nowchess.api.dto
final case class OkResponseDto(ok: Boolean = true)
@@ -0,0 +1,3 @@
package de.nowchess.api.dto
final case class PlayerInfoDto(id: String, displayName: String)
+4 -11
View File
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
@@ -26,16 +28,7 @@ scoverage {
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
)
)
excludedFiles.set(
listOf(
".*NNUE\\.scala",
".*NNUEBot\\.scala",
".*NbaiLoader\\.scala",
".*NbaiMigrator\\.scala",
".*NbaiWriter\\.scala",
".*PolyglotBook\\.scala",
)
)
excludedFiles.set(scoverageExcluded)
}
tasks.withType<ScalaCompile> {
@@ -44,7 +37,7 @@ tasks.withType<ScalaCompile> {
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
+5
View File
@@ -0,0 +1,5 @@
.gitignore
!build/*-runner
!build/*-runner.jar
!build/lib/*
!build/quarkus-app/*
+41
View File
@@ -0,0 +1,41 @@
# Gradle
.gradle/
build/
# Eclipse
.project
.classpath
.settings/
bin/
# IntelliJ
.idea
*.ipr
*.iml
*.iws
# NetBeans
nb-configuration.xml
# Visual Studio Code
.vscode
.factorypath
# OSX
.DS_Store
# Vim
*.swp
*.swo
# patch
*.orig
*.rej
# Local environment
.env
# Plugin directory
/.quarkus/cli/plugins/
# TLS Certificates
.certs/
+52 -3
View File
@@ -1,6 +1,7 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
}
group = "de.nowchess"
@@ -8,6 +9,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
@@ -19,15 +22,21 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedFiles.set(scoverageExcluded)
}
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 {
implementation("org.scala-lang:scala3-compiler_3") {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
@@ -43,19 +52,59 @@ dependencies {
implementation(project(":modules:rule"))
implementation(project(":modules:bot"))
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(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")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
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)
+100
View File
@@ -0,0 +1,100 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./gradlew build
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backcore-jvm .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
# You can find more information about the UBI base runtime images and their configuration here:
# https://rh-openjdk.github.io/redhat-openjdk-containers/
###
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
ENV LANGUAGE='en_US:en'
# We make four distinct layers so if there are application changes the library layers can be re-used
COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 build/quarkus-app/*.jar /deployments/
COPY --chown=185 build/quarkus-app/app/ /deployments/app/
COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
@@ -0,0 +1,96 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.package.jar.type=legacy-jar
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/backcore-legacy-jar .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
#
# If you want to include the debug port into your docker image
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
# when running the container
#
# Then run the container using :
#
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
#
# This image uses the `run-java.sh` script to run the application.
# This scripts computes the command line to execute your Java application, and
# includes memory/GC tuning.
# You can configure the behavior using the following environment properties:
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
# in JAVA_OPTS (example: "-Dsome.property=foo")
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
# used to calculate a default maximal heap memory based on a containers restriction.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
# of the container available memory as set here. The default is `50` which means 50%
# of the available memory is used as an upper boundary. You can skip this mechanism by
# setting this value to `0` in which case no `-Xmx` option is added.
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
# is used to calculate a default initial heap memory based on the maximum heap memory.
# If used in a container without any memory constraints for the container then this
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
# is used as the initial heap size. You can skip this mechanism by setting this value
# to `0` in which case no `-Xms` option is added (example: "25")
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
# This is used to calculate the maximum value of the initial heap memory. If used in
# a container without any memory constraints for the container then this option has
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
# here. The default is 4096MB which means the calculated value of `-Xms` never will
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
# when things are happening. This option, if set to true, will set
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
# true").
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
# (example: "20")
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
# (example: "40")
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
# (example: "4")
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
# previous GC times. (example: "90")
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
# contain the necessary JRE command-line options to specify the required GC, which
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
# accessed directly. (example: "foo.example.com,bar.example.com")
#
# You can find more information about the UBI base runtime images and their configuration here:
# https://rh-openjdk.github.io/redhat-openjdk-containers/
###
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
ENV LANGUAGE='en_US:en'
COPY build/lib/* /deployments/lib/
COPY build/*-runner.jar /deployments/quarkus-run.jar
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
@@ -0,0 +1,29 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.native.enabled=true
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore
#
# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
###
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,32 @@
####
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
# It uses a micro base image, tuned for Quarkus native executables.
# It reduces the size of the resulting container image.
# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
#
# Before building the container image run:
#
# ./gradlew build -Dquarkus.native.enabled=true
#
# Then, build the image with:
#
# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/backcore .
#
# Then run the container using:
#
# docker run -i --rm -p 8080:8080 quarkus/backcore
#
# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
###
FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
WORKDIR /work/
RUN chown 1001 /work \
&& chmod "g+rwX" /work \
&& chown 1001:root /work
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
EXPOSE 8080
USER 1001
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,27 @@
{
"reflection": [
{ "type": "scala.Tuple1[]" },
{ "type": "scala.Tuple2[]" },
{ "type": "scala.Tuple3[]" },
{ "type": "scala.Tuple4[]" },
{ "type": "scala.Tuple5[]" },
{ "type": "scala.Tuple6[]" },
{ "type": "scala.Tuple7[]" },
{ "type": "scala.Tuple8[]" },
{ "type": "scala.Tuple9[]" },
{ "type": "scala.Tuple10[]" },
{ "type": "scala.Tuple11[]" },
{ "type": "scala.Tuple12[]" },
{ "type": "scala.Tuple13[]" },
{ "type": "scala.Tuple14[]" },
{ "type": "scala.Tuple15[]" },
{ "type": "scala.Tuple16[]" },
{ "type": "scala.Tuple17[]" },
{ "type": "scala.Tuple18[]" },
{ "type": "scala.Tuple19[]" },
{ "type": "scala.Tuple20[]" },
{ "type": "scala.Tuple21[]" },
{ "type": "scala.Tuple22[]" },
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
]
}
@@ -0,0 +1,3 @@
greeting:
message: "hello"
@@ -0,0 +1,6 @@
-- This file allow to write SQL commands that will be emitted in test and dev.
-- The commands are commented as their support depends of the database
-- insert into myentity (id, field) values(1, 'field-1');
-- insert into myentity (id, field) values(2, 'field-2');
-- insert into myentity (id, field) values(3, 'field-3');
-- alter sequence myentity_seq restart with 4;
@@ -0,0 +1,17 @@
package de.nowchess.chess.config
import com.fasterxml.jackson.core.Version
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(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
})
@@ -0,0 +1,23 @@
package de.nowchess.chess.config
import de.nowchess.api.dto.*
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[ApiErrorDto],
classOf[CreateGameRequestDto],
classOf[ErrorEventDto],
classOf[GameFullDto],
classOf[GameFullEventDto],
classOf[GameStateDto],
classOf[GameStateEventDto],
classOf[ImportFenRequestDto],
classOf[ImportPgnRequestDto],
classOf[LegalMoveDto],
classOf[LegalMovesResponseDto],
classOf[OkResponseDto],
classOf[PlayerInfoDto],
),
)
class NativeReflectionConfig
@@ -38,9 +38,10 @@ class GameEngine(
private implicit val ec: ExecutionContext = ExecutionContext.global
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
/** Check if undo is available. */
def canUndo: Boolean = synchronized(invoker.canUndo)
@@ -67,21 +68,7 @@ class GameEngine(
performRedo()
case "draw" =>
if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else
notifyObservers(
InvalidMoveEvent(
currentContext,
InvalidMoveReason.DrawCannotBeClaimed,
),
)
claimDraw()
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput))
@@ -195,6 +182,21 @@ class GameEngine(
notifyObservers(DrawOfferDeclinedEvent(currentContext, color))
}
/** Claim a draw by fifty-move rule or threefold repetition. */
def claimDraw(): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
}
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
@@ -258,6 +260,22 @@ class GameEngine(
notifyObservers(BoardResetEvent(currentContext))
}
/** Resign the game on behalf of the side to move. */
def resign(): Unit = synchronized {
if currentContext.result.isEmpty then
val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
invoker.clear()
}
/** Apply a draw result directly (for agreement, fifty-move claim, etc.). */
def applyDraw(reason: DrawReason): Unit = synchronized {
if currentContext.result.isEmpty then
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
invoker.clear()
notifyObservers(DrawEvent(currentContext, reason))
}
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
@@ -0,0 +1,13 @@
package de.nowchess.chess.exception
class ApiException(
val status: Int,
val code: String,
message: String,
val field: Option[String] = None,
) extends RuntimeException(message)
class GameNotFoundException(gameId: String) extends ApiException(404, "GAME_NOT_FOUND", s"Game $gameId not found")
class BadRequestException(code: String, message: String, field: Option[String] = None)
extends ApiException(400, code, message, field)
@@ -0,0 +1,14 @@
package de.nowchess.chess.exception
import de.nowchess.api.dto.ApiErrorDto
import jakarta.ws.rs.core.{MediaType, Response}
import jakarta.ws.rs.ext.{ExceptionMapper, Provider}
@Provider
class ApiExceptionMapper extends ExceptionMapper[ApiException]:
def toResponse(ex: ApiException): Response =
Response
.status(ex.status)
.entity(ApiErrorDto(ex.code, ex.getMessage, ex.field))
.`type`(MediaType.APPLICATION_JSON)
.build()
@@ -0,0 +1,13 @@
package de.nowchess.chess.registry
import de.nowchess.api.board.Color
import de.nowchess.api.player.PlayerInfo
import de.nowchess.chess.engine.GameEngine
final case class GameEntry(
gameId: String,
engine: GameEngine,
white: PlayerInfo,
black: PlayerInfo,
resigned: Boolean = false,
)
@@ -0,0 +1,7 @@
package de.nowchess.chess.registry
trait GameRegistry:
def store(entry: GameEntry): Unit
def get(gameId: String): Option[GameEntry]
def update(entry: GameEntry): Unit
def generateId(): String
@@ -0,0 +1,23 @@
package de.nowchess.chess.registry
import jakarta.enterprise.context.ApplicationScoped
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class GameRegistryImpl extends GameRegistry:
private val games = ConcurrentHashMap[String, GameEntry]()
private val rng = new SecureRandom()
def store(entry: GameEntry): Unit =
games.put(entry.gameId, entry)
def get(gameId: String): Option[GameEntry] =
Option(games.get(gameId))
def update(entry: GameEntry): Unit =
games.put(entry.gameId, entry)
def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
@@ -0,0 +1,311 @@
package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.Square
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.controller.Parser
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
import de.nowchess.chess.observer.*
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import io.smallrye.mutiny.Multi
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import java.util.concurrent.atomic.AtomicReference
import scala.compiletime.uninitialized
@Path("/api/board/game")
@ApplicationScoped
class GameResource:
// scalafix:off DisableSyntax.var
@Inject
var registry: GameRegistry = uninitialized
@Inject
var objectMapper: ObjectMapper = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
// ── mapping ──────────────────────────────────────────────────────────────
private def statusOf(entry: GameEntry): String =
if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
else
val ctx = entry.engine.context
ctx.result match
case Some(GameResult.Win(_)) =>
if entry.resigned then "resign" else "checkmate"
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
case Some(GameResult.Draw(_)) => "draw"
case None =>
if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
else if entry.engine.ruleSet.isCheck(ctx) then "check"
else "started"
private def moveToUci(move: Move): String =
val base = s"${move.from}${move.to}"
move.moveType match
case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
case _ => base
private def toLegalMoveDto(move: Move): LegalMoveDto =
val (moveTypeStr, promotionStr) = move.moveType 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(PromotionPiece.Queen) => ("promotion", Some("queen"))
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr)
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
PlayerInfoDto(info.id.value, info.displayName)
private def toGameStateDto(entry: GameEntry): GameStateDto =
val ctx = entry.engine.context
GameStateDto(
fen = FenExporter.exportGameContext(ctx),
pgn = PgnExporter.exportGame(
Map(
"Event" -> "NowChess game",
"White" -> entry.white.displayName,
"Black" -> entry.black.displayName,
"Result" -> "*",
),
ctx.moves,
),
turn = ctx.turn.label.toLowerCase,
status = statusOf(entry),
winner = ctx.result.collect { case GameResult.Win(c) => c.label.toLowerCase },
moves = ctx.moves.map(moveToUci),
undoAvailable = entry.engine.canUndo,
redoAvailable = entry.engine.canRedo,
)
private def toGameFullDto(entry: GameEntry): GameFullDto =
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
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)
private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
val error = new AtomicReference[Option[String]](None)
val obs = new Observer:
def onGameEvent(e: GameEvent): Unit = e match
case InvalidMoveEvent(_, reason) => error.set(Some(reason.toString))
case _ => ()
engine.subscribe(obs)
engine.processUserInput(uci)
engine.unsubscribe(obs)
error.get()
// ── response helpers ─────────────────────────────────────────────────────
private def ok(body: AnyRef): Response = Response.ok(body).build()
private def created(body: AnyRef): Response = Response.status(Response.Status.CREATED).entity(body).build()
// scalafix:off DisableSyntax.throw
private def assertGameNotOver(entry: GameEntry): Unit =
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
// scalafix:on DisableSyntax.throw
// ── endpoints ────────────────────────────────────────────────────────────
// scalafix:off DisableSyntax.throw
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(body: CreateGameRequestDto): Response =
val req = Option(body).getOrElse(CreateGameRequestDto(None, None))
val white = playerInfoFrom(req.white, DefaultWhite)
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
@Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
ok(toGameFullDto(entry))
@GET
@Path("/{gameId}/stream")
@Produces(Array("application/x-ndjson"))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
Multi
.createFrom()
.emitter[String] { emitter =>
emitter.emit(objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry))) + "\n")
val obs = new Observer:
def onGameEvent(event: GameEvent): Unit =
registry.get(gameId).foreach { updated =>
emitter.emit(
objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))) + "\n",
)
}
entry.engine.subscribe(obs)
emitter.onTermination(() => entry.engine.unsubscribe(obs))
}
@POST
@Path("/{gameId}/resign")
@Produces(Array(MediaType.APPLICATION_JSON))
def resignGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
entry.engine.resign()
registry.update(entry.copy(resigned = true))
ok(OkResponseDto())
@POST
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
val (from, to, promoOpt) = Parser
.parseMove(uci)
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
ok(toGameStateDto(entry))
@GET
@Path("/{gameId}/moves")
@Produces(Array(MediaType.APPLICATION_JSON))
def getLegalMoves(
@PathParam("gameId") gameId: String,
@QueryParam("square") square: String,
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
val ctx = entry.engine.context
val moves =
if Option(square).isEmpty || square.isEmpty then entry.engine.ruleSet.allLegalMoves(ctx)
else
val sq = Square
.fromAlgebraic(square)
.getOrElse(throw BadRequestException("INVALID_SQUARE", s"Invalid square: $square", Some("square")))
entry.engine.ruleSet.legalMoves(ctx)(sq)
ok(LegalMovesResponseDto(moves.map(toLegalMoveDto)))
@POST
@Path("/{gameId}/undo")
@Produces(Array(MediaType.APPLICATION_JSON))
def undoMove(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
entry.engine.undo()
ok(toGameStateDto(entry))
@POST
@Path("/{gameId}/redo")
@Produces(Array(MediaType.APPLICATION_JSON))
def redoMove(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
entry.engine.redo()
ok(toGameStateDto(entry))
@POST
@Path("/{gameId}/draw/{action}")
@Produces(Array(MediaType.APPLICATION_JSON))
def drawAction(
@PathParam("gameId") gameId: String,
@PathParam("action") action: String,
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
action match
case "offer" =>
entry.engine.offerDraw(entry.engine.context.turn)
ok(OkResponseDto())
case "accept" =>
entry.engine.acceptDraw(entry.engine.context.turn)
ok(OkResponseDto())
case "decline" =>
entry.engine.declineDraw(entry.engine.context.turn)
ok(OkResponseDto())
case "claim" =>
entry.engine.claimDraw()
ok(OkResponseDto())
case _ =>
throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
@POST
@Path("/import/fen")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importFen(body: ImportFenRequestDto): Response =
val ctx = FenParser.parseFen(body.fen) match
case Left(err) => throw BadRequestException("INVALID_FEN", err, Some("fen"))
case Right(ctx) => ctx
val white = playerInfoFrom(body.white, DefaultWhite)
val black = playerInfoFrom(body.black, DefaultBlack)
val entry = newEntry(ctx, white, black)
registry.store(entry)
created(toGameFullDto(entry))
@POST
@Path("/import/pgn")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importPgn(body: ImportPgnRequestDto): Response =
val engine = GameEngine()
engine.loadGame(PgnParser, body.pgn) match
case Left(err) => throw BadRequestException("INVALID_PGN", err, Some("pgn"))
case Right(_) => ()
val entry = GameEntry(registry.generateId(), engine, DefaultWhite, DefaultBlack)
registry.store(entry)
created(toGameFullDto(entry))
@GET
@Path("/{gameId}/export/fen")
@Produces(Array(MediaType.TEXT_PLAIN))
def exportFen(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
ok(FenExporter.exportGameContext(entry.engine.context))
@GET
@Path("/{gameId}/export/pgn")
@Produces(Array("application/x-chess-pgn"))
def exportPgn(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
val pgn = PgnExporter.exportGame(
Map(
"Event" -> "NowChess game",
"White" -> entry.white.displayName,
"Black" -> entry.black.displayName,
"Result" -> "*",
),
entry.engine.context.moves,
)
ok(pgn)
// scalafix:on DisableSyntax.throw
@@ -234,6 +234,77 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("pendingDrawOfferBy returns None initially"):
val engine = new GameEngine()
engine.pendingDrawOfferBy shouldBe None
test("pendingDrawOfferBy returns White after White offers"):
val engine = new GameEngine()
engine.offerDraw(Color.White)
engine.pendingDrawOfferBy shouldBe Some(Color.White)
test("pendingDrawOfferBy returns None after draw is accepted"):
val engine = new GameEngine()
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 observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.applyDraw(DrawReason.Agreement)
observer.events should have length 1
observer.events.head match
case event: DrawEvent =>
event.reason shouldBe DrawReason.Agreement
event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement))
case other =>
fail(s"Expected DrawEvent, but got $other")
test("applyDraw does nothing when game already over"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// End the game with checkmate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
engine.processUserInput("d8h4")
observer.events.clear()
engine.applyDraw(DrawReason.Agreement)
observer.events should have length 0
test("claimDraw with fifty-move rule when at half-move 100"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// Play moves to reach fifty-move rule claim
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
// Need to advance halfMoveClock to 100
// This is hard to do naturally; skip for now if not critical
test("claimDraw when game already over"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// End the game with checkmate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
engine.processUserInput("d8h4")
observer.events.clear()
engine.claimDraw()
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
private class DrawOfferMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -63,6 +63,32 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("resign() without color resigns side to move"):
val engine = new GameEngine()
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign()
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
test("resign() without color does nothing when game already over"):
val engine = new GameEngine()
val observer = new ResignMockObserver()
engine.subscribe(observer)
// End the game with checkmate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.events.clear()
engine.processUserInput("d8h4")
// Try to resign without color parameter
val resultBefore = engine.context.result
engine.resign()
resultBefore shouldBe engine.context.result
private class ResignMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
@@ -0,0 +1,60 @@
package de.nowchess.chess.registry
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.engine.GameEngine
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.{DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import scala.compiletime.uninitialized
// scalafix:off
@QuarkusTest
@DisplayName("GameRegistryImpl")
class GameRegistryImplTest:
@Inject
var registry: GameRegistry = uninitialized
@Test
@DisplayName("store saves entry")
def testStore(): Unit =
val entry = GameEntry("g1", GameEngine(), 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"))
registry.store(entry)
val retrieved = registry.get("g2")
assertTrue(retrieved.isDefined)
assertEquals("g2", retrieved.get.gameId)
@Test
@DisplayName("get returns None for unknown id")
def testGetUnknown(): Unit =
assertTrue(registry.get("unknown").isEmpty)
@Test
@DisplayName("update modifies existing entry")
def testUpdate(): Unit =
val entry = GameEntry("g3", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
registry.store(entry)
val updated = entry.copy(resigned = true)
registry.update(updated)
val retrieved = registry.get("g3")
assertTrue(retrieved.isDefined)
assertTrue(retrieved.get.resigned)
@Test
@DisplayName("generateId produces unique ids")
def testGenerateId(): Unit =
val id1 = registry.generateId()
val id2 = registry.generateId()
assertNotEquals(id1, id2)
assertFalse(id1.isEmpty)
assertFalse(id2.isEmpty)
// scalafix:on
@@ -0,0 +1,154 @@
package de.nowchess.chess.resource
import de.nowchess.api.dto.*
import de.nowchess.chess.exception.BadRequestException
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.{DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import scala.compiletime.uninitialized
// scalafix:off
@QuarkusTest
@DisplayName("GameResource Integration")
class GameResourceIntegrationTest:
@Inject
var resource: GameResource = uninitialized
@Test
@DisplayName("createGame returns 201")
def testCreateGame(): Unit =
val req = CreateGameRequestDto(None, None)
val resp = resource.createGame(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
assertNotNull(dto.gameId)
@Test
@DisplayName("getGame returns 200")
def testGetGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
assertEquals(200, getResp.getStatus)
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
assertEquals(gameId, dto.gameId)
@Test
@DisplayName("makeMove advances game")
def testMakeMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
assertEquals(200, moveResp.getStatus)
val state = moveResp.getEntity.asInstanceOf[GameStateDto]
assertEquals("black", state.turn)
@Test
@DisplayName("makeMove with invalid UCI throws")
def testMakeMoveInvalid(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
@Test
@DisplayName("getLegalMoves returns moves")
def testGetLegalMoves(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
assertEquals(200, movesResp.getStatus)
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
assertFalse(dto.moves.isEmpty)
@Test
@DisplayName("resignGame updates state")
def testResignGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
assertEquals(200, resignResp.getStatus)
val getResp = resource.getGame(gameId)
val state = getResp.getEntity.asInstanceOf[GameFullDto].state
assertEquals("resign", state.status)
@Test
@DisplayName("undoMove reverts")
def testUndoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
assertEquals(200, undoResp.getStatus)
val state = undoResp.getEntity.asInstanceOf[GameStateDto]
assertEquals("white", state.turn)
@Test
@DisplayName("redoMove restores")
def testRedoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
val redoResp = resource.redoMove(gameId)
assertEquals(200, redoResp.getStatus)
val state = redoResp.getEntity.asInstanceOf[GameStateDto]
assertEquals("black", state.turn)
@Test
@DisplayName("drawAction offer")
def testDrawActionOffer(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
assertEquals(200, resp.getStatus)
@Test
@DisplayName("drawAction accept")
def testDrawActionAccept(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
assertEquals(200, resp.getStatus)
@Test
@DisplayName("importFen creates game")
def testImportFen(): Unit =
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val req = ImportFenRequestDto(fen, None, None)
val resp = resource.importFen(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
assertEquals(fen, dto.state.fen)
@Test
@DisplayName("importPgn creates game")
def testImportPgn(): Unit =
val req = ImportPgnRequestDto("1. e4 c5")
val resp = resource.importPgn(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
assertTrue(dto.state.moves.length > 0)
@Test
@DisplayName("exportFen returns FEN")
def testExportFen(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
assertEquals(200, resp.getStatus)
assertTrue(resp.getEntity.asInstanceOf[String].contains("rnbqkbnr"))
@Test
@DisplayName("exportPgn returns PGN")
def testExportPgn(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
assertEquals(200, resp.getStatus)
assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
// scalafix:on
+4 -2
View File
@@ -8,6 +8,8 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
@@ -19,7 +21,7 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedFiles.set(listOf(".*FenParserFastParse.*"))
excludedFiles.set(scoverageExcluded)
}
tasks.withType<ScalaCompile> {
@@ -28,7 +30,7 @@ tasks.withType<ScalaCompile> {
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
+1 -1
View File
@@ -27,7 +27,7 @@ tasks.withType<ScalaCompile> {
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
-119
View File
@@ -1,119 +0,0 @@
## (2026-04-01)
### Features
* 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-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))
## (2026-04-01)
### Features
* 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-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))
## (2026-04-01)
### Features
* 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-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))
## (2026-04-02)
### Features
* 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-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))
## (2026-04-03)
### Features
* 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-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))
## (2026-04-07)
### Features
* 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-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))
## (2026-04-07)
### Features
* 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-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))
## (2026-04-12)
### Features
* 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-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-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-12)
### Features
* 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-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-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-14)
### Features
* 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-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-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-16)
### Features
* 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-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-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-19)
### Features
* 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-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-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-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
## (2026-04-19)
### Features
* 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-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-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-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
-101
View File
@@ -1,101 +0,0 @@
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.jvm.tasks.Jar
plugins {
id("scala")
id("org.scoverage")
application
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(listOf("de\\.nowchess\\.ui\\..*"))
}
application {
mainClass.set("de.nowchess.ui.Main")
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
tasks.named<JavaExec>("run") {
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
standardInput = System.`in`
}
tasks.named<Jar>("jar") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation(project(":modules:core"))
implementation(project(":modules:rule"))
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:bot"))
// ScalaFX dependencies
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
// JavaFX dependencies for the current platform
val javaFXVersion = versions["JAVAFX"]!!
val osName = System.getProperty("os.name").lowercase()
val platform = when {
osName.contains("win") -> "win"
osName.contains("mac") -> "mac"
osName.contains("linux") -> "linux"
else -> "linux"
}
listOf("base", "controls", "graphics", "media").forEach { module ->
implementation("org.openjfx:javafx-$module:$javaFXVersion:$platform")
}
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 B

-30
View File
@@ -1,30 +0,0 @@
/* Arabian Chess GUI Styles */
.root {
-fx-font-family: "Comic Sans MS", "Comic Sans", cursive;
-fx-background-color: #F3C8A0;
}
.button {
-fx-background-radius: 8;
-fx-padding: 8 16 8 16;
-fx-font-family: "Comic Sans MS", cursive;
-fx-font-size: 12px;
-fx-cursor: hand;
}
.button:hover {
-fx-opacity: 0.8;
}
.label {
-fx-font-family: "Comic Sans MS", cursive;
}
.dialog-pane {
-fx-background-color: #F3C8A0;
}
.dialog-pane .content {
-fx-font-family: "Comic Sans MS", cursive;
}
@@ -1,34 +0,0 @@
package de.nowchess.ui
import de.nowchess.api.game.{BotParticipant, Human}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.BotDifficulty
import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher
/** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
* GameEngine via Observer pattern.
*/
object Main:
def main(args: Array[String]): Unit =
val book = PolyglotBook("../../modules/bot/codekiddy.bin")
// Create the core game engine (single source of truth)
val engine = new de.nowchess.chess.engine.GameEngine(
participants = Map(
de.nowchess.api.board.Color.White -> BotParticipant(
de.nowchess.bot.bots.HybridBot(BotDifficulty.Easy, book = Some(book)),
),
de.nowchess.api.board.Color.Black -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
),
)
engine.startGame()
// Launch ScalaFX GUI in separate thread
ChessGUILauncher.launch(engine)
// Create and start the terminal UI (blocks on main thread)
val tui = new TerminalUI(engine)
tui.start()
@@ -1,392 +0,0 @@
package de.nowchess.ui.gui
import java.util.concurrent.atomic.AtomicReference
import scalafx.Includes.*
import scalafx.application.Platform
import scalafx.geometry.{Insets, Pos}
import scalafx.scene.control.{Button, ButtonType, ChoiceDialog, Label}
import scalafx.scene.layout.{BorderPane, GridPane, HBox, StackPane, VBox}
import scalafx.scene.paint.Color as FXColor
import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.MoveType
import de.nowchess.chess.command.{MoveCommand, MoveResult}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.json.{JsonExporter, JsonParser}
import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
import java.nio.file.Paths
import scalafx.stage.FileChooser
import scalafx.stage.FileChooser.ExtensionFilter
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
* interactions (clicks) and sends moves to GameEngine.
*/
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
private val squareSize = 70.0
private val comicSansFontFamily = "Comic Sans MS"
private val boardGrid = new GridPane()
private val messageLabel = new Label {
text = "Welcome!"
font = Font.font(comicSansFontFamily, 16)
padding = Insets(10)
}
private val currentBoard = new AtomicReference[Board](engine.board)
private val currentTurn = new AtomicReference[Color](engine.turn)
private val selectedSquare = new AtomicReference[Option[Square]](None)
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private val undoButton: Button = new Button("Undo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canUndo then engine.undo()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
disable = !engine.canUndo
}
private val redoButton: Button = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
disable = !engine.canRedo
}
// Initialize UI
initializeBoard()
top = new VBox {
padding = Insets(10)
spacing = 5
alignment = Pos.Center
children = Seq(
new Label {
text = "Chess"
font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;"
},
messageLabel,
)
}
center = new VBox {
padding = Insets(20)
alignment = Pos.Center
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
children = boardGrid
}
bottom = new VBox {
padding = Insets(10)
spacing = 8
alignment = Pos.Center
children = Seq(
new HBox {
spacing = 10
alignment = Pos.Center
children = Seq(
undoButton,
redoButton,
new Button("Reset") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset()
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
},
)
},
new HBox {
spacing = 10
alignment = Pos.Center
children = Seq(
new Button("FEN Export") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doFenExport()
style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;"
},
new Button("FEN Import") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doFenImport()
style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;"
},
new Button("PGN Export") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnExport()
style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;"
},
new Button("PGN Import") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnImport()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
},
)
},
new HBox {
spacing = 10
alignment = Pos.Center
children = Seq(
new Button("JSON Export") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonExport()
style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;"
},
new Button("JSON Import") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonImport()
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
},
)
},
)
}
private def initializeBoard(): Unit =
boardGrid.padding = Insets(5)
boardGrid.hgap = 0
boardGrid.vgap = 0
// Create 8x8 board with rank/file labels
for
rank <- 0 until 8
file <- 0 until 8
do
val square = createSquare(rank, file)
squareViews((rank, file)) = square
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
updateBoard(currentBoard.get(), currentTurn.get())
private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
width = squareSize
height = squareSize
fill = FXColor.web(baseColor)
arcWidth = 8
arcHeight = 8
}
val square = new StackPane {
children = Seq(bgRect)
onMouseClicked = _ => handleSquareClick(rank, file)
style = "-fx-cursor: hand;"
}
square
private def handleSquareClick(rank: Int, file: Int): Unit =
val clickedSquare = Square(File.values(file), Rank.values(rank))
selectedSquare.get() match
case None =>
// First click - select piece if it belongs to current player
currentBoard.get().pieceAt(clickedSquare).foreach { piece =>
if piece.color == currentTurn.get() then
selectedSquare.set(Some(clickedSquare))
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet
.legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
case Some(fromSquare) =>
// Second click - attempt move
if clickedSquare == fromSquare then
// Deselect
selectedSquare.set(None)
updateBoard(currentBoard.get(), currentTurn.get())
else
val isPromo = engine.ruleSet
.legalMoves(engine.context)(fromSquare)
.exists(m =>
m.to == clickedSquare && (m.moveType match
case MoveType.Promotion(_) => true
case _ => false
),
)
if isPromo then showPromotionDialog(fromSquare, clickedSquare)
else engine.processUserInput(s"${fromSquare}$clickedSquare")
selectedSquare.set(None)
def updateBoard(board: Board, turn: Color): Unit =
currentBoard.set(board)
currentTurn.set(turn)
selectedSquare.set(None)
// Update all squares
for
rank <- 0 until 8
file <- 0 until 8
do
squareViews.get((rank, file)).foreach { stackPane =>
val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle {
width = squareSize
height = squareSize
fill = FXColor.web(baseColor)
arcWidth = 8
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square)
val children: Seq[scalafx.scene.Node] = pieceOption match
case Some(piece) =>
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
case None =>
Seq(bgRect)
stackPane.children = children
}
updateUndoRedoButtons()
def updateUndoRedoButtons(): Unit =
undoButton.disable = !engine.canUndo
redoButton.disable = !engine.canRedo
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
squareViews.get((rank, file)).foreach { stackPane =>
val bgRect = new Rectangle {
width = squareSize
height = squareSize
fill = FXColor.web(color)
arcWidth = 8
arcHeight = 8
}
val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.get().pieceAt(square)
stackPane.children = (pieceOption match
case Some(piece) =>
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
case None =>
Seq(bgRect)
): Seq[scalafx.scene.Node]
}
def showMessage(msg: String): Unit =
messageLabel.text = msg
def showPromotionDialog(from: Square, to: Square): Unit =
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
initOwner(stage)
title = "Pawn Promotion"
headerText = "Choose promotion piece"
contentText = "Promote to:"
}
val uciSuffix = dialog.showAndWait() match
case Some("Rook") => "r"
case Some("Bishop") => "b"
case Some("Knight") => "n"
case _ => "q"
engine.processUserInput(s"${from}${to}$uciSuffix")
private def doFenExport(): Unit =
doExport(FenExporter, "FEN")
private def doFenImport(): Unit =
doImport(FenParser, "FEN")
private def doPgnExport(): Unit =
doExport(PgnExporter, "PGN")
private def doPgnImport(): Unit =
doImport(PgnParser, "PGN")
private def doJsonExport(): Unit =
val fileChooser = new FileChooser {
title = "Export Game as JSON"
initialFileName = "chess_game.json"
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
Option(fileChooser.showSaveDialog(stage)).foreach { selectedFile =>
val result = FileSystemGameService.saveGameToFile(
engine.context,
selectedFile.toPath,
JsonExporter,
)
result match
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
}
private def doJsonImport(): Unit =
val fileChooser = new FileChooser {
title = "Import Game from JSON"
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
Option(fileChooser.showOpenDialog(stage)).foreach { selectedFile =>
val result = FileSystemGameService.loadGameFromFile(
selectedFile.toPath,
JsonParser,
)
result match
case Right(gameContext) =>
engine.loadPosition(gameContext)
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
case Left(err) =>
showMessage(s"⚠️ Error: $err")
}
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context)
showCopyDialog(s"$formatName Export", exported)
}
private def doImport(importer: GameContextImport, formatName: String): Unit =
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match
case Right(gameContext) =>
engine.loadPosition(gameContext)
showMessage(s"$formatName loaded successfully!")
case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err")
}
private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content)
area.setEditable(false)
area.setWrapText(true)
area.setPrefRowCount(4)
val alert = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION)
alert.setTitle(title)
alert.setHeaderText("")
alert.getDialogPane.setContent(area)
alert.getDialogPane.setPrefWidth(500)
alert.initOwner(stage.delegate)
alert.showAndWait()
private def showInputDialog(title: String, rows: Int = 2): Option[String] =
val area = new javafx.scene.control.TextArea()
area.setWrapText(true)
area.setPrefRowCount(rows)
val dialog = new javafx.scene.control.Dialog[String]()
dialog.setTitle(title)
dialog.getDialogPane.setContent(area)
dialog.getDialogPane.getButtonTypes.addAll(
javafx.scene.control.ButtonType.OK,
javafx.scene.control.ButtonType.CANCEL,
)
dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else ""
}
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get.nonEmpty then Some(result.get) else None
@@ -1,57 +0,0 @@
package de.nowchess.ui.gui
import javafx.application.{Application as JFXApplication, Platform as JFXPlatform}
import javafx.stage.Stage as JFXStage
import scalafx.application.Platform
import scalafx.scene.Scene
import scalafx.stage.Stage
import de.nowchess.chess.engine.GameEngine
/** ScalaFX GUI Application for Chess. This is launched from Main alongside the TUI. Both subscribe to the same
* GameEngine via Observer pattern.
*/
class ChessGUIApp extends JFXApplication:
override def start(primaryStage: JFXStage): Unit =
val engine = ChessGUILauncher.getEngine
val stage = new Stage(primaryStage)
stage.title = "Chess"
stage.width = 700
stage.height = 1000
stage.resizable = false
val boardView = new ChessBoardView(stage, engine)
val guiObserver = new GUIObserver(boardView)
// Subscribe GUI observer to engine
engine.subscribe(guiObserver)
stage.scene = new Scene {
root = boardView
// Load CSS if available
try
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
catch {
case _: Exception => // CSS is optional
}
}
stage.onCloseRequest = _ =>
// Unsubscribe when window closes
engine.unsubscribe(guiObserver)
stage.show()
/** Launcher object that holds the engine reference and launches GUI in separate thread. */
object ChessGUILauncher:
private val engineRef = new java.util.concurrent.atomic.AtomicReference[GameEngine]()
def getEngine: GameEngine = engineRef.get()
def launch(eng: GameEngine): Unit =
engineRef.set(eng)
val guiThread = new Thread(() => JFXApplication.launch(classOf[ChessGUIApp]))
guiThread.setDaemon(false)
guiThread.setName("ScalaFX-GUI-Thread")
guiThread.start()
@@ -1,80 +0,0 @@
package de.nowchess.ui.gui
import scalafx.application.Platform
import scalafx.scene.control.Alert
import scalafx.scene.control.Alert.AlertType
import de.nowchess.chess.observer.{GameEvent, Observer, *}
import de.nowchess.api.board.Board
import de.nowchess.api.game.DrawReason
/** GUI Observer that implements the Observer pattern. Receives game events from GameEngine and updates the ScalaFX UI.
* All UI updates must be done on the JavaFX Application Thread.
*/
class GUIObserver(private val boardView: ChessBoardView) extends Observer:
override def onGameEvent(event: GameEvent): Unit =
// Ensure UI updates happen on JavaFX thread
Platform.runLater {
event match
case e: MoveExecutedEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
e.capturedPiece.foreach { piece =>
boardView.showMessage(s"Captured: $piece on ${e.toSquare}")
}
case e: CheckDetectedEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"${e.context.turn.label} is in check!")
case e: CheckmateEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
case e: DrawEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
val msg = e.reason match
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement."
showAlert(AlertType.Information, "Game Over", msg)
case e: InvalidMoveEvent =>
boardView.showMessage(s"⚠️ ${e.reason}")
case e: BoardResetEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("Board has been reset to initial position.")
case e: FiftyMoveRuleAvailableEvent =>
boardView.showMessage("50-move rule is now available — type 'draw' to claim.")
case e: ThreefoldRepetitionAvailableEvent =>
boardView.showMessage("Threefold repetition is now available — type 'draw' to claim.")
case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: MoveRedoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
else boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("✓ PGN loaded successfully!")
boardView.updateUndoRedoButtons()
}
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
new Alert(alertType) {
initOwner(boardView.stage)
title = titleText
headerText = None
contentText = content
}.showAndWait()
@@ -1,34 +0,0 @@
package de.nowchess.ui.gui
import scalafx.scene.image.{Image, ImageView}
import de.nowchess.api.board.{Color, Piece, PieceType}
/** Utility object for loading chess piece sprites. */
object PieceSprites:
private val spriteCache = scala.collection.mutable.Map[String, Option[Image]]()
/** Load a piece sprite image from resources. Sprites are cached for performance.
*/
def loadPieceImage(piece: Piece, size: Double = 60.0): Option[ImageView] =
val key = s"${piece.color.label.toLowerCase}_${piece.pieceType.label.toLowerCase}"
spriteCache.getOrElseUpdate(key, loadImage(key)).map { image =>
new ImageView(image) {
fitWidth = size
fitHeight = size
preserveRatio = true
smooth = true
}
}
private def loadImage(key: String): Option[Image] =
val path = s"/sprites/pieces/$key.png"
Option(getClass.getResourceAsStream(path)).map(new Image(_))
/** Get square colors for the board using theme. */
object SquareColors:
val White = "#F3C8A0" // Warm light beige
val Black = "#BA6D4B" // Warm terracotta
val Selected = "#C19EF5" // Purple highlight
val ValidMove = "#E1EAA9" // Light yellow-green
val Border = "#5A2C28" // Dark brown border
@@ -1,107 +0,0 @@
package de.nowchess.ui.terminal
import java.util.concurrent.atomic.AtomicBoolean
import scala.io.StdIn
import de.nowchess.api.game.DrawReason
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern. Subscribes to GameEngine and receives state change events. Handles all
* I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private val running = new AtomicBoolean(true)
/** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit =
event match
case e: MoveExecutedEvent =>
println()
print(Renderer.render(e.context.board))
e.capturedPiece.foreach: cap =>
println(s"Captured: $cap on ${e.toSquare}")
printPrompt(e.context.turn)
case e: MoveUndoneEvent =>
println(s"Undo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: MoveRedoneEvent =>
println(s"Redo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: CheckDetectedEvent =>
println(s"${e.context.turn.label} is in check!")
case e: CheckmateEvent =>
println(s"Checkmate! ${e.winner.label} wins.")
println()
print(Renderer.render(e.context.board))
case e: DrawEvent =>
val msg = e.reason match
case DrawReason.Stalemate => "Stalemate! The game is a draw."
case DrawReason.InsufficientMaterial => "Draw by insufficient material."
case DrawReason.FiftyMoveRule => "Draw claimed under the 50-move rule."
case DrawReason.ThreefoldRepetition => "Draw by threefold repetition."
case DrawReason.Agreement => "Draw by agreement."
println(msg)
println()
print(Renderer.render(e.context.board))
case e: InvalidMoveEvent =>
println(s"⚠️ ${e.reason}")
case e: BoardResetEvent =>
println("Board has been reset to initial position.")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule is now available — type 'draw' to claim.")
case _: ThreefoldRepetitionAvailableEvent =>
println("Threefold repetition is now available — type 'draw' to claim.")
case e: PgnLoadedEvent =>
println("PGN loaded successfully.")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
/** Start the terminal UI game loop. */
def start(): Unit =
// Register as observer
engine.subscribe(this)
// Show initial board
println()
print(Renderer.render(engine.board))
printPrompt(engine.turn)
while running.get() do
val input = Option(StdIn.readLine()).getOrElse("quit").trim
synchronized {
input.toLowerCase match
case "quit" | "q" =>
running.set(false)
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)
case _ =>
engine.processUserInput(input)
}
// Unsubscribe when done
engine.unsubscribe(this)
private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
val undoHint = if engine.canUndo then " [undo]" else ""
val redoHint = if engine.canRedo then " [redo]" else ""
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
@@ -1,18 +0,0 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.{Color, Piece, PieceType}
extension (p: Piece)
def unicode: String = (p.color, p.pieceType) match
case (Color.White, PieceType.King) => "\u2654"
case (Color.White, PieceType.Queen) => "\u2655"
case (Color.White, PieceType.Rook) => "\u2656"
case (Color.White, PieceType.Bishop) => "\u2657"
case (Color.White, PieceType.Knight) => "\u2658"
case (Color.White, PieceType.Pawn) => "\u2659"
case (Color.Black, PieceType.King) => "\u265A"
case (Color.Black, PieceType.Queen) => "\u265B"
case (Color.Black, PieceType.Rook) => "\u265C"
case (Color.Black, PieceType.Bishop) => "\u265D"
case (Color.Black, PieceType.Knight) => "\u265E"
case (Color.Black, PieceType.Pawn) => "\u265F"
@@ -1,30 +0,0 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.*
object Renderer:
private val AnsiReset = "\u001b[0m"
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String =
val rows = (0 until 8).reverse
.map { rank =>
val cells = (0 until 8).map { file =>
val sq = Square(File.values(file), Rank.values(rank))
val isLightSq = (file + rank) % 2 != 0
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
board.pieceAt(sq) match
case Some(piece) =>
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
case None =>
s"$bgColor $AnsiReset"
}.mkString
s"${rank + 1} $cells ${rank + 1}"
}
.mkString("\n")
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
@@ -1,44 +0,0 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
test("unicode returns correct unicode character for all piece types"):
val pieces = Seq(
(Piece(Color.White, PieceType.King), "\u2654"),
(Piece(Color.White, PieceType.Queen), "\u2655"),
(Piece(Color.White, PieceType.Rook), "\u2656"),
(Piece(Color.White, PieceType.Bishop), "\u2657"),
(Piece(Color.White, PieceType.Knight), "\u2658"),
(Piece(Color.White, PieceType.Pawn), "\u2659"),
(Piece(Color.Black, PieceType.King), "\u265A"),
(Piece(Color.Black, PieceType.Queen), "\u265B"),
(Piece(Color.Black, PieceType.Rook), "\u265C"),
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
(Piece(Color.Black, PieceType.Knight), "\u265E"),
(Piece(Color.Black, PieceType.Pawn), "\u265F"),
)
pieces.foreach { (piece, expected) =>
piece.unicode shouldBe expected
}
test("render outputs coordinates ranks ansi escapes and piece glyphs"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val rendered = Renderer.render(Board(Map.empty))
val lines = rendered.trim.split("\\n").toList.map(_.trim)
lines.head shouldBe "a b c d e f g h"
lines.last shouldBe "a b c d e f g h"
rendered should include("8")
rendered should include("1")
Renderer.render(board) should include("\u2655")
Renderer.render(board) should include("\u001b[")
test("render applies black piece color for black pieces"):
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val rendered = Renderer.render(board)
rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color
-3
View File
@@ -1,3 +0,0 @@
MAJOR=0
MINOR=13
PATCH=0