From 638bdb7b2d3b2a3ea49049c9d9f068eac0bbe44e Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 15:33:28 +0100 Subject: [PATCH] fix(chess): finished games transition from active to completed Wire onGameEndDismiss callback so the "Continue" button on game end overlay moves the game to the completed list. Add auto-detection of finished games during polling refresh so games transition even if the user navigates away without clicking the button. Discovered games that are already finished now go straight to completed instead of appearing in the active list. Co-Authored-By: Claude Opus 4.6 --- .../screen/loggedIn/chess/ChessGameScreen.kt | 1 + .../loggedIn/chess/ChessViewModelNew.kt | 2 + .../amethyst/commons/chess/ChessLobbyLogic.kt | 46 +++++++++++++++++-- .../amethyst/commons/chess/ChessLobbyState.kt | 34 ++++++++++++++ .../desktop/chess/DesktopChessViewModelNew.kt | 2 + 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 99f9579113..ef46c576c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -319,6 +319,7 @@ fun ChessGameScreen( }, onResign = { chessViewModel.resign(gameId) }, isSpectatorOverride = isSpectating, + onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index a5ab59bba6..e6b569e8cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -166,6 +166,8 @@ class ChessViewModelNew( fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + fun dismissGame(gameId: String) = logic.dismissGame(gameId) + // ============================================ // Spectator operations // ============================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 87e7bd9ed1..6c5df00c53 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameStatus import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents @@ -236,9 +238,9 @@ class ChessLobbyLogic( if (result != null) { val gameResult = when (result) { - "1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS - "0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS - "1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW + "1-0" -> GameResult.WHITE_WINS + "0-1" -> GameResult.BLACK_WINS + "1/2-1/2" -> GameResult.DRAW else -> null } if (gameResult != null) { @@ -583,7 +585,14 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - state.replaceGameState(startEventId, result.liveState) + // Check status FIRST to avoid briefly emitting a finished game through activeGames + val gameStatus = result.liveState.gameStatus.value + if (gameStatus is GameStatus.Finished) { + state.moveToCompleted(startEventId, gameStatus.result.notation, null) + pollingDelegate.removeGameId(startEventId) + } else { + state.replaceGameState(startEventId, result.liveState) + } } is LoadGameResult.Error -> { @@ -747,13 +756,26 @@ class ChessLobbyLogic( val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds + // Also check completed games to avoid re-adding them + val completedGameIds = + state.completedGames.value + .map { it.gameId } + .toSet() + for (startEventId in newGameIds) { + if (startEventId in completedGameIds) continue + val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - if (!result.liveState.isSpectator) { + // If the discovered game is already finished, send it straight to completed + val gameStatus = result.liveState.gameStatus.value + if (gameStatus is GameStatus.Finished) { + // Use the direct helper to avoid the addActiveGame → moveToCompleted flicker + state.addCompletedGameDirectly(startEventId, result.liveState, gameStatus.result.notation, null) + } else if (!result.liveState.isSpectator) { state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) } else { @@ -815,6 +837,20 @@ class ChessLobbyLogic( return null } + /** + * Dismiss a finished game from the active/spectating list and move it to completed. + * Called when the user clicks "Continue" on the game end overlay, or automatically + * when a finished game is detected during polling refresh. + */ + fun dismissGame(gameId: String) { + val gameState = state.getGameState(gameId) ?: return + val status = gameState.gameStatus.value + if (status is GameStatus.Finished) { + state.moveToCompleted(gameId, status.result.notation, null) + pollingDelegate.removeGameId(gameId) + } + } + fun clearError() { state.setError(null) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 5ebcd39f09..1f5fbaf306 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -453,6 +453,40 @@ class ChessLobbyState( } } + /** + * Build and record a CompletedGame directly from a LiveChessGameState without requiring the + * game to be inserted into activeGames first. Use this when a newly discovered game is already + * finished so we avoid the intermediate addActiveGame → moveToCompleted flicker. + */ + fun addCompletedGameDirectly( + gameId: String, + liveState: LiveChessGameState, + result: String, + termination: String?, + ) { + val whitePubkey = + if (liveState.playerColor == Color.WHITE) liveState.playerPubkey else liveState.opponentPubkey + val blackPubkey = + if (liveState.playerColor == Color.BLACK) liveState.playerPubkey else liveState.opponentPubkey + + val completed = + CompletedGame( + gameId = gameId, + whitePubkey = whitePubkey, + whiteDisplayName = null, + blackPubkey = blackPubkey, + blackDisplayName = null, + result = result, + termination = termination, + moveCount = liveState.moveHistory.value.size, + completedAt = TimeUtils.now(), + ) + + _completedGames.update { current -> + if (current.any { it.gameId == gameId }) current else listOf(completed) + current + } + } + fun addSpectatingGame( gameId: String, state: LiveChessGameState, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index b329a8c273..fb23322e7a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -162,6 +162,8 @@ class DesktopChessViewModelNew( fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + fun dismissGame(gameId: String) = logic.dismissGame(gameId) + // ============================================ // Spectator operations // ============================================