Compare commits
8 Commits
api-0.6.0
...
020941e404
| Author | SHA1 | Date | |
|---|---|---|---|
| 020941e404 | |||
| 95fcfc824c | |||
| 37d74335c7 | |||
| a6ff529f95 | |||
| d40a64a160 | |||
| 24ae9893ef | |||
| e3c1e9b76d | |||
| 15904d4545 |
Generated
+1
@@ -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" />
|
||||
|
||||
Generated
+1
-1
@@ -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>
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ScalaProjectSettings">
|
||||
<option name="scala3DisclaimerShown" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
quarkusPluginId=io.quarkus
|
||||
quarkusPluginVersion=3.32.4
|
||||
quarkusPlatformGroupId=io.quarkus.platform
|
||||
quarkusPlatformArtifactId=quarkus-bom
|
||||
quarkusPlatformVersion=3.32.4
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user