Add chess resign and undo actions
All checks were successful
Deploy Blog Backend / test (push) Successful in 1m20s
Deploy Blog Backend / deploy (push) Successful in 1m27s

This commit is contained in:
박원엽
2026-06-19 16:41:03 +09:00
parent bd08af5d59
commit 5686d3d5ad
3 changed files with 230 additions and 0 deletions

View File

@@ -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)))
}
}

View File

@@ -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) {

View File

@@ -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",