feat: NCS-37 resign, draw actions, and export/stream endpoints
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package de.nowchess.backcore.game
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.backcore.dto.*
|
||||
@@ -8,6 +10,11 @@ import de.nowchess.io.pgn.PgnExporter
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
object GameMapper:
|
||||
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
|
||||
|
||||
|
||||
def toGameFullJson(session: GameSession): String =
|
||||
mapper.writeValueAsString(toGameFull(session))
|
||||
|
||||
def toGameFull(session: GameSession): GameFullResponse =
|
||||
GameFullResponse(
|
||||
|
||||
@@ -51,7 +51,7 @@ class GameStore:
|
||||
)
|
||||
session.invoker.execute(cmd)
|
||||
val result = detectGameOver(nextCtx)
|
||||
val updated = session.copy(context = nextCtx, result = result, drawOfferedBy = None)
|
||||
val updated = session.copy(context = nextCtx, result = result)
|
||||
games(id) = updated
|
||||
Right(updated)
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ 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
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(req: CreateGameRequest): Response =
|
||||
val session = store.create(Option(req).getOrElse(CreateGameRequest()))
|
||||
Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
||||
@@ -27,3 +27,69 @@ class GameResource @Inject() (store: GameStore):
|
||||
Response.status(404)
|
||||
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||
.build()
|
||||
|
||||
@GET
|
||||
@Path("/{gameId}/stream")
|
||||
@Produces(Array("application/x-ndjson"))
|
||||
def streamGame(@PathParam("gameId") gameId: String): Response =
|
||||
store.get(gameId) match
|
||||
case None =>
|
||||
Response.status(404)
|
||||
.`type`(MediaType.APPLICATION_JSON)
|
||||
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||
.build()
|
||||
case Some(session) =>
|
||||
// Simplified: return a single-line NDJSON snapshot of the current game state
|
||||
val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(session)}}"""
|
||||
Response.ok(event + "\n").build()
|
||||
|
||||
@POST
|
||||
@Path("/{gameId}/resign")
|
||||
def resignGame(@PathParam("gameId") gameId: String): Response =
|
||||
store.resign(gameId) match
|
||||
case Right(_) => Response.ok(OkResponse()).build()
|
||||
case Left(err) if err.contains("not found") =>
|
||||
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||
case Left(err) =>
|
||||
Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build()
|
||||
|
||||
@POST
|
||||
@Path("/{gameId}/draw/{action}")
|
||||
def drawAction(
|
||||
@PathParam("gameId") gameId: String,
|
||||
@PathParam("action") action: String,
|
||||
): Response =
|
||||
store.drawAction(gameId, action) match
|
||||
case Right(_) => Response.ok(OkResponse()).build()
|
||||
case Left(err) if err.contains("not found") =>
|
||||
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build()
|
||||
case Left(err) =>
|
||||
Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{gameId}/export/fen")
|
||||
@Produces(Array(MediaType.TEXT_PLAIN))
|
||||
def exportFen(@PathParam("gameId") gameId: String): Response =
|
||||
store.get(gameId) match
|
||||
case None =>
|
||||
Response.status(404)
|
||||
.`type`(MediaType.APPLICATION_JSON)
|
||||
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||
.build()
|
||||
case Some(session) =>
|
||||
import de.nowchess.io.fen.FenExporter
|
||||
Response.ok(FenExporter.exportGameContext(session.context)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{gameId}/export/pgn")
|
||||
@Produces(Array("application/x-chess-pgn"))
|
||||
def exportPgn(@PathParam("gameId") gameId: String): Response =
|
||||
store.get(gameId) match
|
||||
case None =>
|
||||
Response.status(404)
|
||||
.`type`(MediaType.APPLICATION_JSON)
|
||||
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||
.build()
|
||||
case Some(session) =>
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
Response.ok(PgnExporter.exportGameContext(session.context)).build()
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.nowchess.backcore.resource
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import org.hamcrest.Matchers.{equalTo, notNullValue}
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@QuarkusTest
|
||||
class ResignDrawTest:
|
||||
|
||||
private def createGame(): String =
|
||||
RestAssured.`given`()
|
||||
.contentType("application/json")
|
||||
.body("{}")
|
||||
.when()
|
||||
.post("/api/board/game")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.path[String]("gameId")
|
||||
|
||||
// ─── Resign ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def resignReturns200(): Unit =
|
||||
val gameId = createGame()
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/resign")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("ok", equalTo(true))
|
||||
|
||||
@Test
|
||||
def afterResignGameShowsResignStatusAndWinner(): Unit =
|
||||
val gameId = createGame()
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/resign")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.get(s"/api/board/game/$gameId")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("state.status", equalTo("resign"))
|
||||
.body("state.winner", notNullValue())
|
||||
|
||||
@Test
|
||||
def resignOnUnknownGameReturns404(): Unit =
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post("/api/board/game/XXXXXXXX/resign")
|
||||
.`then`()
|
||||
.statusCode(404)
|
||||
|
||||
// ─── Draw ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def offerDrawSetsDrawOfferedStatus(): Unit =
|
||||
val gameId = createGame()
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/offer")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("ok", equalTo(true))
|
||||
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.get(s"/api/board/game/$gameId")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("state.status", equalTo("drawOffered"))
|
||||
|
||||
@Test
|
||||
def acceptDrawAfterOfferSetsDrawStatus(): Unit =
|
||||
val gameId = createGame()
|
||||
// White offers
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/offer")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
|
||||
// Black moves so it's black's turn... actually the API doesn't enforce turn-based draw accept.
|
||||
// White offered, so black (opponent) accepts — but since there's no auth, we just call accept.
|
||||
// The GameStore checks drawOfferedBy != turn to allow accept.
|
||||
// White offered on white's turn, so black needs to accept — but current turn is still white.
|
||||
// We need to make a move first to switch turns.
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/move/e2e4")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
|
||||
// Now it's black's turn and white offered the draw — black accepts
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/accept")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("ok", equalTo(true))
|
||||
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.get(s"/api/board/game/$gameId")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("state.status", equalTo("draw"))
|
||||
|
||||
@Test
|
||||
def declineDrawClearsOffer(): Unit =
|
||||
val gameId = createGame()
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/offer")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/decline")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("ok", equalTo(true))
|
||||
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.get(s"/api/board/game/$gameId")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("state.status", equalTo("started"))
|
||||
|
||||
@Test
|
||||
def acceptWithoutOfferReturns400(): Unit =
|
||||
val gameId = createGame()
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post(s"/api/board/game/$gameId/draw/accept")
|
||||
.`then`()
|
||||
.statusCode(400)
|
||||
|
||||
@Test
|
||||
def drawOnUnknownGameReturns404(): Unit =
|
||||
RestAssured.`given`()
|
||||
.when()
|
||||
.post("/api/board/game/XXXXXXXX/draw/offer")
|
||||
.`then`()
|
||||
.statusCode(404)
|
||||
Reference in New Issue
Block a user