Add chess resign and undo actions
This commit is contained in:
@@ -79,4 +79,20 @@ class ChessGameController(
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request)))
|
||||
}
|
||||
|
||||
@PostMapping("/{gameId}/resign")
|
||||
fun resign(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.resign(userDetails.memberId, gameId)))
|
||||
}
|
||||
|
||||
@PostMapping("/{gameId}/undo")
|
||||
fun undoMove(
|
||||
@AuthenticationPrincipal userDetails: CustomUserDetails,
|
||||
@PathVariable gameId: String
|
||||
): ResponseEntity<ApiResponse<ChessGameResponse>> {
|
||||
return ResponseEntity.ok(ApiResponse.success(chessGameService.undoMove(userDetails.memberId, gameId)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,55 @@ class ChessGameService(
|
||||
return ChessGameResponse.from(updated, maiaResponse.move)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resign(memberId: Long, gameId: String): ChessGameResponse {
|
||||
val session = getPlayableSession(memberId, gameId)
|
||||
require(session.status == "IN_PROGRESS") { "This chess game is already finished." }
|
||||
|
||||
val result = session.playerColor.resignationResult()
|
||||
val updated = session.copy(
|
||||
status = "RESIGNED",
|
||||
result = result,
|
||||
pgn = session.pgn.withPgnResult(result),
|
||||
updatedAt = Instant.now(clock)
|
||||
)
|
||||
|
||||
saveSession(updated)
|
||||
return ChessGameResponse.from(updated)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun undoMove(memberId: Long, gameId: String): ChessGameResponse {
|
||||
val session = getPlayableSession(memberId, gameId)
|
||||
require(session.status != "RESIGNED") { "Resigned games cannot be undone." }
|
||||
|
||||
val lastPlayerMoveIndex = session.moves.indices
|
||||
.lastOrNull { sideForMoveIndex(it) == session.playerColor }
|
||||
?: throw IllegalArgumentException("There is no player move to undo.")
|
||||
val revertedMoves = session.moves.take(lastPlayerMoveIndex)
|
||||
val labels = playerLabels(session.playerColor, session.rating, session.model)
|
||||
val state = maiaEngine.getState(
|
||||
MaiaStateRequest(
|
||||
moves = revertedMoves,
|
||||
white = labels.white,
|
||||
black = labels.black
|
||||
)
|
||||
)
|
||||
|
||||
val updated = session.copy(
|
||||
fen = state.fen,
|
||||
turn = ChessSide.from(state.turn),
|
||||
moves = revertedMoves,
|
||||
status = state.status,
|
||||
result = state.result,
|
||||
pgn = state.pgn,
|
||||
updatedAt = Instant.now(clock)
|
||||
)
|
||||
|
||||
saveSession(updated)
|
||||
return ChessGameResponse.from(updated)
|
||||
}
|
||||
|
||||
private fun getPlayableSession(memberId: Long, gameId: String): ChessGameSession {
|
||||
val session = chessGameStore.findById(gameId)
|
||||
if (session != null) {
|
||||
@@ -128,6 +177,28 @@ class ChessGameService(
|
||||
require(session.memberId == memberId) { "Chess game not found." }
|
||||
}
|
||||
|
||||
private fun sideForMoveIndex(index: Int): ChessSide {
|
||||
return if (index % 2 == 0) ChessSide.WHITE else ChessSide.BLACK
|
||||
}
|
||||
|
||||
private fun ChessSide.resignationResult(): String {
|
||||
return if (this == ChessSide.WHITE) "0-1" else "1-0"
|
||||
}
|
||||
|
||||
private fun String.withPgnResult(result: String): String {
|
||||
val withHeader = if (contains(Regex("""\[Result\s+"[^"]*"]"""))) {
|
||||
replace(Regex("""\[Result\s+"[^"]*"]"""), """[Result "$result"]""")
|
||||
} else {
|
||||
"""[Result "$result"]""" + "\n" + this
|
||||
}
|
||||
val trailingResult = Regex("""(1-0|0-1|1/2-1/2|\*)\s*$""")
|
||||
return if (trailingResult.containsMatchIn(withHeader)) {
|
||||
trailingResult.replace(withHeader, result)
|
||||
} else {
|
||||
"${withHeader.trimEnd()} $result"
|
||||
}
|
||||
}
|
||||
|
||||
private fun playerLabels(playerColor: ChessSide, rating: Int, model: String): PlayerLabels {
|
||||
val maiaName = "Maia3-$model-$rating"
|
||||
return if (playerColor == ChessSide.WHITE) {
|
||||
|
||||
@@ -156,6 +156,143 @@ class ChessGameServiceTest {
|
||||
assertEquals("WIN", historyStore.savedSessions.last().outcome().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resigns an in-progress game as a player loss`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.WHITE,
|
||||
moves = listOf("e2e4", "c7c5"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "[Event \"Maia3\"]\n[Result \"*\"]\n\n1. e4 c5 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.resign(MEMBER_ID, "game-1")
|
||||
|
||||
assertEquals("RESIGNED", response.status)
|
||||
assertEquals("0-1", response.result)
|
||||
assertEquals("LOSS", response.outcome)
|
||||
assertEquals(true, response.pgn.contains("[Result \"0-1\"]"))
|
||||
assertEquals(true, response.pgn.trimEnd().endsWith("0-1"))
|
||||
assertEquals("LOSS", historyStore.savedSessions.last().outcome().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo removes the last player move and Maia reply`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.WHITE,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = "rnbqkb1r/pp1ppppp/5n2/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3",
|
||||
turn = ChessSide.WHITE,
|
||||
moves = listOf("e2e4", "c7c5", "g1f3", "g8f6"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 c5 2. Nf3 Nf6 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.stateResponses.add(
|
||||
MaiaStateResponse(
|
||||
fen = "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2",
|
||||
turn = "white",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 c5 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.undoMove(MEMBER_ID, "game-1")
|
||||
|
||||
assertEquals(listOf("e2e4", "c7c5"), response.moves)
|
||||
assertEquals("white", response.turn)
|
||||
assertEquals("1. e4 c5 *", response.pgn)
|
||||
assertEquals(listOf("e2e4", "c7c5"), engine.stateRequests.last().moves)
|
||||
assertEquals("IN_PROGRESS", historyStore.savedSessions.last().outcome().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo black game keeps Maia opening move`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.BLACK,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
|
||||
turn = ChessSide.BLACK,
|
||||
moves = listOf("e2e4", "e7e5", "g1f3"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 e5 2. Nf3 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
engine.stateResponses.add(
|
||||
MaiaStateResponse(
|
||||
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
|
||||
turn = "black",
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *"
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.undoMove(MEMBER_ID, "game-1")
|
||||
|
||||
assertEquals(listOf("e2e4"), response.moves)
|
||||
assertEquals("black", response.turn)
|
||||
assertEquals(listOf("e2e4"), engine.stateRequests.last().moves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects undo when player has not moved yet`() {
|
||||
store.save(
|
||||
ChessGameSession(
|
||||
gameId = "game-1",
|
||||
memberId = MEMBER_ID,
|
||||
rating = 1500,
|
||||
playerColor = ChessSide.BLACK,
|
||||
model = "5m",
|
||||
temperature = 0.8,
|
||||
topP = 0.95,
|
||||
fen = FakeMaiaEngine.START_FEN,
|
||||
turn = ChessSide.BLACK,
|
||||
moves = listOf("e2e4"),
|
||||
status = "IN_PROGRESS",
|
||||
result = null,
|
||||
pgn = "1. e4 *",
|
||||
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
|
||||
)
|
||||
)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
service.undoMove(MEMBER_ID, "game-1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects move when it is not player's turn`() {
|
||||
store.save(
|
||||
@@ -253,10 +390,16 @@ private class FakeChessGameHistoryStore : ChessGameHistoryStore {
|
||||
}
|
||||
|
||||
private class FakeMaiaEngine : MaiaEngine {
|
||||
val stateResponses = ArrayDeque<MaiaStateResponse>()
|
||||
val stateRequests = mutableListOf<MaiaStateRequest>()
|
||||
val playResponses = ArrayDeque<MaiaPlayResponse>()
|
||||
val playRequests = mutableListOf<MaiaPlayRequest>()
|
||||
|
||||
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
|
||||
stateRequests.add(request)
|
||||
if (stateResponses.isNotEmpty()) {
|
||||
return stateResponses.removeFirst()
|
||||
}
|
||||
return MaiaStateResponse(
|
||||
fen = START_FEN,
|
||||
turn = "white",
|
||||
|
||||
Reference in New Issue
Block a user