From 5686d3d5add532c9c2ede7fced95c99511ef5f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9B=90=EC=97=BD?= Date: Fri, 19 Jun 2026 16:41:03 +0900 Subject: [PATCH] Add chess resign and undo actions --- .../api/controller/ChessGameController.kt | 16 ++ .../domain/chess/ChessGameService.kt | 71 +++++++++ .../domain/chess/ChessGameServiceTest.kt | 143 ++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt index 898b425..3557e82 100644 --- a/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessGameController.kt @@ -79,4 +79,20 @@ class ChessGameController( ): ResponseEntity> { return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request))) } + + @PostMapping("/{gameId}/resign") + fun resign( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @PathVariable gameId: String + ): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.resign(userDetails.memberId, gameId))) + } + + @PostMapping("/{gameId}/undo") + fun undoMove( + @AuthenticationPrincipal userDetails: CustomUserDetails, + @PathVariable gameId: String + ): ResponseEntity> { + return ResponseEntity.ok(ApiResponse.success(chessGameService.undoMove(userDetails.memberId, gameId))) + } } diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt index 4a18199..1faa776 100644 --- a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessGameService.kt @@ -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) { diff --git a/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt b/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt index a03366d..1199ec0 100644 --- a/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt +++ b/src/test/kotlin/me/wypark/blogbackend/domain/chess/ChessGameServiceTest.kt @@ -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 { + 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() + val stateRequests = mutableListOf() val playResponses = ArrayDeque() val playRequests = mutableListOf() override fun getState(request: MaiaStateRequest): MaiaStateResponse { + stateRequests.add(request) + if (stateResponses.isNotEmpty()) { + return stateResponses.removeFirst() + } return MaiaStateResponse( fen = START_FEN, turn = "white",