Compare commits

...

8 Commits

Author SHA1 Message Date
LQ63 020941e404 feat(backcore): Quarkus tests
Build & Test (NowChessSystems) TeamCity build failed
Added quarkus test compatability with sonar through jacoco reporter
2026-04-13 16:57:51 +02:00
LQ63 95fcfc824c feat: NCS-37 import FEN/PGN, export endpoints, NDJSON stream snapshot
Build & Test (NowChessSystems) TeamCity build failed
2026-04-13 16:05:19 +02:00
LQ63 37d74335c7 feat: NCS-37 resign, draw actions, and export/stream endpoints 2026-04-13 16:02:10 +02:00
LQ63 a6ff529f95 feat: NCS-37 move-making, legal moves, undo/redo endpoints 2026-04-13 15:58:01 +02:00
LQ63 d40a64a160 fix: use PgnExporter for SAN pgn field, fix status priority in GameMapper 2026-04-13 15:49:36 +02:00
LQ63 24ae9893ef 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 <noreply@anthropic.com>
2026-04-13 15:44:05 +02:00
LQ63 e3c1e9b76d fix: use version catalog keys for jackson-scala and junit-bom in backcore 2026-04-13 15:37:47 +02:00
LQ63 15904d4545 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 <noreply@anthropic.com>
2026-04-13 15:36:14 +02:00
24 changed files with 1344 additions and 2 deletions
+1
View File
@@ -11,6 +11,7 @@
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/backcore" />
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/rule" />
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<profile name="Gradle 2" modules="NowChessSystems.modules.backcore.integrationTest,NowChessSystems.modules.backcore.main,NowChessSystems.modules.backcore.native-test,NowChessSystems.modules.backcore.quarkus-generated-sources,NowChessSystems.modules.backcore.quarkus-test-generated-sources,NowChessSystems.modules.backcore.scoverage,NowChessSystems.modules.backcore.test,NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
<parameters>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ScalaProjectSettings">
<option name="scala3DisclaimerShown" value="true" />
</component>
</project>
+8
View File
@@ -21,7 +21,15 @@ sonar {
if (report.exists()) report.absolutePath else null
}.joinToString(",")
val jacocoReports = subprojects.mapNotNull { subproject ->
val report = subproject.file("build/reports/jacoco/test/jacocoTestReport.xml")
if (report.exists()) report.absolutePath else null
}.joinToString(",")
property("sonar.scala.coverage.reportPaths", scoverageReports)
if (jacocoReports.isNotEmpty()) {
property("sonar.coverage.jacoco.xmlReportPaths", jacocoReports)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
quarkusPluginId=io.quarkus
quarkusPluginVersion=3.32.4
quarkusPlatformGroupId=io.quarkus.platform
quarkusPlatformArtifactId=quarkus-bom
quarkusPlatformVersion=3.32.4
+101
View File
@@ -0,0 +1,101 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
id("jacoco")
}
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
repositories {
mavenCentral()
mavenLocal()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
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(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:${versions["JACKSON_SCALA"]!!}")
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"]!!}")
testImplementation("io.quarkus:quarkus-junit5")
testImplementation("io.quarkus:quarkus-jacoco")
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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest", "junit-jupiter")
}
testLogging {
events("passed", "skipped", "failed")
}
finalizedBy(tasks.named("jacocoTestReport"))
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
executionData.setFrom(layout.buildDirectory.file("jacoco-quarkus.exec"))
sourceDirectories.setFrom(files("src/main/scala"))
classDirectories.setFrom(files(layout.buildDirectory.dir("classes/scala/main")))
reports {
xml.required.set(true)
xml.outputLocation.set(
layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")
)
html.required.set(false)
}
}
@@ -0,0 +1,6 @@
quarkus:
http:
port: 8080
jacoco:
data-file: ${user.dir}/build/jacoco-quarkus.exec
report: false
@@ -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)
@@ -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])
@@ -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
@@ -0,0 +1,81 @@
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.*
import de.nowchess.io.fen.FenExporter
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(
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 DefaultRules.isCheck(ctx) then ("check", None)
else if session.drawOfferedBy.isDefined then ("drawOffered", None)
else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", 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 =
// Use PgnExporter with no headers to get move-text only (SAN notation)
PgnExporter.exportGame(Map.empty, moves)
@@ -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
@@ -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,
)
@@ -0,0 +1,257 @@
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, 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
@ApplicationScoped
class GameStore:
private val games: mutable.Map[String, GameSession] = mutable.Map.empty
// ─── Create / Get ────────────────────────────────────────────────
def create(req: CreateGameRequest): GameSession = synchronized:
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)
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))
@@ -0,0 +1,95 @@
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))
@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()
@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()
@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()
@@ -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()
@@ -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,
)
@@ -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
()
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
+16 -1
View File
@@ -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",
)
"modules:backcore",
)