Compare commits
9 Commits
api-0.11.0
...
db955c08a5
| Author | SHA1 | Date | |
|---|---|---|---|
| db955c08a5 | |||
| 2c4d96e373 | |||
| acddd58ad3 | |||
| 33dd63a9b6 | |||
| 6fc3b3c3df | |||
| 41fa674bf7 | |||
| 21eee717f1 | |||
| d59d692381 | |||
| c830b143dc |
Generated
+1
@@ -11,6 +11,7 @@
|
|||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
<option value="$PROJECT_DIR$/modules/api" />
|
<option value="$PROJECT_DIR$/modules/api" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/backcore" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
<option value="$PROJECT_DIR$/modules/io" />
|
<option value="$PROJECT_DIR$/modules/io" />
|
||||||
<option value="$PROJECT_DIR$/modules/rule" />
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</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="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<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
|
if (report.exists()) report.absolutePath else null
|
||||||
}.joinToString(",")
|
}.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)
|
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,80 @@
|
|||||||
|
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,260 @@
|
|||||||
|
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,99 @@
|
|||||||
|
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,67 @@
|
|||||||
|
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,177 @@
|
|||||||
|
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,84 @@
|
|||||||
|
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,168 @@
|
|||||||
|
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,88 @@
|
|||||||
|
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)
|
||||||
+3
-1
@@ -69,7 +69,9 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
|
|
||||||
engine.context shouldBe target
|
engine.context shouldBe target
|
||||||
engine.commandHistory shouldBe empty
|
engine.commandHistory shouldBe empty
|
||||||
events.lastOption.exists { case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false } shouldBe true
|
events.lastOption.exists {
|
||||||
|
case _: de.nowchess.chess.observer.BoardResetEvent => true; case _ => false
|
||||||
|
} shouldBe true
|
||||||
|
|
||||||
test("redo event includes captured piece description when replaying a capture"):
|
test("redo event includes captured piece description when replaying a capture"):
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
|
|||||||
@@ -70,58 +70,66 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
|||||||
FenParserFastParse.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
FenParserFastParse.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
|
||||||
test("parseFen handles all individual castling rights"):
|
test("parseFen handles all individual castling rights"):
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w K - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w K - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.castlingRights.whiteQueenSide shouldBe false
|
ctx.castlingRights.whiteQueenSide shouldBe false
|
||||||
ctx.castlingRights.blackKingSide shouldBe false
|
ctx.castlingRights.blackKingSide shouldBe false
|
||||||
ctx.castlingRights.blackQueenSide shouldBe false
|
ctx.castlingRights.blackQueenSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Q - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Q - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteQueenSide shouldBe true
|
ctx.castlingRights.whiteQueenSide shouldBe true
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w k - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w k - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.blackKingSide shouldBe true
|
ctx.castlingRights.blackKingSide shouldBe true
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w q - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w q - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.blackQueenSide shouldBe true
|
ctx.castlingRights.blackQueenSide shouldBe true
|
||||||
ctx.castlingRights.whiteKingSide shouldBe false
|
ctx.castlingRights.whiteKingSide shouldBe false,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("parseFen parses all en passant squares"):
|
test("parseFen parses all en passant squares"):
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - a3 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.A, Rank.R3))
|
.parseFen("8/8/8/8/8/8/8/8 w - a3 0 1")
|
||||||
)
|
.fold(_ => fail(), ctx => ctx.enPassantSquare shouldBe Some(Square(File.A, Rank.R3)))
|
||||||
|
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - h6 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.enPassantSquare shouldBe Some(Square(File.H, Rank.R6))
|
.parseFen("8/8/8/8/8/8/8/8 w - h6 0 1")
|
||||||
)
|
.fold(_ => fail(), ctx => ctx.enPassantSquare shouldBe Some(Square(File.H, Rank.R6)))
|
||||||
|
|
||||||
test("parseFen parses different halfMove and fullMove clocks"):
|
test("parseFen parses different halfMove and fullMove clocks"):
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 5 10").fold(_ => fail(), ctx =>
|
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 5 10").fold(_ => fail(), ctx => ctx.halfMoveClock shouldBe 5)
|
||||||
ctx.halfMoveClock shouldBe 5
|
|
||||||
)
|
|
||||||
|
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 100").fold(_ => fail(), ctx =>
|
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 100").fold(_ => fail(), ctx => ctx.halfMoveClock shouldBe 0)
|
||||||
ctx.halfMoveClock shouldBe 0
|
|
||||||
)
|
|
||||||
|
|
||||||
test("parseBoard parses boards with mixed empty and piece tokens"):
|
test("parseBoard parses boards with mixed empty and piece tokens"):
|
||||||
val mixed = "8/1p1p1p1p/8/1P1P1P1P/8/8/8/8"
|
val mixed = "8/1p1p1p1p/8/1P1P1P1P/8/8/8/8"
|
||||||
FenParserFastParse.parseBoard(mixed) should not be empty
|
FenParserFastParse.parseBoard(mixed) should not be empty
|
||||||
|
|
||||||
test("parseFen handles turn transitions"):
|
test("parseFen handles turn transitions"):
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w - - 0 1").fold(_ => fail(), ctx => ctx.turn shouldBe Color.White)
|
||||||
ctx.turn shouldBe Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), ctx => ctx.turn shouldBe Color.Black)
|
||||||
ctx.turn shouldBe Color.Black
|
|
||||||
)
|
|
||||||
|
|
||||||
test("parseFen rejects invalid piece characters"):
|
test("parseFen rejects invalid piece characters"):
|
||||||
FenParserFastParse.parseFen("8x/8/8/8/8/8/8/8 w - - 0 1").isLeft shouldBe true
|
FenParserFastParse.parseFen("8x/8/8/8/8/8/8/8 w - - 0 1").isLeft shouldBe true
|
||||||
@@ -150,25 +158,33 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
|||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), _.turn shouldBe Color.Black)
|
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 b - - 0 1").fold(_ => fail(), _.turn shouldBe Color.Black)
|
||||||
|
|
||||||
test("parseFen tests all castling combinations"):
|
test("parseFen tests all castling combinations"):
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w KQkq - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("8/8/8/8/8/8/8/8 w KQkq - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.castlingRights.whiteQueenSide shouldBe true
|
ctx.castlingRights.whiteQueenSide shouldBe true
|
||||||
ctx.castlingRights.blackKingSide shouldBe true
|
ctx.castlingRights.blackKingSide shouldBe true
|
||||||
ctx.castlingRights.blackQueenSide shouldBe true
|
ctx.castlingRights.blackQueenSide shouldBe true,
|
||||||
)
|
)
|
||||||
|
|
||||||
FenParserFastParse.parseFen("8/8/8/8/8/8/8/8 w Kq - 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
|
.parseFen("8/8/8/8/8/8/8/8 w Kq - 0 1")
|
||||||
|
.fold(
|
||||||
|
_ => fail(),
|
||||||
|
ctx =>
|
||||||
ctx.castlingRights.whiteKingSide shouldBe true
|
ctx.castlingRights.whiteKingSide shouldBe true
|
||||||
ctx.castlingRights.whiteQueenSide shouldBe false
|
ctx.castlingRights.whiteQueenSide shouldBe false
|
||||||
ctx.castlingRights.blackKingSide shouldBe false
|
ctx.castlingRights.blackKingSide shouldBe false
|
||||||
ctx.castlingRights.blackQueenSide shouldBe true
|
ctx.castlingRights.blackQueenSide shouldBe true,
|
||||||
)
|
)
|
||||||
|
|
||||||
test("parseFen tests all en passant files"):
|
test("parseFen tests all en passant files"):
|
||||||
for file <- Seq("a", "b", "c", "d", "e", "f", "g", "h") do
|
for file <- Seq("a", "b", "c", "d", "e", "f", "g", "h") do
|
||||||
FenParserFastParse.parseFen(s"8/8/8/8/8/8/8/8 w - ${file}3 0 1").fold(_ => fail(), ctx =>
|
FenParserFastParse
|
||||||
ctx.enPassantSquare should not be empty
|
.parseFen(s"8/8/8/8/8/8/8/8 w - ${file}3 0 1")
|
||||||
)
|
.fold(_ => fail(), ctx => ctx.enPassantSquare should not be empty)
|
||||||
|
|
||||||
test("parseBoard with mixed pieces and empty squares"):
|
test("parseBoard with mixed pieces and empty squares"):
|
||||||
FenParserFastParse.parseBoard("r1bqkb1r/pppppppp/2n2n2/8/8/2N2N2/PPPPPPPP/R1BQKB1R") should not be empty
|
FenParserFastParse.parseBoard("r1bqkb1r/pppppppp/2n2n2/8/8/2N2N2/PPPPPPPP/R1BQKB1R") should not be empty
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
|||||||
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
val fen = "8/8/8/3p4/4P3/8/8/8 w - - 0 1"
|
||||||
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
val context = FenParser.parseFen(fen).fold(_ => fail(), identity)
|
||||||
val moves = rules.allLegalMoves(context)
|
val moves = rules.allLegalMoves(context)
|
||||||
val captures = moves.filter(m => m.from == Square(File.E, Rank.R4) && (m.moveType match { case _: MoveType.Normal => true; case _ => false }))
|
val captures = moves.filter(m =>
|
||||||
|
m.from == Square(File.E, Rank.R4) && (m.moveType match { case _: MoveType.Normal => true; case _ => false }),
|
||||||
|
)
|
||||||
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
captures.exists(m => m.to == Square(File.D, Rank.R5)) shouldBe true
|
||||||
|
|
||||||
test("pawn cannot move backward"):
|
test("pawn cannot move backward"):
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
case Some(piece) =>
|
case Some(piece) =>
|
||||||
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
Seq(bgRect) ++ PieceSprites.loadPieceImage(piece, squareSize * 0.8).toSeq
|
||||||
case None =>
|
case None =>
|
||||||
Seq(bgRect)): Seq[scalafx.scene.Node]
|
Seq(bgRect)
|
||||||
|
): Seq[scalafx.scene.Node]
|
||||||
}
|
}
|
||||||
|
|
||||||
def showMessage(msg: String): Unit =
|
def showMessage(msg: String): Unit =
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class ChessGUIApp extends JFXApplication:
|
|||||||
stage.scene = new Scene {
|
stage.scene = new Scene {
|
||||||
root = boardView
|
root = boardView
|
||||||
// Load CSS if available
|
// Load CSS if available
|
||||||
try {
|
try
|
||||||
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
|
Option(getClass.getResource("/styles.css")).foreach(url => stylesheets.add(url.toExternalForm))
|
||||||
} catch {
|
catch {
|
||||||
case _: Exception => // CSS is optional
|
case _: Exception => // CSS is optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
rootProject.name = "NowChessSystems"
|
rootProject.name = "NowChessSystems"
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
val quarkusPluginVersion: String by settings
|
||||||
|
val quarkusPluginId: String by settings
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenLocal()
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id(quarkusPluginId) version quarkusPluginVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
include(
|
include(
|
||||||
"modules:core",
|
"modules:core",
|
||||||
"modules:api",
|
"modules:api",
|
||||||
"modules:io",
|
"modules:io",
|
||||||
"modules:rule",
|
"modules:rule",
|
||||||
"modules:ui",
|
"modules:ui",
|
||||||
|
"modules:backcore",
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user