diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala index 2c7a45a..96aac04 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -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( diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala index 5202800..e42a2d3 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala @@ -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) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala index 54c407f..61bcaf3 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala @@ -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() diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala new file mode 100644 index 0000000..74f7587 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/ResignDrawTest.scala @@ -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)