diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala new file mode 100644 index 0000000..fed153a --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/config/JacksonConfig.scala @@ -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) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala new file mode 100644 index 0000000..28d17cb --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/dto/Dtos.scala @@ -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]) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala new file mode 100644 index 0000000..4f60437 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameId.scala @@ -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 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 new file mode 100644 index 0000000..814770a --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -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(" ") diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala new file mode 100644 index 0000000..3b14da6 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameResult.scala @@ -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 diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala new file mode 100644 index 0000000..32025fd --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala @@ -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, +) 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 new file mode 100644 index 0000000..a7b41bd --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala @@ -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)) 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 new file mode 100644 index 0000000..54c407f --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala @@ -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() diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala new file mode 100644 index 0000000..61387fa --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/GameResourceTest.scala @@ -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)