feat: NCS-37 resign, draw actions, and export/stream endpoints

This commit is contained in:
LQ63
2026-04-13 16:02:10 +02:00
parent 6fc3b3c3df
commit 33dd63a9b6
4 changed files with 227 additions and 2 deletions
@@ -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)