feat: NCS-37 game lifecycle endpoints (createGame, getGame)

Add domain model (GameResult, GameSession, GameId, GameStore, GameMapper),
DTOs, JacksonConfig, and GameResource REST endpoints with QuarkusTest coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-04-13 15:44:05 +02:00
parent d59d692381
commit 21eee717f1
9 changed files with 305 additions and 0 deletions
@@ -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,75 @@
package de.nowchess.backcore.game
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.rules.sets.DefaultRules
object GameMapper:
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 session.drawOfferedBy.isDefined then ("drawOffered", None)
else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None)
else if DefaultRules.isCheck(ctx) then ("check", 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 =
moves.zipWithIndex.map { (move, i) =>
val prefix = if i % 2 == 0 then s"${i / 2 + 1}. " else ""
prefix + moveToUci(move)
}.mkString(" ")
@@ -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,33 @@
package de.nowchess.backcore.game
import de.nowchess.api.game.GameContext
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.backcore.dto.{CreateGameRequest, PlayerInfoDto}
import de.nowchess.chess.command.CommandInvoker
import jakarta.enterprise.context.ApplicationScoped
import scala.collection.mutable
@ApplicationScoped
class GameStore:
private val games: mutable.Map[String, GameSession] = mutable.Map.empty
def create(req: CreateGameRequest): GameSession = synchronized:
val id = GameId.generate()
val white = toPlayerInfo(req.white, "white", "White")
val black = toPlayerInfo(req.black, "black", "Black")
val session = GameSession(
gameId = id,
white = white,
black = black,
context = GameContext.initial,
invoker = new CommandInvoker(),
)
games(id) = session
session
def get(id: String): Option[GameSession] = synchronized:
games.get(id)
private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo =
dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName))
@@ -0,0 +1,29 @@
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))
@Consumes(Array(MediaType.APPLICATION_JSON))
@ApplicationScoped
class GameResource @Inject() (store: GameStore):
@POST
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()
@@ -0,0 +1,62 @@
package de.nowchess.backcore.resource
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import org.hamcrest.Matchers.{equalTo, matchesPattern, notNullValue}
import org.junit.jupiter.api.Test
@QuarkusTest
class GameResourceTest:
@Test
def createGameReturns201WithGameId(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body("{}")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
.body("state.fen", notNullValue())
.body("state.turn", equalTo("white"))
.body("state.status", equalTo("started"))
@Test
def createGameWithPlayersReturns201(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body("""{"white":{"id":"p1","displayName":"Alice"},"black":{"id":"p2","displayName":"Bob"}}""")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.body("white.id", equalTo("p1"))
.body("black.displayName", equalTo("Bob"))
@Test
def getGameReturns200ForExistingGame(): Unit =
val gameId = RestAssured.`given`()
.contentType("application/json")
.body("{}")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.extract()
.path[String]("gameId")
RestAssured.`given`()
.when()
.get(s"/api/board/game/$gameId")
.`then`()
.statusCode(200)
.body("gameId", equalTo(gameId))
@Test
def getGameReturns404ForUnknownId(): Unit =
RestAssured.`given`()
.when()
.get("/api/board/game/XXXXXXXX")
.`then`()
.statusCode(404)