WIP: feat: NCS-38 Http4s server #26
Generated
+1
@@ -14,6 +14,7 @@
|
|||||||
<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" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/server" />
|
||||||
<option value="$PROJECT_DIR$/modules/ui" />
|
<option value="$PROJECT_DIR$/modules/ui" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
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.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.server.main,NowChessSystems.modules.server.scoverage,NowChessSystems.modules.server.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>
|
||||||
|
|||||||
+7
-1
@@ -34,7 +34,13 @@ val versions = mapOf(
|
|||||||
"JAVAFX" to "21.0.1",
|
"JAVAFX" to "21.0.1",
|
||||||
"JUNIT_BOM" to "5.13.4",
|
"JUNIT_BOM" to "5.13.4",
|
||||||
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
"FASTPARSE" to "3.0.2"
|
"FASTPARSE" to "3.0.2",
|
||||||
|
"HTTP4S" to "0.23.29",
|
||||||
|
"CIRCE" to "0.14.10"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
tasks.register("run") {
|
||||||
|
dependsOn(":modules:ui:run")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import org.gradle.jvm.tasks.Jar
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("scala")
|
||||||
|
id("org.scoverage") version "8.1"
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.nowchess"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
scala {
|
||||||
|
scalaVersion = versions["SCALA3"]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
scoverage {
|
||||||
|
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||||
|
excludedPackages.set(listOf("de.nowchess.server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("de.nowchess.server.ServerApp")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<ScalaCompile> {
|
||||||
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<Jar>("jar") {
|
||||||
|
duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.EXCLUDE
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.scala-lang:scala3-compiler_3") {
|
||||||
|
version { strictly(versions["SCALA3"]!!) }
|
||||||
|
}
|
||||||
|
implementation("org.scala-lang:scala3-library_3") {
|
||||||
|
version { strictly(versions["SCALA3"]!!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation(project(":modules:api"))
|
||||||
|
implementation(project(":modules:core"))
|
||||||
|
implementation(project(":modules:io"))
|
||||||
|
implementation(project(":modules:rule"))
|
||||||
|
|
||||||
|
val http4s = versions["HTTP4S"]!!
|
||||||
|
val circe = versions["CIRCE"]!!
|
||||||
|
|
||||||
|
implementation("org.http4s:http4s-ember-server_3:$http4s")
|
||||||
|
implementation("org.http4s:http4s-dsl_3:$http4s")
|
||||||
|
implementation("org.http4s:http4s-circe_3:$http4s")
|
||||||
|
implementation("io.circe:circe-core_3:$circe")
|
||||||
|
implementation("io.circe:circe-generic_3:$circe")
|
||||||
|
implementation("io.circe:circe-parser_3:$circe")
|
||||||
|
|
||||||
|
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
|
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeEngines("scalatest")
|
||||||
|
testLogging {
|
||||||
|
events("skipped", "failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import org.http4s.*
|
||||||
|
import org.http4s.dsl.io.*
|
||||||
|
import org.http4s.circe.*
|
||||||
|
import org.http4s.circe.CirceEntityDecoder.given
|
||||||
|
import io.circe.syntax.*
|
||||||
|
import de.nowchess.api.board.{Color, Square}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.chess.observer.InvalidMoveEvent
|
||||||
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
|
import JsonCodecs.given
|
||||||
|
|
||||||
|
class BoardRoutes(registry: GameRegistry):
|
||||||
|
|
||||||
|
def routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
|
||||||
|
case req @ POST -> Root / "api" / "board" / "game" / "import" / "fen" => importFen(req)
|
||||||
|
case req @ POST -> Root / "api" / "board" / "game" / "import" / "pgn" => importPgn(req)
|
||||||
|
case req @ POST -> Root / "api" / "board" / "game" => createGame(req)
|
||||||
|
case GET -> Root / "api" / "board" / "game" / id => getGame(id)
|
||||||
|
case GET -> Root / "api" / "board" / "game" / id / "stream" => streamGame(id)
|
||||||
|
case POST -> Root / "api" / "board" / "game" / id / "move" / uci => makeMove(id, uci)
|
||||||
|
case req @ GET -> Root / "api" / "board" / "game" / id / "moves" => getLegalMoves(req, id)
|
||||||
|
case POST -> Root / "api" / "board" / "game" / id / "undo" => undoMove(id)
|
||||||
|
case POST -> Root / "api" / "board" / "game" / id / "redo" => redoMove(id)
|
||||||
|
case POST -> Root / "api" / "board" / "game" / id / "resign" => resign(id)
|
||||||
|
case POST -> Root / "api" / "board" / "game" / id / "draw" / action => drawAction(id, action)
|
||||||
|
case GET -> Root / "api" / "board" / "game" / id / "export" / "fen" => exportFen(id)
|
||||||
|
case GET -> Root / "api" / "board" / "game" / id / "export" / "pgn" => exportPgn(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Game lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def createGame(req: Request[IO]): IO[Response[IO]] =
|
||||||
|
req.as[CreateGameRequest]
|
||||||
|
.handleError(_ => CreateGameRequest())
|
||||||
|
.flatMap { body =>
|
||||||
|
val white = resolvePlayer(body.white, "white")
|
||||||
|
val black = resolvePlayer(body.black, "black")
|
||||||
|
registry.create(white, black).flatMap { id =>
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) => Created(toGameFullDto(id, entry).asJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def getGame(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) => Ok(toGameFullDto(id, entry).asJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def streamGame(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) => Ok(GameFullEvent("gameFull", toGameFullDto(id, entry)).asJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def resign(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
|
||||||
|
case Some(entry) =>
|
||||||
|
IO.blocking {
|
||||||
|
val winner = entry.engine.context.turn.opposite
|
||||||
|
entry.gameOver = Some(GameOverState.Resigned(winner, entry.engine.context))
|
||||||
|
}.flatMap(_ => Ok(OkDto().asJson))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Move-making ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def makeMove(id: String, uci: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
|
||||||
|
case Some(entry) =>
|
||||||
|
IO.blocking {
|
||||||
|
entry.lastEvent = None
|
||||||
|
applyUci(entry, uci)
|
||||||
|
entry.lastEvent
|
||||||
|
}.flatMap {
|
||||||
|
case Some(InvalidMoveEvent(_, reason)) => badRequest("INVALID_MOVE", reason)
|
||||||
|
case _ => Ok(toGameStateDto(entry).asJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def getLegalMoves(req: Request[IO], id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
|
||||||
|
case Some(entry) =>
|
||||||
|
val squareOpt = req.uri.query.params.get("square").flatMap(Square.fromAlgebraic)
|
||||||
|
IO.blocking {
|
||||||
|
val ctx = entry.engine.context
|
||||||
|
val rs = entry.engine.ruleSet
|
||||||
|
squareOpt.map(rs.legalMoves(ctx)).getOrElse(rs.allLegalMoves(ctx))
|
||||||
|
}.flatMap { moves =>
|
||||||
|
Ok(LegalMovesDto(moves.map(toLegalMoveDto)).asJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def undoMove(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if !entry.engine.canUndo => badRequest("UNDO_UNAVAILABLE", "No moves to undo")
|
||||||
|
case Some(entry) =>
|
||||||
|
IO.blocking { entry.engine.undo() }.flatMap(_ => Ok(toGameStateDto(entry).asJson))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def redoMove(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if !entry.engine.canRedo => badRequest("REDO_UNAVAILABLE", "No moves to redo")
|
||||||
|
case Some(entry) =>
|
||||||
|
IO.blocking { entry.engine.redo() }.flatMap(_ => Ok(toGameStateDto(entry).asJson))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Draw handling ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def drawAction(id: String, action: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) if entry.isOver => badRequest("GAME_OVER", "Game is already over")
|
||||||
|
case Some(entry) => action match
|
||||||
|
case "offer" =>
|
||||||
|
IO.blocking { entry.drawOffered = true }.flatMap(_ => Ok(OkDto().asJson))
|
||||||
|
case "decline" =>
|
||||||
|
IO.blocking { entry.drawOffered = false }.flatMap(_ => Ok(OkDto().asJson))
|
||||||
|
case "accept" =>
|
||||||
|
if !entry.drawOffered then badRequest("NO_DRAW_OFFER", "No draw offer to accept")
|
||||||
|
else IO.blocking {
|
||||||
|
entry.gameOver = Some(GameOverState.Draw(entry.engine.context))
|
||||||
|
}.flatMap(_ => Ok(OkDto().asJson))
|
||||||
|
case "claim" =>
|
||||||
|
IO.blocking {
|
||||||
|
entry.lastEvent = None
|
||||||
|
entry.engine.processUserInput("draw")
|
||||||
|
entry.lastEvent
|
||||||
|
}.flatMap {
|
||||||
|
case Some(InvalidMoveEvent(_, reason)) => badRequest("DRAW_UNAVAILABLE", reason)
|
||||||
|
case _ => Ok(OkDto().asJson)
|
||||||
|
}
|
||||||
|
case _ => badRequest("INVALID_ACTION", s"Unknown draw action: $action")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Import / Export ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def importFen(req: Request[IO]): IO[Response[IO]] =
|
||||||
|
req.as[ImportFenRequest].flatMap { body =>
|
||||||
|
FenParser.parseFen(body.fen) match
|
||||||
|
case Left(err) => badRequest("INVALID_FEN", err)
|
||||||
|
case Right(ctx) =>
|
||||||
|
val white = resolvePlayer(body.white, "white")
|
||||||
|
val black = resolvePlayer(body.black, "black")
|
||||||
|
val entry = GameEntry(new GameEngine(ctx), white, black)
|
||||||
|
registry.add(entry).flatMap { id => Created(toGameFullDto(id, entry).asJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private def importPgn(req: Request[IO]): IO[Response[IO]] =
|
||||||
|
req.as[ImportPgnRequest].flatMap { body =>
|
||||||
|
val engine = new GameEngine()
|
||||||
|
engine.loadGame(PgnParser, body.pgn) match
|
||||||
|
case Left(err) => badRequest("INVALID_PGN", err)
|
||||||
|
case Right(_) =>
|
||||||
|
val entry = GameEntry(engine, defaultPlayer("white"), defaultPlayer("black"))
|
||||||
|
registry.add(entry).flatMap { id => Created(toGameFullDto(id, entry).asJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private def exportFen(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) => Ok(FenExporter.gameContextToFen(entry.activeContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def exportPgn(id: String): IO[Response[IO]] =
|
||||||
|
registry.get(id).flatMap {
|
||||||
|
case None => notFound(id)
|
||||||
|
case Some(entry) => Ok(PgnExporter.exportGameContext(entry.activeContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapping helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
||||||
|
val ctx = entry.activeContext
|
||||||
|
val (status, winner) = computeStatus(entry)
|
||||||
|
GameStateDto(
|
||||||
|
fen = FenExporter.gameContextToFen(ctx),
|
||||||
|
pgn = PgnExporter.exportGameContext(ctx),
|
||||||
|
turn = ctx.turn.toString.toLowerCase,
|
||||||
|
status = status,
|
||||||
|
winner = winner,
|
||||||
|
moves = ctx.moves.map(moveToUci),
|
||||||
|
undoAvailable = entry.engine.canUndo,
|
||||||
|
redoAvailable = entry.engine.canRedo
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toGameFullDto(id: String, entry: GameEntry): GameFullDto =
|
||||||
|
GameFullDto(
|
||||||
|
gameId = id,
|
||||||
|
white = PlayerInfoDto(entry.white.id.value, entry.white.displayName),
|
||||||
|
black = PlayerInfoDto(entry.black.id.value, entry.black.displayName),
|
||||||
|
state = toGameStateDto(entry)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toLegalMoveDto(move: Move): LegalMoveDto =
|
||||||
|
val (moveType, promo) = 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 s = pp match
|
||||||
|
case PromotionPiece.Queen => "queen"
|
||||||
|
case PromotionPiece.Rook => "rook"
|
||||||
|
case PromotionPiece.Bishop => "bishop"
|
||||||
|
case PromotionPiece.Knight => "knight"
|
||||||
|
("promotion", Some(s))
|
||||||
|
LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveType, promo)
|
||||||
|
|
||||||
|
private def moveToUci(move: Move): String =
|
||||||
|
val base = s"${move.from}${move.to}"
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(pp) =>
|
||||||
|
val char = pp match
|
||||||
|
case PromotionPiece.Queen => "q"
|
||||||
|
case PromotionPiece.Rook => "r"
|
||||||
|
case PromotionPiece.Bishop => "b"
|
||||||
|
case PromotionPiece.Knight => "n"
|
||||||
|
base + char
|
||||||
|
case _ => base
|
||||||
|
|
||||||
|
private def applyUci(entry: GameEntry, uci: String): Unit =
|
||||||
|
if uci.length == 5 then
|
||||||
|
entry.engine.processUserInput(uci.take(4))
|
||||||
|
if entry.engine.isPendingPromotion then
|
||||||
|
val pp = uci.last match
|
||||||
|
case 'q' => PromotionPiece.Queen
|
||||||
|
case 'r' => PromotionPiece.Rook
|
||||||
|
case 'b' => PromotionPiece.Bishop
|
||||||
|
case _ => PromotionPiece.Knight
|
||||||
|
entry.engine.completePromotion(pp)
|
||||||
|
else
|
||||||
|
entry.engine.processUserInput(uci)
|
||||||
|
|
||||||
|
private def computeStatus(entry: GameEntry): (String, Option[String]) =
|
||||||
|
entry.gameOver match
|
||||||
|
case Some(GameOverState.Checkmate(w, _)) => ("checkmate", Some(w.toString.toLowerCase))
|
||||||
|
case Some(GameOverState.Stalemate(_)) => ("stalemate", None)
|
||||||
|
case Some(GameOverState.Draw(_)) => ("draw", None)
|
||||||
|
case Some(GameOverState.Resigned(w, _)) => ("resign", Some(w.toString.toLowerCase))
|
||||||
|
case None => statusFromContext(entry)
|
||||||
|
|
||||||
|
private def statusFromContext(entry: GameEntry): (String, Option[String]) =
|
||||||
|
if entry.engine.isPendingPromotion then ("promotionPending", None)
|
||||||
|
else if entry.drawOffered then ("drawOffered", None)
|
||||||
|
else
|
||||||
|
val ctx = entry.engine.context
|
||||||
|
val rs = entry.engine.ruleSet
|
||||||
|
if rs.isInsufficientMaterial(ctx) then ("insufficientMaterial", None)
|
||||||
|
else if rs.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None)
|
||||||
|
else if rs.isCheck(ctx) then ("check", None)
|
||||||
|
else ("started", None)
|
||||||
|
|
||||||
|
// ─── Utility ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private def resolvePlayer(dto: Option[PlayerInfoDto], color: String): PlayerInfo =
|
||||||
|
dto.map(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||||
|
.getOrElse(defaultPlayer(color))
|
||||||
|
|
||||||
|
private def defaultPlayer(color: String): PlayerInfo =
|
||||||
|
PlayerInfo(PlayerId(color), color.capitalize)
|
||||||
|
|
||||||
|
private def notFound(id: String): IO[Response[IO]] =
|
||||||
|
NotFound(ApiErrorDto("GAME_NOT_FOUND", s"Game not found: $id").asJson)
|
||||||
|
|
||||||
|
private def badRequest(code: String, msg: String): IO[Response[IO]] =
|
||||||
|
BadRequest(ApiErrorDto(code, msg).asJson)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.player.PlayerInfo
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.chess.observer.*
|
||||||
|
|
||||||
|
enum GameOverState:
|
||||||
|
case Checkmate(winner: Color, context: GameContext)
|
||||||
|
case Stalemate(context: GameContext)
|
||||||
|
case Draw(context: GameContext)
|
||||||
|
case Resigned(winner: Color, context: GameContext)
|
||||||
|
|
||||||
|
class GameEntry(
|
||||||
|
val engine: GameEngine,
|
||||||
|
val white: PlayerInfo,
|
||||||
|
val black: PlayerInfo
|
||||||
|
):
|
||||||
|
var lastEvent: Option[GameEvent] = None
|
||||||
|
var gameOver: Option[GameOverState] = None
|
||||||
|
var drawOffered: Boolean = false
|
||||||
|
|
||||||
|
engine.subscribe(new Observer:
|
||||||
|
def onGameEvent(event: GameEvent): Unit =
|
||||||
|
lastEvent = Some(event)
|
||||||
|
event match
|
||||||
|
case CheckmateEvent(ctx, winner) => gameOver = Some(GameOverState.Checkmate(winner, ctx))
|
||||||
|
case StalemateEvent(ctx) => gameOver = Some(GameOverState.Stalemate(ctx))
|
||||||
|
case DrawClaimedEvent(ctx) => gameOver = Some(GameOverState.Draw(ctx))
|
||||||
|
case _ => ()
|
||||||
|
)
|
||||||
|
|
||||||
|
def isOver: Boolean = gameOver.isDefined
|
||||||
|
|
||||||
|
def activeContext: GameContext = gameOver match
|
||||||
|
case Some(GameOverState.Checkmate(_, ctx)) => ctx
|
||||||
|
case Some(GameOverState.Stalemate(ctx)) => ctx
|
||||||
|
case Some(GameOverState.Draw(ctx)) => ctx
|
||||||
|
case Some(GameOverState.Resigned(_, ctx)) => ctx
|
||||||
|
case None => engine.context
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import cats.effect.{IO, Ref}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
|
||||||
|
class GameRegistry(ref: Ref[IO, Map[String, GameEntry]]):
|
||||||
|
|
||||||
|
def create(white: PlayerInfo, black: PlayerInfo): IO[String] =
|
||||||
|
add(GameEntry(new GameEngine(), white, black))
|
||||||
|
|
||||||
|
def add(entry: GameEntry): IO[String] =
|
||||||
|
val id = scala.util.Random.alphanumeric.take(8).mkString
|
||||||
|
ref.update(_ + (id -> entry)).as(id)
|
||||||
|
|
||||||
|
def get(id: String): IO[Option[GameEntry]] =
|
||||||
|
ref.get.map(_.get(id))
|
||||||
|
|
||||||
|
object GameRegistry:
|
||||||
|
def make: IO[GameRegistry] =
|
||||||
|
Ref.of[IO, Map[String, GameEntry]](Map.empty).map(new GameRegistry(_))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import io.circe.{Decoder, Encoder}
|
||||||
|
import io.circe.generic.semiauto.*
|
||||||
|
|
||||||
|
object JsonCodecs:
|
||||||
|
given Decoder[PlayerInfoDto] = deriveDecoder
|
||||||
|
given Encoder[PlayerInfoDto] = deriveEncoder
|
||||||
|
given Decoder[CreateGameRequest] = deriveDecoder
|
||||||
|
given Decoder[ImportFenRequest] = deriveDecoder
|
||||||
|
given Decoder[ImportPgnRequest] = deriveDecoder
|
||||||
|
given Encoder[GameStateDto] = deriveEncoder
|
||||||
|
given Encoder[GameFullDto] = deriveEncoder
|
||||||
|
given Encoder[GameFullEvent] = deriveEncoder
|
||||||
|
given Encoder[LegalMoveDto] = deriveEncoder
|
||||||
|
given Encoder[LegalMovesDto] = deriveEncoder
|
||||||
|
given Encoder[OkDto] = deriveEncoder
|
||||||
|
given Encoder[ApiErrorDto] = deriveEncoder
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
case class PlayerInfoDto(id: String, displayName: String)
|
||||||
|
|
||||||
|
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 GameStateDto(
|
||||||
|
fen: String,
|
||||||
|
pgn: String,
|
||||||
|
turn: String,
|
||||||
|
status: String,
|
||||||
|
winner: Option[String],
|
||||||
|
moves: List[String],
|
||||||
|
undoAvailable: Boolean,
|
||||||
|
redoAvailable: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
case class GameFullDto(
|
||||||
|
gameId: String,
|
||||||
|
white: PlayerInfoDto,
|
||||||
|
black: PlayerInfoDto,
|
||||||
|
state: GameStateDto
|
||||||
|
)
|
||||||
|
|
||||||
|
case class GameFullEvent(`type`: String, game: GameFullDto)
|
||||||
|
|
||||||
|
case class LegalMoveDto(
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
uci: String,
|
||||||
|
moveType: String,
|
||||||
|
promotion: Option[String]
|
||||||
|
)
|
||||||
|
|
||||||
|
case class LegalMovesDto(moves: List[LegalMoveDto])
|
||||||
|
|
||||||
|
case class OkDto(ok: Boolean = true)
|
||||||
|
|
||||||
|
case class ApiErrorDto(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
field: Option[String] = None
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import cats.effect.{ExitCode, IO, IOApp}
|
||||||
|
import com.comcast.ip4s.{host, port}
|
||||||
|
import org.http4s.ember.server.EmberServerBuilder
|
||||||
|
|
||||||
|
object ServerApp extends IOApp:
|
||||||
|
def run(args: List[String]): IO[ExitCode] =
|
||||||
|
GameRegistry.make.flatMap { registry =>
|
||||||
|
EmberServerBuilder
|
||||||
|
.default[IO]
|
||||||
|
.withHost(host"0.0.0.0")
|
||||||
|
.withPort(port"8080")
|
||||||
|
.withHttpApp(BoardRoutes(registry).routes.orNotFound)
|
||||||
|
.build
|
||||||
|
.useForever
|
||||||
|
}.as(ExitCode.Success)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.nowchess.server
|
||||||
|
|
||||||
|
import cats.effect.IO
|
||||||
|
import cats.effect.unsafe.implicits.global
|
||||||
|
import com.comcast.ip4s.{host, port}
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import org.http4s.ember.server.EmberServerBuilder
|
||||||
|
|
||||||
|
object ServerLauncher:
|
||||||
|
def start(engine: GameEngine): String =
|
||||||
|
val white = PlayerInfo(PlayerId("white"), "Player 1")
|
||||||
|
val black = PlayerInfo(PlayerId("black"), "Player 2")
|
||||||
|
(for
|
||||||
|
registry <- GameRegistry.make
|
||||||
|
_ <- EmberServerBuilder
|
||||||
|
.default[IO]
|
||||||
|
.withHost(host"0.0.0.0")
|
||||||
|
.withPort(port"8080")
|
||||||
|
.withHttpApp(BoardRoutes(registry).routes.orNotFound)
|
||||||
|
.build
|
||||||
|
.useForever
|
||||||
|
.start
|
||||||
|
gameId <- registry.add(GameEntry(engine, white, black))
|
||||||
|
yield gameId).unsafeRunSync()
|
||||||
@@ -64,6 +64,7 @@ dependencies {
|
|||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:io"))
|
implementation(project(":modules:io"))
|
||||||
|
implementation(project(":modules:server"))
|
||||||
|
|
||||||
// ScalaFX dependencies
|
// ScalaFX dependencies
|
||||||
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
package de.nowchess.ui
|
package de.nowchess.ui
|
||||||
|
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.server.ServerLauncher
|
||||||
import de.nowchess.ui.terminal.TerminalUI
|
import de.nowchess.ui.terminal.TerminalUI
|
||||||
import de.nowchess.ui.gui.ChessGUILauncher
|
import de.nowchess.ui.gui.ChessGUILauncher
|
||||||
|
|
||||||
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
/** Application entry point - starts both GUI and Terminal UI for the chess game.
|
||||||
* Both views subscribe to the same GameEngine via Observer pattern.
|
* Both views subscribe to the same GameEngine via Observer pattern.
|
||||||
|
* The REST server shares the same engine instance, so API moves are reflected in the UI.
|
||||||
*/
|
*/
|
||||||
object Main:
|
object Main:
|
||||||
def main(args: Array[String]): Unit =
|
def main(args: Array[String]): Unit =
|
||||||
// Create the core game engine (single source of truth)
|
|
||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
|
val gameId = ServerLauncher.start(engine)
|
||||||
|
println(s"REST API ready — game ID: $gameId → http://localhost:8080/api/board/game/$gameId")
|
||||||
|
|
||||||
// Launch ScalaFX GUI in separate thread
|
|
||||||
ChessGUILauncher.launch(engine)
|
ChessGUILauncher.launch(engine)
|
||||||
|
|
||||||
// Create and start the terminal UI (blocks on main thread)
|
|
||||||
val tui = new TerminalUI(engine)
|
val tui = new TerminalUI(engine)
|
||||||
tui.start()
|
tui.start()
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ include(
|
|||||||
"modules:io",
|
"modules:io",
|
||||||
"modules:rule",
|
"modules:rule",
|
||||||
"modules:ui",
|
"modules:ui",
|
||||||
|
"modules:server",
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user