From 6fc3b3c3df9c2f581e9f2fcd75742d208582a7f1 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Mon, 13 Apr 2026 15:58:01 +0200 Subject: [PATCH] feat: NCS-37 move-making, legal moves, undo/redo endpoints --- .../de/nowchess/backcore/game/GameStore.scala | 248 +++++++++++++++++- .../backcore/resource/MoveResource.scala | 87 ++++++ .../backcore/resource/MoveResourceTest.scala | 77 ++++++ .../backcore/resource/UndoRedoTest.scala | 80 ++++++ 4 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala 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 a7b41bd..5202800 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 @@ -1,9 +1,14 @@ package de.nowchess.backcore.game +import de.nowchess.api.board.{Color, Square} import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.player.{PlayerId, PlayerInfo} -import de.nowchess.backcore.dto.{CreateGameRequest, PlayerInfoDto} -import de.nowchess.chess.command.CommandInvoker +import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto} +import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} +import de.nowchess.io.fen.FenParser +import de.nowchess.io.pgn.PgnParser +import de.nowchess.rules.sets.DefaultRules import jakarta.enterprise.context.ApplicationScoped import scala.collection.mutable @@ -12,22 +17,241 @@ import scala.collection.mutable class GameStore: private val games: mutable.Map[String, GameSession] = mutable.Map.empty + // ─── Create / Get ──────────────────────────────────────────────── + 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(), - ) + val id = generateId() + val session = newSession(id, req.white, req.black, GameContext.initial) games(id) = session session def get(id: String): Option[GameSession] = synchronized: games.get(id) + // ─── Move-making ───────────────────────────────────────────────── + + def applyMove(id: String, uci: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + parseUci(uci) match + case None => Left(s"Invalid UCI notation: $uci") + case Some((from, to, promotion)) => + val legalCandidates = DefaultRules.legalMoves(session.context)(from) + findMatchingMove(legalCandidates, to, promotion) match + case None => Left(s"$uci is not a legal move") + case Some(move) => + val nextCtx = DefaultRules.applyMove(session.context)(move) + val prevCtx = session.context + val cmd = MoveCommand( + from = move.from, + to = move.to, + moveResult = Some(MoveResult.Successful(nextCtx, prevCtx.board.pieceAt(move.to))), + previousContext = Some(prevCtx), + ) + session.invoker.execute(cmd) + val result = detectGameOver(nextCtx) + val updated = session.copy(context = nextCtx, result = result, drawOfferedBy = None) + games(id) = updated + Right(updated) + + def legalMoves(id: String, square: Option[Square]): Either[String, List[Move]] = synchronized: + withSession(id): session => + val moves = square match + case Some(sq) => DefaultRules.legalMoves(session.context)(sq) + case None => DefaultRules.allLegalMoves(session.context) + Right(moves) + + // ─── Undo / Redo ───────────────────────────────────────────────── + + def undo(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if !session.invoker.canUndo then Left("No moves to undo") + else + val idx = session.invoker.getCurrentIndex + session.invoker.history(idx) match + case cmd: MoveCommand => + cmd.previousContext match + case None => Left("Cannot undo: no previous context stored") + case Some(prevCtx) => + session.invoker.undo() + val updated = session.copy(context = prevCtx, result = None, drawOfferedBy = None) + games(id) = updated + Right(updated) + case _ => Left("Cannot undo this command type") + + def redo(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if !session.invoker.canRedo then Left("No moves to redo") + else + val idx = session.invoker.getCurrentIndex + 1 + session.invoker.history(idx) match + case cmd: MoveCommand => + cmd.moveResult match + case Some(MoveResult.Successful(nextCtx, _)) => + session.invoker.redo() + val result = detectGameOver(nextCtx) + val updated = session.copy(context = nextCtx, result = result) + games(id) = updated + Right(updated) + case _ => Left("Cannot redo: move result not available") + case _ => Left("Cannot redo this command type") + + // ─── Resign ────────────────────────────────────────────────────── + + def resign(id: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + val winner = session.context.turn.opposite + val updated = session.copy(result = Some(GameResult.Resign(winner))) + games(id) = updated + Right(updated) + + // ─── Draw actions ──────────────────────────────────────────────── + + def drawAction(id: String, action: String): Either[String, GameSession] = synchronized: + withSession(id): session => + if session.result.isDefined then Left("Game is already over") + else + action match + case "offer" => + val updated = session.copy(drawOfferedBy = Some(session.context.turn)) + games(id) = updated + Right(updated) + case "accept" => + session.drawOfferedBy match + case None => Left("No draw offer to accept") + case Some(offerer) if offerer == session.context.turn => + Left("Cannot accept your own draw offer") + case Some(_) => + val updated = session.copy(result = Some(GameResult.AgreedDraw), drawOfferedBy = None) + games(id) = updated + Right(updated) + case "decline" => + session.drawOfferedBy match + case None => Left("No draw offer to decline") + case Some(_) => + val updated = session.copy(drawOfferedBy = None) + games(id) = updated + Right(updated) + case "claim" => + if DefaultRules.isFiftyMoveRule(session.context) then + val updated = session.copy(result = Some(GameResult.FiftyMoveDraw)) + games(id) = updated + Right(updated) + else Left("Fifty-move rule has not been triggered") + case other => Left(s"Unknown draw action: $other") + + // ─── Import ────────────────────────────────────────────────────── + + def importFen(req: ImportFenRequest): Either[String, GameSession] = synchronized: + FenParser.parseFen(req.fen) match + case Left(err) => Left(err) + case Right(ctx) => + val id = generateId() + val session = newSession(id, req.white, req.black, ctx) + games(id) = session + Right(session) + + def importPgn(pgn: String, white: Option[PlayerInfoDto], black: Option[PlayerInfoDto]): Either[String, GameSession] = + synchronized: + PgnParser.validatePgn(pgn) match + case Left(err) => Left(err) + case Right(game) => + val id = generateId() + val session = newSession(id, white, black, GameContext.initial) + replayIntoSession(session, game.moves, GameContext.initial) match + case Left(err) => Left(err) + case Right(s) => + games(id) = s + Right(s) + + // ─── Private helpers ───────────────────────────────────────────── + + private def withSession[A](id: String)(f: GameSession => Either[String, A]): Either[String, A] = + games.get(id) match + case None => Left(s"Game $id not found") + case Some(session) => f(session) + + private def generateId(): String = + var id = GameId.generate() + while games.contains(id) do id = GameId.generate() + id + + private def newSession( + id: String, + white: Option[PlayerInfoDto], + black: Option[PlayerInfoDto], + ctx: GameContext, + ): GameSession = + GameSession( + gameId = id, + white = toPlayerInfo(white, "white", "White"), + black = toPlayerInfo(black, "black", "Black"), + context = ctx, + invoker = new CommandInvoker(), + ) + private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo = dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName)) + + private def parseUci(uci: String): Option[(Square, Square, Option[PromotionPiece])] = + if uci.length < 4 || uci.length > 5 then None + else + for + from <- Square.fromAlgebraic(uci.substring(0, 2)) + to <- Square.fromAlgebraic(uci.substring(2, 4)) + yield + val promotion = if uci.length == 5 then parsePromotionChar(uci.charAt(4)) else None + (from, to, promotion) + + private def parsePromotionChar(c: Char): Option[PromotionPiece] = + c match + case 'q' => Some(PromotionPiece.Queen) + case 'r' => Some(PromotionPiece.Rook) + case 'b' => Some(PromotionPiece.Bishop) + case 'n' => Some(PromotionPiece.Knight) + case _ => None + + private def findMatchingMove( + candidates: List[Move], + to: Square, + promotion: Option[PromotionPiece], + ): Option[Move] = + candidates.filter(_.to == to) match + case Nil => None + case moves => + promotion match + case Some(pp) => moves.find(_.moveType == MoveType.Promotion(pp)) + case None => moves.find(m => !m.moveType.isInstanceOf[MoveType.Promotion]) + .orElse(moves.headOption) + + private def detectGameOver(ctx: GameContext): Option[GameResult] = + if DefaultRules.isCheckmate(ctx) then Some(GameResult.Checkmate(ctx.turn.opposite)) + else if DefaultRules.isStalemate(ctx) then Some(GameResult.Stalemate) + else if DefaultRules.isInsufficientMaterial(ctx) then Some(GameResult.InsufficientMaterial) + else None + + private def replayIntoSession( + session: GameSession, + moves: List[Move], + startCtx: GameContext, + ): Either[String, GameSession] = + moves.foldLeft[Either[String, GameSession]](Right(session)): + case (Left(err), _) => Left(err) + case (Right(s), move) => + val legal = DefaultRules.legalMoves(s.context)(move.from) + legal.find(m => m.from == move.from && m.to == move.to && m.moveType == move.moveType) + .orElse(legal.find(m => m.from == move.from && m.to == move.to)) match + case None => Left(s"Illegal move in PGN: $move") + case Some(legalMove) => + val nextCtx = DefaultRules.applyMove(s.context)(legalMove) + val cmd = MoveCommand( + from = legalMove.from, + to = legalMove.to, + moveResult = Some(MoveResult.Successful(nextCtx, s.context.board.pieceAt(legalMove.to))), + previousContext = Some(s.context), + ) + s.invoker.execute(cmd) + Right(s.copy(context = nextCtx)) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala new file mode 100644 index 0000000..395af14 --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala @@ -0,0 +1,87 @@ +package de.nowchess.backcore.resource + +import de.nowchess.api.board.Square +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +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)) +@ApplicationScoped +class MoveResource @Inject() (store: GameStore): + + @POST + @Path("/{gameId}/move/{uci}") + def makeMove( + @PathParam("gameId") gameId: String, + @PathParam("uci") uci: String, + ): Response = + store.applyMove(gameId, uci) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).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("INVALID_MOVE", err)).build() + + @GET + @Path("/{gameId}/moves") + def getLegalMoves( + @PathParam("gameId") gameId: String, + @QueryParam("square") squareParam: String, + ): Response = + val square = Option(squareParam).flatMap(Square.fromAlgebraic) + store.legalMoves(gameId, square) match + case Right(moves) => + val dtos = moves.map(toLegalMoveDto) + Response.ok(LegalMovesResponse(dtos)).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("ERROR", err)).build() + + @POST + @Path("/{gameId}/undo") + def undoMove(@PathParam("gameId") gameId: String): Response = + store.undo(gameId) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).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("UNDO_NOT_AVAILABLE", err)).build() + + @POST + @Path("/{gameId}/redo") + def redoMove(@PathParam("gameId") gameId: String): Response = + store.redo(gameId) match + case Right(session) => Response.ok(GameMapper.toGameState(session)).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("REDO_NOT_AVAILABLE", err)).build() + + private def toLegalMoveDto(move: Move): LegalMoveDto = + val uci = GameMapper.moveToUci(move) + val (moveType, promotion) = move.moveType match + case MoveType.Normal(true) => ("capture", None) + case MoveType.Normal(false) => ("normal", None) + case MoveType.CastleKingside => ("castleKingside", None) + case MoveType.CastleQueenside => ("castleQueenside", None) + case MoveType.EnPassant => ("enPassant", None) + case MoveType.Promotion(pp) => + val pName = pp match + case PromotionPiece.Queen => "queen" + case PromotionPiece.Rook => "rook" + case PromotionPiece.Bishop => "bishop" + case PromotionPiece.Knight => "knight" + ("promotion", Some(pName)) + LegalMoveDto( + from = move.from.toString, + to = move.to.toString, + uci = uci, + moveType = moveType, + promotion = promotion, + ) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala new file mode 100644 index 0000000..bd04b22 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/MoveResourceTest.scala @@ -0,0 +1,77 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{containsString, empty, equalTo, hasItem, hasItems, not, notNullValue} +import org.junit.jupiter.api.Test + +@QuarkusTest +class MoveResourceTest: + + private def createGame(): String = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + @Test + def makeMoveReturns200WithUpdatedFen(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + .body("fen", containsString("4P3")) // e4 pawn present in FEN + .body("turn", equalTo("black")) + .body("moves", hasItem("e2e4")) + + @Test + def makeMoveOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .post("/api/board/game/XXXXXXXX/move/e2e4") + .`then`() + .statusCode(404) + + @Test + def illegalMoveReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e5") // illegal — pawns can't jump 3 squares + .`then`() + .statusCode(400) + + @Test + def getLegalMovesReturnsNonEmptyList(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/moves") + .`then`() + .statusCode(200) + .body("moves", not(empty())) + + @Test + def getLegalMovesFilteredBySquareReturnsCorrectMoves(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .get(s"/api/board/game/$gameId/moves?square=e2") + .`then`() + .statusCode(200) + .body("moves.uci", hasItems("e2e3", "e2e4")) + + @Test + def getLegalMovesOnUnknownGameReturns404(): Unit = + RestAssured.`given`() + .when() + .get("/api/board/game/XXXXXXXX/moves") + .`then`() + .statusCode(404) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala new file mode 100644 index 0000000..2e86344 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/resource/UndoRedoTest.scala @@ -0,0 +1,80 @@ +package de.nowchess.backcore.resource + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import org.hamcrest.Matchers.{containsString, equalTo} +import org.junit.jupiter.api.Test + +@QuarkusTest +class UndoRedoTest: + + private val initialFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + private def createGame(): String = + RestAssured.`given`() + .contentType("application/json") + .body("{}") + .when() + .post("/api/board/game") + .`then`() + .statusCode(201) + .extract() + .path[String]("gameId") + + @Test + def undoAfterMoveRestoresOriginalPosition(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(200) + .body("fen", equalTo(initialFen)) + .body("undoAvailable", equalTo(false)) + + @Test + def redoAfterUndoRestoresMovedPosition(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/move/e2e4") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(200) + + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/redo") + .`then`() + .statusCode(200) + .body("fen", containsString("4P3")) + .body("turn", equalTo("black")) + + @Test + def undoWithNoHistoryReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/undo") + .`then`() + .statusCode(400) + + @Test + def redoWithNoRedoStackReturns400(): Unit = + val gameId = createGame() + RestAssured.`given`() + .when() + .post(s"/api/board/game/$gameId/redo") + .`then`() + .statusCode(400)