feat: NCS-37 move-making, legal moves, undo/redo endpoints
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user