From c830b143dcc0b5039083440b442799830812b84d Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:36:14 +0200 Subject: [PATCH 01/13] feat: NCS-37 add backcore Quarkus module skeleton Introduces the backcore Quarkus REST module with build configuration, application.yml, and a smoke test confirming Quarkus boots successfully. Co-Authored-By: Claude Sonnet 4.6 --- gradle.properties | 5 ++ modules/backcore/build.gradle.kts | 88 +++++++++++++++++++ .../src/main/resources/application.yml | 3 + .../backcore/BackcoreStartupTest.scala | 11 +++ settings.gradle.kts | 17 +++- 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 gradle.properties create mode 100644 modules/backcore/build.gradle.kts create mode 100644 modules/backcore/src/main/resources/application.yml create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/BackcoreStartupTest.scala diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..21f2a33 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +quarkusPluginId=io.quarkus +quarkusPluginVersion=3.32.4 +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.32.4 diff --git a/modules/backcore/build.gradle.kts b/modules/backcore/build.gradle.kts new file mode 100644 index 0000000..d3c228e --- /dev/null +++ b/modules/backcore/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + id("scala") + id("org.scoverage") version "8.1" + id("io.quarkus") +} + +@Suppress("UNCHECKED_CAST") +val versions = rootProject.extra["VERSIONS"] as Map + +repositories { + mavenCentral() + mavenLocal() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) +} + +tasks.withType { + scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") +} + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +dependencies { + implementation(project(":modules:api")) + implementation(project(":modules:core")) + implementation(project(":modules:io")) + implementation(project(":modules:rule")) + + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-rest") + implementation("io.quarkus:quarkus-rest-jackson") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-arc") + + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:2.18.0") + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.rest-assured:rest-assured") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +configurations.matching { !it.name.startsWith("scoverage") }.configureEach { + resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}") +} +configurations.scoverage { + resolutionStrategy.eachDependency { + if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) { + useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0") + } + } +} + +group = "de.nowchess" +version = "1.0-SNAPSHOT" + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest", "junit-jupiter") + testLogging { + events("passed", "skipped", "failed") + } + } + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} diff --git a/modules/backcore/src/main/resources/application.yml b/modules/backcore/src/main/resources/application.yml new file mode 100644 index 0000000..85b85bb --- /dev/null +++ b/modules/backcore/src/main/resources/application.yml @@ -0,0 +1,3 @@ +quarkus: + http: + port: 8080 diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/BackcoreStartupTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/BackcoreStartupTest.scala new file mode 100644 index 0000000..66e0c47 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/BackcoreStartupTest.scala @@ -0,0 +1,11 @@ +package de.nowchess.backcore + +import io.quarkus.test.junit.QuarkusTest +import org.junit.jupiter.api.Test + +@QuarkusTest +class BackcoreStartupTest: + @Test + def applicationStarts(): Unit = + // If we get here the Quarkus container started successfully + () diff --git a/settings.gradle.kts b/settings.gradle.kts index 1571957..c4b72ae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,23 @@ rootProject.name = "NowChessSystems" + +pluginManagement { + val quarkusPluginVersion: String by settings + val quarkusPluginId: String by settings + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id(quarkusPluginId) version quarkusPluginVersion + } +} + include( "modules:core", "modules:api", "modules:io", "modules:rule", "modules:ui", -) \ No newline at end of file + "modules:backcore", +) -- 2.52.0 From d59d69238128a4495dbadd37ddb98e582758c1f0 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:37:47 +0200 Subject: [PATCH 02/13] fix: use version catalog keys for jackson-scala and junit-bom in backcore --- modules/backcore/build.gradle.kts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/backcore/build.gradle.kts b/modules/backcore/build.gradle.kts index d3c228e..8e45cdf 100644 --- a/modules/backcore/build.gradle.kts +++ b/modules/backcore/build.gradle.kts @@ -40,9 +40,9 @@ dependencies { implementation("io.quarkus:quarkus-config-yaml") implementation("io.quarkus:quarkus-arc") - implementation("com.fasterxml.jackson.module:jackson-module-scala_3:2.18.0") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") - testImplementation(platform("org.junit:junit-bom:5.13.4")) + 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"]!!}") @@ -77,9 +77,9 @@ tasks.withType().configureEach { tasks.test { useJUnitPlatform { includeEngines("scalatest", "junit-jupiter") - testLogging { - events("passed", "skipped", "failed") - } + } + testLogging { + events("passed", "skipped", "failed") } finalizedBy(tasks.reportScoverage) } -- 2.52.0 From 21eee717f16f3ce47347d06b9c515b3d16a4af27 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:44:05 +0200 Subject: [PATCH 03/13] feat: NCS-37 game lifecycle endpoints (createGame, getGame) Add domain model (GameResult, GameSession, GameId, GameStore, GameMapper), DTOs, JacksonConfig, and GameResource REST endpoints with QuarkusTest coverage. Co-Authored-By: Claude Sonnet 4.6 --- .../backcore/config/JacksonConfig.scala | 11 +++ .../scala/de/nowchess/backcore/dto/Dtos.scala | 57 ++++++++++++++ .../de/nowchess/backcore/game/GameId.scala | 10 +++ .../nowchess/backcore/game/GameMapper.scala | 75 +++++++++++++++++++ .../nowchess/backcore/game/GameResult.scala | 12 +++ .../nowchess/backcore/game/GameSession.scala | 16 ++++ .../de/nowchess/backcore/game/GameStore.scala | 33 ++++++++ .../backcore/resource/GameResource.scala | 29 +++++++ .../backcore/resource/GameResourceTest.scala | 62 +++++++++++++++ 9 files changed, 305 insertions(+) create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala new file mode 100644 index 0000000..fed153a --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala @@ -0,0 +1,11 @@ +package de.nowchess.backcore.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.quarkus.jackson.ObjectMapperCustomizer +import jakarta.inject.Singleton + +@Singleton +class JacksonConfig extends ObjectMapperCustomizer: + def customize(mapper: ObjectMapper): Unit = + mapper.registerModule(DefaultScalaModule) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala new file mode 100644 index 0000000..28d17cb --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala @@ -0,0 +1,57 @@ +package de.nowchess.backcore.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include + +case class PlayerInfoDto(id: String, displayName: String) + +case class GameStateResponse( + fen: String, + pgn: String, + turn: String, + status: String, + @JsonInclude(Include.NON_ABSENT) winner: Option[String], + moves: List[String], + undoAvailable: Boolean, + redoAvailable: Boolean, +) + +case class GameFullResponse( + gameId: String, + white: PlayerInfoDto, + black: PlayerInfoDto, + state: GameStateResponse, +) + +case class OkResponse(ok: Boolean = true) + +@JsonInclude(Include.NON_ABSENT) +case class ApiErrorResponse( + code: String, + message: String, + field: Option[String] = None, +) + +// Requests +case class CreateGameRequest( + white: Option[PlayerInfoDto] = None, + black: Option[PlayerInfoDto] = None, +) + +case class ImportFenRequest( + fen: String = "", + white: Option[PlayerInfoDto] = None, + black: Option[PlayerInfoDto] = None, +) + +case class ImportPgnRequest(pgn: String = "") + +case class LegalMoveDto( + from: String, + to: String, + uci: String, + moveType: String, + @JsonInclude(Include.NON_ABSENT) promotion: Option[String] = None, +) + +case class LegalMovesResponse(moves: List[LegalMoveDto]) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala new file mode 100644 index 0000000..4f60437 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala @@ -0,0 +1,10 @@ +package de.nowchess.backcore.game + +import java.security.SecureRandom + +object GameId: + private val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private val random = SecureRandom() + + def generate(): String = + (1 to 8).map(_ => chars(random.nextInt(chars.length))).mkString diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala new file mode 100644 index 0000000..814770a --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -0,0 +1,75 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.Color +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.backcore.dto.* +import de.nowchess.io.fen.FenExporter +import de.nowchess.rules.sets.DefaultRules + +object GameMapper: + + def toGameFull(session: GameSession): GameFullResponse = + GameFullResponse( + gameId = session.gameId, + white = toPlayerInfo(session.white), + black = toPlayerInfo(session.black), + state = toGameState(session), + ) + + def toGameState(session: GameSession): GameStateResponse = + val (status, winner) = computeStatus(session) + GameStateResponse( + fen = FenExporter.exportGameContext(session.context), + pgn = buildPgn(session.context.moves), + turn = if session.context.turn == Color.White then "white" else "black", + status = status, + winner = winner, + moves = session.context.moves.map(moveToUci), + undoAvailable = session.invoker.canUndo, + redoAvailable = session.invoker.canRedo, + ) + + private def toPlayerInfo(p: de.nowchess.api.player.PlayerInfo): PlayerInfoDto = + PlayerInfoDto(id = p.id.value, displayName = p.displayName) + + private def computeStatus(session: GameSession): (String, Option[String]) = + session.result match + case Some(GameResult.Checkmate(winner)) => + val w = if winner == Color.White then "white" else "black" + ("checkmate", Some(w)) + case Some(GameResult.Stalemate) => + ("stalemate", None) + case Some(GameResult.Resign(winner)) => + val w = if winner == Color.White then "white" else "black" + ("resign", Some(w)) + case Some(GameResult.AgreedDraw) | Some(GameResult.FiftyMoveDraw) => + ("draw", None) + case Some(GameResult.InsufficientMaterial) => + ("insufficientMaterial", None) + case None => + computeLiveStatus(session) + + private def computeLiveStatus(session: GameSession): (String, Option[String]) = + val ctx = session.context + if session.drawOfferedBy.isDefined then ("drawOffered", None) + else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None) + else if DefaultRules.isCheck(ctx) then ("check", None) + else ("started", None) + + def moveToUci(move: Move): String = + val base = s"${move.from}${move.to}" + move.moveType match + case MoveType.Promotion(piece) => + val suffix = piece match + case PromotionPiece.Queen => "q" + case PromotionPiece.Rook => "r" + case PromotionPiece.Bishop => "b" + case PromotionPiece.Knight => "n" + base + suffix + case _ => base + + private def buildPgn(moves: List[Move]): String = + moves.zipWithIndex.map { (move, i) => + val prefix = if i % 2 == 0 then s"${i / 2 + 1}. " else "" + prefix + moveToUci(move) + }.mkString(" ") diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala new file mode 100644 index 0000000..3b14da6 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala @@ -0,0 +1,12 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.Color + +sealed trait GameResult +object GameResult: + case class Checkmate(winner: Color) extends GameResult + case object Stalemate extends GameResult + case class Resign(winner: Color) extends GameResult + case object AgreedDraw extends GameResult + case object FiftyMoveDraw extends GameResult + case object InsufficientMaterial extends GameResult diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala new file mode 100644 index 0000000..32025fd --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala @@ -0,0 +1,16 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.Color +import de.nowchess.api.game.GameContext +import de.nowchess.api.player.PlayerInfo +import de.nowchess.chess.command.CommandInvoker + +case class GameSession( + gameId: String, + white: PlayerInfo, + black: PlayerInfo, + context: GameContext, + invoker: CommandInvoker, + drawOfferedBy: Option[Color] = None, + result: Option[GameResult] = None, +) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala new file mode 100644 index 0000000..a7b41bd --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala @@ -0,0 +1,33 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.game.GameContext +import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.backcore.dto.{CreateGameRequest, PlayerInfoDto} +import de.nowchess.chess.command.CommandInvoker +import jakarta.enterprise.context.ApplicationScoped + +import scala.collection.mutable + +@ApplicationScoped +class GameStore: + private val games: mutable.Map[String, GameSession] = mutable.Map.empty + + def create(req: CreateGameRequest): GameSession = synchronized: + val id = GameId.generate() + val white = toPlayerInfo(req.white, "white", "White") + val black = toPlayerInfo(req.black, "black", "Black") + val session = GameSession( + gameId = id, + white = white, + black = black, + context = GameContext.initial, + invoker = new CommandInvoker(), + ) + games(id) = session + session + + def get(id: String): Option[GameSession] = synchronized: + games.get(id) + + private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo = + dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName)) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala new file mode 100644 index 0000000..54c407f --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala @@ -0,0 +1,29 @@ +package de.nowchess.backcore.resource + +import de.nowchess.backcore.dto.* +import de.nowchess.backcore.game.{GameMapper, GameStore} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{MediaType, Response} + +@Path("/api/board/game") +@Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) +@ApplicationScoped +class GameResource @Inject() (store: GameStore): + + @POST + def createGame(req: CreateGameRequest): Response = + val session = store.create(Option(req).getOrElse(CreateGameRequest())) + Response.status(201).entity(GameMapper.toGameFull(session)).build() + + @GET + @Path("/{gameId}") + def getGame(@PathParam("gameId") gameId: String): Response = + store.get(gameId) match + case Some(session) => Response.ok(GameMapper.toGameFull(session)).build() + case None => + Response.status(404) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala new file mode 100644 index 0000000..61387fa --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala @@ -0,0 +1,62 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{equalTo, matchesPattern, notNullValue} +import org.junit.jupiter.api.Test + +@QuarkusTest +class GameResourceTest: + + @Test + def createGameReturns201WithGameId(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .body("gameId", matchesPattern("[A-Za-z0-9]{8}")) + .body("state.fen", notNullValue()) + .body("state.turn", equalTo("white")) + .body("state.status", equalTo("started")) + + @Test + def createGameWithPlayersReturns201(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body("""{"white":{"id":"p1","displayName":"Alice"},"black":{"id":"p2","displayName":"Bob"}}""") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .body("white.id", equalTo("p1")) + .body("black.displayName", equalTo("Bob")) + + @Test + def getGameReturns200ForExistingGame(): Unit = + val gameId = RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId") + .`then`() + .statusCode(200) + .body("gameId", equalTo(gameId)) + + @Test + def getGameReturns404ForUnknownId(): Unit = + RestAssured.`given`() + .when() + .get("/api/board/game/XXXXXXXX") + .`then`() + .statusCode(404) -- 2.52.0 From 41fa674bf72afa9ab95c79bd49e35eb086ef7baa Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:49:36 +0200 Subject: [PATCH 04/13] fix: use PgnExporter for SAN pgn field, fix status priority in GameMapper --- .idea/scala_settings.xml | 6 ++++++ .../scala/de/nowchess/backcore/game/GameMapper.scala | 11 +++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .idea/scala_settings.xml diff --git a/.idea/scala_settings.xml b/.idea/scala_settings.xml new file mode 100644 index 0000000..4608fe0 --- /dev/null +++ b/.idea/scala_settings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala index 814770a..2c7a45a 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -4,6 +4,7 @@ import de.nowchess.api.board.Color import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.backcore.dto.* import de.nowchess.io.fen.FenExporter +import de.nowchess.io.pgn.PgnExporter import de.nowchess.rules.sets.DefaultRules object GameMapper: @@ -51,9 +52,9 @@ object GameMapper: private def computeLiveStatus(session: GameSession): (String, Option[String]) = val ctx = session.context - if session.drawOfferedBy.isDefined then ("drawOffered", None) + if DefaultRules.isCheck(ctx) then ("check", None) + else if session.drawOfferedBy.isDefined then ("drawOffered", None) else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None) - else if DefaultRules.isCheck(ctx) then ("check", None) else ("started", None) def moveToUci(move: Move): String = @@ -69,7 +70,5 @@ object GameMapper: case _ => base private def buildPgn(moves: List[Move]): String = - moves.zipWithIndex.map { (move, i) => - val prefix = if i % 2 == 0 then s"${i / 2 + 1}. " else "" - prefix + moveToUci(move) - }.mkString(" ") + // Use PgnExporter with no headers to get move-text only (SAN notation) + PgnExporter.exportGame(Map.empty, moves) -- 2.52.0 From 6fc3b3c3df9c2f581e9f2fcd75742d208582a7f1 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:58:01 +0200 Subject: [PATCH 05/13] feat: NCS-37 move-making, legal moves, undo/redo endpoints --- .../de/nowchess/backcore/game/GameStore.scala | 248 +++++++++++++++++- .../backcore/resource/MoveResource.scala | 87 ++++++ .../backcore/resource/MoveResourceTest.scala | 77 ++++++ .../backcore/resource/UndoRedoTest.scala | 80 ++++++ 4 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala index a7b41bd..5202800 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala @@ -1,9 +1,14 @@ package de.nowchess.backcore.game +import de.nowchess.api.board.{Color, Square} import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.player.{PlayerId, PlayerInfo} -import de.nowchess.backcore.dto.{CreateGameRequest, PlayerInfoDto} -import de.nowchess.chess.command.CommandInvoker +import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto} +import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} +import de.nowchess.io.fen.FenParser +import de.nowchess.io.pgn.PgnParser +import de.nowchess.rules.sets.DefaultRules import jakarta.enterprise.context.ApplicationScoped import scala.collection.mutable @@ -12,22 +17,241 @@ import scala.collection.mutable class GameStore: private val games: mutable.Map[String, GameSession] = mutable.Map.empty + // ─── Create / Get ──────────────────────────────────────────────── + def create(req: CreateGameRequest): GameSession = synchronized: - val id = GameId.generate() - val white = toPlayerInfo(req.white, "white", "White") - val black = toPlayerInfo(req.black, "black", "Black") - val session = GameSession( - gameId = id, - white = white, - black = black, - context = GameContext.initial, - invoker = new CommandInvoker(), - ) + val id = generateId() + val session = newSession(id, req.white, req.black, GameContext.initial) games(id) = session session def get(id: String): Option[GameSession] = synchronized: games.get(id) + // ─── Move-making ───────────────────────────────────────────────── + + def applyMove(id: String, uci: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + parseUci(uci) match + case None => Left(s"Invalid UCI notation: $uci") + case Some((from, to, promotion)) => + val legalCandidates = DefaultRules.legalMoves(session.context)(from) + findMatchingMove(legalCandidates, to, promotion) match + case None => Left(s"$uci is not a legal move") + case Some(move) => + val nextCtx = DefaultRules.applyMove(session.context)(move) + val prevCtx = session.context + val cmd = MoveCommand( + from = move.from, + to = move.to, + moveResult = Some(MoveResult.Successful(nextCtx, prevCtx.board.pieceAt(move.to))), + previousContext = Some(prevCtx), + ) + session.invoker.execute(cmd) + val result = detectGameOver(nextCtx) + val updated = session.copy(context = nextCtx, result = result, drawOfferedBy = None) + games(id) = updated + Right(updated) + + def legalMoves(id: String, square: Option[Square]): Either[String, List[Move]] = synchronized: + withSession(id): session => + val moves = square match + case Some(sq) => DefaultRules.legalMoves(session.context)(sq) + case None => DefaultRules.allLegalMoves(session.context) + Right(moves) + + // ─── Undo / Redo ───────────────────────────────────────────────── + + def undo(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if !session.invoker.canUndo then Left("No moves to undo") + else + val idx = session.invoker.getCurrentIndex + session.invoker.history(idx) match + case cmd: MoveCommand => + cmd.previousContext match + case None => Left("Cannot undo: no previous context stored") + case Some(prevCtx) => + session.invoker.undo() + val updated = session.copy(context = prevCtx, result = None, drawOfferedBy = None) + games(id) = updated + Right(updated) + case _ => Left("Cannot undo this command type") + + def redo(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if !session.invoker.canRedo then Left("No moves to redo") + else + val idx = session.invoker.getCurrentIndex + 1 + session.invoker.history(idx) match + case cmd: MoveCommand => + cmd.moveResult match + case Some(MoveResult.Successful(nextCtx, _)) => + session.invoker.redo() + val result = detectGameOver(nextCtx) + val updated = session.copy(context = nextCtx, result = result) + games(id) = updated + Right(updated) + case _ => Left("Cannot redo: move result not available") + case _ => Left("Cannot redo this command type") + + // ─── Resign ────────────────────────────────────────────────────── + + def resign(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + val winner = session.context.turn.opposite + val updated = session.copy(result = Some(GameResult.Resign(winner))) + games(id) = updated + Right(updated) + + // ─── Draw actions ──────────────────────────────────────────────── + + def drawAction(id: String, action: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + action match + case "offer" => + val updated = session.copy(drawOfferedBy = Some(session.context.turn)) + games(id) = updated + Right(updated) + case "accept" => + session.drawOfferedBy match + case None => Left("No draw offer to accept") + case Some(offerer) if offerer == session.context.turn => + Left("Cannot accept your own draw offer") + case Some(_) => + val updated = session.copy(result = Some(GameResult.AgreedDraw), drawOfferedBy = None) + games(id) = updated + Right(updated) + case "decline" => + session.drawOfferedBy match + case None => Left("No draw offer to decline") + case Some(_) => + val updated = session.copy(drawOfferedBy = None) + games(id) = updated + Right(updated) + case "claim" => + if DefaultRules.isFiftyMoveRule(session.context) then + val updated = session.copy(result = Some(GameResult.FiftyMoveDraw)) + games(id) = updated + Right(updated) + else Left("Fifty-move rule has not been triggered") + case other => Left(s"Unknown draw action: $other") + + // ─── Import ────────────────────────────────────────────────────── + + def importFen(req: ImportFenRequest): Either[String, GameSession] = synchronized: + FenParser.parseFen(req.fen) match + case Left(err) => Left(err) + case Right(ctx) => + val id = generateId() + val session = newSession(id, req.white, req.black, ctx) + games(id) = session + Right(session) + + def importPgn(pgn: String, white: Option[PlayerInfoDto], black: Option[PlayerInfoDto]): Either[String, GameSession] = + synchronized: + PgnParser.validatePgn(pgn) match + case Left(err) => Left(err) + case Right(game) => + val id = generateId() + val session = newSession(id, white, black, GameContext.initial) + replayIntoSession(session, game.moves, GameContext.initial) match + case Left(err) => Left(err) + case Right(s) => + games(id) = s + Right(s) + + // ─── Private helpers ───────────────────────────────────────────── + + private def withSession[A](id: String)(f: GameSession => Either[String, A]): Either[String, A] = + games.get(id) match + case None => Left(s"Game $id not found") + case Some(session) => f(session) + + private def generateId(): String = + var id = GameId.generate() + while games.contains(id) do id = GameId.generate() + id + + private def newSession( + id: String, + white: Option[PlayerInfoDto], + black: Option[PlayerInfoDto], + ctx: GameContext, + ): GameSession = + GameSession( + gameId = id, + white = toPlayerInfo(white, "white", "White"), + black = toPlayerInfo(black, "black", "Black"), + context = ctx, + invoker = new CommandInvoker(), + ) + private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo = dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName)) + + private def parseUci(uci: String): Option[(Square, Square, Option[PromotionPiece])] = + if uci.length < 4 || uci.length > 5 then None + else + for + from <- Square.fromAlgebraic(uci.substring(0, 2)) + to <- Square.fromAlgebraic(uci.substring(2, 4)) + yield + val promotion = if uci.length == 5 then parsePromotionChar(uci.charAt(4)) else None + (from, to, promotion) + + private def parsePromotionChar(c: Char): Option[PromotionPiece] = + c match + case 'q' => Some(PromotionPiece.Queen) + case 'r' => Some(PromotionPiece.Rook) + case 'b' => Some(PromotionPiece.Bishop) + case 'n' => Some(PromotionPiece.Knight) + case _ => None + + private def findMatchingMove( + candidates: List[Move], + to: Square, + promotion: Option[PromotionPiece], + ): Option[Move] = + candidates.filter(_.to == to) match + case Nil => None + case moves => + promotion match + case Some(pp) => moves.find(_.moveType == MoveType.Promotion(pp)) + case None => moves.find(m => !m.moveType.isInstanceOf[MoveType.Promotion]) + .orElse(moves.headOption) + + private def detectGameOver(ctx: GameContext): Option[GameResult] = + if DefaultRules.isCheckmate(ctx) then Some(GameResult.Checkmate(ctx.turn.opposite)) + else if DefaultRules.isStalemate(ctx) then Some(GameResult.Stalemate) + else if DefaultRules.isInsufficientMaterial(ctx) then Some(GameResult.InsufficientMaterial) + else None + + private def replayIntoSession( + session: GameSession, + moves: List[Move], + startCtx: GameContext, + ): Either[String, GameSession] = + moves.foldLeft[Either[String, GameSession]](Right(session)): + case (Left(err), _) => Left(err) + case (Right(s), move) => + val legal = DefaultRules.legalMoves(s.context)(move.from) + legal.find(m => m.from == move.from && m.to == move.to && m.moveType == move.moveType) + .orElse(legal.find(m => m.from == move.from && m.to == move.to)) match + case None => Left(s"Illegal move in PGN: $move") + case Some(legalMove) => + val nextCtx = DefaultRules.applyMove(s.context)(legalMove) + val cmd = MoveCommand( + from = legalMove.from, + to = legalMove.to, + moveResult = Some(MoveResult.Successful(nextCtx, s.context.board.pieceAt(legalMove.to))), + previousContext = Some(s.context), + ) + s.invoker.execute(cmd) + Right(s.copy(context = nextCtx)) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala new file mode 100644 index 0000000..395af14 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala @@ -0,0 +1,87 @@ +package de.nowchess.backcore.resource + +import de.nowchess.api.board.Square +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.backcore.dto.* +import de.nowchess.backcore.game.{GameMapper, GameStore} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{MediaType, Response} + +@Path("/api/board/game") +@Produces(Array(MediaType.APPLICATION_JSON)) +@ApplicationScoped +class MoveResource @Inject() (store: GameStore): + + @POST + @Path("/{gameId}/move/{uci}") + def makeMove( + @PathParam("gameId") gameId: String, + @PathParam("uci") uci: String, + ): Response = + store.applyMove(gameId, uci) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build() + + @GET + @Path("/{gameId}/moves") + def getLegalMoves( + @PathParam("gameId") gameId: String, + @QueryParam("square") squareParam: String, + ): Response = + val square = Option(squareParam).flatMap(Square.fromAlgebraic) + store.legalMoves(gameId, square) match + case Right(moves) => + val dtos = moves.map(toLegalMoveDto) + Response.ok(LegalMovesResponse(dtos)).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("ERROR", err)).build() + + @POST + @Path("/{gameId}/undo") + def undoMove(@PathParam("gameId") gameId: String): Response = + store.undo(gameId) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build() + + @POST + @Path("/{gameId}/redo") + def redoMove(@PathParam("gameId") gameId: String): Response = + store.redo(gameId) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build() + + private def toLegalMoveDto(move: Move): LegalMoveDto = + val uci = GameMapper.moveToUci(move) + val (moveType, promotion) = move.moveType match + case MoveType.Normal(true) => ("capture", None) + case MoveType.Normal(false) => ("normal", None) + case MoveType.CastleKingside => ("castleKingside", None) + case MoveType.CastleQueenside => ("castleQueenside", None) + case MoveType.EnPassant => ("enPassant", None) + case MoveType.Promotion(pp) => + val pName = pp match + case PromotionPiece.Queen => "queen" + case PromotionPiece.Rook => "rook" + case PromotionPiece.Bishop => "bishop" + case PromotionPiece.Knight => "knight" + ("promotion", Some(pName)) + LegalMoveDto( + from = move.from.toString, + to = move.to.toString, + uci = uci, + moveType = moveType, + promotion = promotion, + ) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala new file mode 100644 index 0000000..bd04b22 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala @@ -0,0 +1,77 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{containsString, empty, equalTo, hasItem, hasItems, not, notNullValue} +import org.junit.jupiter.api.Test + +@QuarkusTest +class MoveResourceTest: + + private def createGame(): String = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + @Test + def makeMoveReturns200WithUpdatedFen(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + .body("fen", containsString("4P3")) // e4 pawn present in FEN + .body("turn", equalTo("black")) + .body("moves", hasItem("e2e4")) + + @Test + def makeMoveOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .post("/api/board/game/XXXXXXXX/move/e2e4") + .`then`() + .statusCode(404) + + @Test + def illegalMoveReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e5") // illegal — pawns can't jump 3 squares + .`then`() + .statusCode(400) + + @Test + def getLegalMovesReturnsNonEmptyList(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/moves") + .`then`() + .statusCode(200) + .body("moves", not(empty())) + + @Test + def getLegalMovesFilteredBySquareReturnsCorrectMoves(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/moves?square=e2") + .`then`() + .statusCode(200) + .body("moves.uci", hasItems("e2e3", "e2e4")) + + @Test + def getLegalMovesOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .get("/api/board/game/XXXXXXXX/moves") + .`then`() + .statusCode(404) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala new file mode 100644 index 0000000..2e86344 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala @@ -0,0 +1,80 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{containsString, equalTo} +import org.junit.jupiter.api.Test + +@QuarkusTest +class UndoRedoTest: + + private val initialFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + private def createGame(): String = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + @Test + def undoAfterMoveRestoresOriginalPosition(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(200) + .body("fen", equalTo(initialFen)) + .body("undoAvailable", equalTo(false)) + + @Test + def redoAfterUndoRestoresMovedPosition(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/redo") + .`then`() + .statusCode(200) + .body("fen", containsString("4P3")) + .body("turn", equalTo("black")) + + @Test + def undoWithNoHistoryReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(400) + + @Test + def redoWithNoRedoStackReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/redo") + .`then`() + .statusCode(400) -- 2.52.0 From 33dd63a9b6c0438b9cf851040d99367a7dd7b085 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 16:02:10 +0200 Subject: [PATCH 06/13] feat: NCS-37 resign, draw actions, and export/stream endpoints --- .../nowchess/backcore/game/GameMapper.scala | 7 + .../de/nowchess/backcore/game/GameStore.scala | 2 +- .../backcore/resource/GameResource.scala | 68 +++++++- .../backcore/resource/ResignDrawTest.scala | 152 ++++++++++++++++++ 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala index 2c7a45a..96aac04 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -1,5 +1,7 @@ package de.nowchess.backcore.game +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule import de.nowchess.api.board.Color import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.backcore.dto.* @@ -8,6 +10,11 @@ import de.nowchess.io.pgn.PgnExporter import de.nowchess.rules.sets.DefaultRules object GameMapper: + private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) + + + def toGameFullJson(session: GameSession): String = + mapper.writeValueAsString(toGameFull(session)) def toGameFull(session: GameSession): GameFullResponse = GameFullResponse( diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala index 5202800..e42a2d3 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala @@ -51,7 +51,7 @@ class GameStore: ) session.invoker.execute(cmd) val result = detectGameOver(nextCtx) - val updated = session.copy(context = nextCtx, result = result, drawOfferedBy = None) + val updated = session.copy(context = nextCtx, result = result) games(id) = updated Right(updated) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala index 54c407f..61bcaf3 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala @@ -9,11 +9,11 @@ import jakarta.ws.rs.core.{MediaType, Response} @Path("/api/board/game") @Produces(Array(MediaType.APPLICATION_JSON)) -@Consumes(Array(MediaType.APPLICATION_JSON)) @ApplicationScoped class GameResource @Inject() (store: GameStore): @POST + @Consumes(Array(MediaType.APPLICATION_JSON)) def createGame(req: CreateGameRequest): Response = val session = store.create(Option(req).getOrElse(CreateGameRequest())) Response.status(201).entity(GameMapper.toGameFull(session)).build() @@ -27,3 +27,69 @@ class GameResource @Inject() (store: GameStore): Response.status(404) .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) .build() + + @GET + @Path("/{gameId}/stream") + @Produces(Array("application/x-ndjson")) + def streamGame(@PathParam("gameId") gameId: String): Response = + store.get(gameId) match + case None => + Response.status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + case Some(session) => + // Simplified: return a single-line NDJSON snapshot of the current game state + val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(session)}}""" + Response.ok(event + "\n").build() + + @POST + @Path("/{gameId}/resign") + def resignGame(@PathParam("gameId") gameId: String): Response = + store.resign(gameId) match + case Right(_) => Response.ok(OkResponse()).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build() + + @POST + @Path("/{gameId}/draw/{action}") + def drawAction( + @PathParam("gameId") gameId: String, + @PathParam("action") action: String, + ): Response = + store.drawAction(gameId, action) match + case Right(_) => Response.ok(OkResponse()).build() + case Left(err) if err.contains("not found") => + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() + case Left(err) => + Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build() + + @GET + @Path("/{gameId}/export/fen") + @Produces(Array(MediaType.TEXT_PLAIN)) + def exportFen(@PathParam("gameId") gameId: String): Response = + store.get(gameId) match + case None => + Response.status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + case Some(session) => + import de.nowchess.io.fen.FenExporter + Response.ok(FenExporter.exportGameContext(session.context)).build() + + @GET + @Path("/{gameId}/export/pgn") + @Produces(Array("application/x-chess-pgn")) + def exportPgn(@PathParam("gameId") gameId: String): Response = + store.get(gameId) match + case None => + Response.status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + case Some(session) => + import de.nowchess.io.pgn.PgnExporter + Response.ok(PgnExporter.exportGameContext(session.context)).build() diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala new file mode 100644 index 0000000..74f7587 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala @@ -0,0 +1,152 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{equalTo, notNullValue} +import org.junit.jupiter.api.Test + +@QuarkusTest +class ResignDrawTest: + + private def createGame(): String = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + // ─── Resign ──────────────────────────────────────────────────── + + @Test + def resignReturns200(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/resign") + .`then`() + .statusCode(200) + .body("ok", equalTo(true)) + + @Test + def afterResignGameShowsResignStatusAndWinner(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/resign") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId") + .`then`() + .statusCode(200) + .body("state.status", equalTo("resign")) + .body("state.winner", notNullValue()) + + @Test + def resignOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .post("/api/board/game/XXXXXXXX/resign") + .`then`() + .statusCode(404) + + // ─── Draw ────────────────────────────────────────────────────── + + @Test + def offerDrawSetsDrawOfferedStatus(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/offer") + .`then`() + .statusCode(200) + .body("ok", equalTo(true)) + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId") + .`then`() + .statusCode(200) + .body("state.status", equalTo("drawOffered")) + + @Test + def acceptDrawAfterOfferSetsDrawStatus(): Unit = + val gameId = createGame() + // White offers + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/offer") + .`then`() + .statusCode(200) + + // Black moves so it's black's turn... actually the API doesn't enforce turn-based draw accept. + // White offered, so black (opponent) accepts — but since there's no auth, we just call accept. + // The GameStore checks drawOfferedBy != turn to allow accept. + // White offered on white's turn, so black needs to accept — but current turn is still white. + // We need to make a move first to switch turns. + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + // Now it's black's turn and white offered the draw — black accepts + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/accept") + .`then`() + .statusCode(200) + .body("ok", equalTo(true)) + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId") + .`then`() + .statusCode(200) + .body("state.status", equalTo("draw")) + + @Test + def declineDrawClearsOffer(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/offer") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/decline") + .`then`() + .statusCode(200) + .body("ok", equalTo(true)) + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId") + .`then`() + .statusCode(200) + .body("state.status", equalTo("started")) + + @Test + def acceptWithoutOfferReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/draw/accept") + .`then`() + .statusCode(400) + + @Test + def drawOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .post("/api/board/game/XXXXXXXX/draw/offer") + .`then`() + .statusCode(404) -- 2.52.0 From acddd58ad32e38afa91e158290707cac89f9cb7a Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 16:05:19 +0200 Subject: [PATCH 07/13] feat: NCS-37 import FEN/PGN, export endpoints, NDJSON stream snapshot --- .../backcore/resource/ImportResource.scala | 29 ++++ .../backcore/resource/ImportExportTest.scala | 163 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/ImportExportTest.scala diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala new file mode 100644 index 0000000..6291661 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala @@ -0,0 +1,29 @@ +package de.nowchess.backcore.resource + +import de.nowchess.backcore.dto.{ApiErrorResponse, ImportFenRequest, ImportPgnRequest} +import de.nowchess.backcore.game.{GameMapper, GameStore} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{MediaType, Response} + +@Path("/api/board/game/import") +@Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) +@ApplicationScoped +class ImportResource @Inject() (store: GameStore): + + @POST + @Path("/fen") + def importFen(req: ImportFenRequest): Response = + store.importFen(Option(req).getOrElse(ImportFenRequest())) match + case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build() + + @POST + @Path("/pgn") + def importPgn(req: ImportPgnRequest): Response = + val body = Option(req).getOrElse(ImportPgnRequest()) + store.importPgn(body.pgn, None, None) match + case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build() diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ImportExportTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ImportExportTest.scala new file mode 100644 index 0000000..a4a8eff --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ImportExportTest.scala @@ -0,0 +1,163 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{containsString, equalTo, matchesPattern, notNullValue} +import org.junit.jupiter.api.Test + +@QuarkusTest +class ImportExportTest: + + private val startFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + // ─── Import FEN ──────────────────────────────────────────────── + + @Test + def importFenReturns201WithCorrectPosition(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body(s"""{"fen":"$startFen"}""") + .when() + .post("/api/board/game/import/fen") + .`then`() + .statusCode(201) + .body("gameId", matchesPattern("[A-Za-z0-9]{8}")) + .body("state.fen", equalTo(startFen)) + .body("state.turn", equalTo("white")) + + @Test + def importFenWithCustomPositionWorks(): Unit = + val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" + RestAssured.`given`() + .contentType("application/json") + .body(s"""{"fen":"$fen"}""") + .when() + .post("/api/board/game/import/fen") + .`then`() + .statusCode(201) + .body("state.fen", equalTo(fen)) + .body("state.turn", equalTo("black")) + + @Test + def importFenWithInvalidFenReturns400(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body("""{"fen":"not-a-fen"}""") + .when() + .post("/api/board/game/import/fen") + .`then`() + .statusCode(400) + + // ─── Import PGN ──────────────────────────────────────────────── + + @Test + def importPgnReturns201(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body("""{"pgn":"1. e4 e5 2. Nf3 Nc6 *"}""") + .when() + .post("/api/board/game/import/pgn") + .`then`() + .statusCode(201) + .body("gameId", matchesPattern("[A-Za-z0-9]{8}")) + .body("state.turn", equalTo("white")) + + @Test + def importPgnWithInvalidPgnReturns400(): Unit = + RestAssured.`given`() + .contentType("application/json") + .body("""{"pgn":"1. z9 *"}""") + .when() + .post("/api/board/game/import/pgn") + .`then`() + .statusCode(400) + + // ─── Export FEN ──────────────────────────────────────────────── + + @Test + def exportFenReturnsStartingFen(): Unit = + val gameId = RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/export/fen") + .`then`() + .statusCode(200) + .body(equalTo(startFen)) + + @Test + def exportFenOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .get("/api/board/game/XXXXXXXX/export/fen") + .`then`() + .statusCode(404) + + // ─── Export PGN ──────────────────────────────────────────────── + + @Test + def exportPgnReturnsText(): Unit = + val gameId = RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/export/pgn") + .`then`() + .statusCode(200) + .body(containsString("e4")) + + // ─── Stream ──────────────────────────────────────────────────── + + @Test + def streamReturnsNdjsonSnapshot(): Unit = + val gameId = RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + val body = RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/stream") + .`then`() + .statusCode(200) + .contentType("application/x-ndjson") + .extract() + .body() + .asString() + + assert(body.trim.startsWith("""{"type":"gameFull""""), s"Expected gameFull event, got: $body") + + @Test + def streamOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .get("/api/board/game/XXXXXXXX/stream") + .`then`() + .statusCode(404) -- 2.52.0 From 2c4d96e373d0f028d0c2d051b87084082cef05b3 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 16:57:33 +0200 Subject: [PATCH 08/13] feat(backcore): Quarkus tests Added quarkus test compatability with sonar through jacoco reporter --- .idea/gradle.xml | 1 + .idea/scala_compiler.xml | 2 +- build.gradle.kts | 8 ++++++++ modules/backcore/build.gradle.kts | 17 +++++++++++++++-- .../backcore/src/main/resources/application.yml | 3 +++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 59fb705..4ddf417 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -11,6 +11,7 @@