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