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:
@@ -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)
|
||||||
Reference in New Issue
Block a user