feat(server): Http4s server
Build & Test (NowChessSystems) TeamCity build failed

Added http4s server according to the API specification. A game is playable via API
This commit is contained in:
LQ63
2026-04-12 18:15:04 +02:00
parent 9ad11fb97a
commit 25c113e4d5
11 changed files with 519 additions and 2 deletions
+1
View File
@@ -14,6 +14,7 @@
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/server" />
<option value="$PROJECT_DIR$/modules/ui" />
</set>
</option>
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<profile name="Gradle 2" modules="NowChessSystems.modules.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="uncheckedWarnings" value="true" />
<parameters>
+3 -1
View File
@@ -34,7 +34,9 @@ val versions = mapOf(
"JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4",
"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
+79
View File
@@ -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)
+1
View File
@@ -5,4 +5,5 @@ include(
"modules:io",
"modules:rule",
"modules:ui",
"modules:server",
)