feat: NCS-37 resign, draw actions, and export/stream endpoints
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
package de.nowchess.backcore.game
|
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.board.Color
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.backcore.dto.*
|
import de.nowchess.backcore.dto.*
|
||||||
@@ -8,6 +10,11 @@ import de.nowchess.io.pgn.PgnExporter
|
|||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
object GameMapper:
|
object GameMapper:
|
||||||
|
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
|
||||||
|
|
||||||
|
|
||||||
|
def toGameFullJson(session: GameSession): String =
|
||||||
|
mapper.writeValueAsString(toGameFull(session))
|
||||||
|
|
||||||
def toGameFull(session: GameSession): GameFullResponse =
|
def toGameFull(session: GameSession): GameFullResponse =
|
||||||
GameFullResponse(
|
GameFullResponse(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class GameStore:
|
|||||||
)
|
)
|
||||||
session.invoker.execute(cmd)
|
session.invoker.execute(cmd)
|
||||||
val result = detectGameOver(nextCtx)
|
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
|
games(id) = updated
|
||||||
Right(updated)
|
Right(updated)
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import jakarta.ws.rs.core.{MediaType, Response}
|
|||||||
|
|
||||||
@Path("/api/board/game")
|
@Path("/api/board/game")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class GameResource @Inject() (store: GameStore):
|
class GameResource @Inject() (store: GameStore):
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
def createGame(req: CreateGameRequest): Response =
|
def createGame(req: CreateGameRequest): Response =
|
||||||
val session = store.create(Option(req).getOrElse(CreateGameRequest()))
|
val session = store.create(Option(req).getOrElse(CreateGameRequest()))
|
||||||
Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
Response.status(201).entity(GameMapper.toGameFull(session)).build()
|
||||||
@@ -27,3 +27,69 @@ class GameResource @Inject() (store: GameStore):
|
|||||||
Response.status(404)
|
Response.status(404)
|
||||||
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
|
||||||
.build()
|
.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