diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f4f8f743b..6a5276d03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -168,6 +168,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -715,6 +722,49 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + // Chess events (NIP-64 live chess) + fun consume( + event: ChessGameEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: JesterEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: LiveChessGameChallengeEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameAcceptEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessMoveEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameEndEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessDrawOfferEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -2966,6 +3016,13 @@ object LocalCache : ILocalCache, ICacheProvider { is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is ChessGameEvent -> consume(event, relay, wasVerified) + is JesterEvent -> consume(event, relay, wasVerified) + is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) + is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) + is LiveChessMoveEvent -> consume(event, relay, wasVerified) + is LiveChessGameEndEvent -> consume(event, relay, wasVerified) + is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified) is HashtagListEvent -> consume(event, relay, wasVerified) is HighlightEvent -> consume(event, relay, wasVerified) is IndexerRelayListEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 01d9b07a6..8a0335bf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler @@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator( val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) + val chess = ChessFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + chess, nwc, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 8927c0793..b0f7c33d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -41,17 +41,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** * Render NIP-64 Chess Game event (Kind 64) @@ -94,9 +96,9 @@ fun RenderLiveChessChallenge( val event = (note.event as? LiveChessGameChallengeEvent) ?: return val gameId = event.gameId() ?: return - val chessViewModel: ChessViewModel = + val chessViewModel: ChessViewModelNew = viewModel( - key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", factory = ChessViewModelFactory(accountViewModel.account), ) @@ -181,15 +183,21 @@ fun RenderLiveChessChallenge( onClick = { // Accept challenge and navigate to game val challengerPubkey = note.author?.pubkeyHex ?: return@Button - val playerColor = - event.playerColor()?.opposite() ?: com.vitorpamplona.quartz.nip64Chess.Color.WHITE - chessViewModel.acceptChallenge( - challengeEventId = note.idHex, - gameId = gameId, - challengerPubkey = challengerPubkey, - playerColor = playerColor, - ) + // Create ChessChallenge from Note data + val challenge = + ChessChallenge( + eventId = note.idHex, + gameId = gameId, + challengerPubkey = challengerPubkey, + challengerDisplayName = note.author?.toBestDisplayName(), + challengerAvatarUrl = note.author?.info?.profilePicture(), + opponentPubkey = event.opponentPubkey(), + challengerColor = event.playerColor() ?: ChessColor.WHITE, + createdAt = event.createdAt, + ) + + chessViewModel.acceptChallenge(challenge) // Navigate to game nav.nav(Route.ChessGame(gameId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index fdb617d02..fa0f694a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -20,217 +20,375 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent /** - * Android implementation of ChessEventPublisher. + * Android implementation of ChessEventPublisher using Jester protocol. * Wraps account.signAndComputeBroadcast() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content */ class AndroidChessPublisher( private val account: Account, ) : ChessEventPublisher { - override suspend fun publishChallenge( - gameId: String, + private val broadcaster = ChessEventBroadcaster(account.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[AndroidChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( playerColor: Color, opponentPubkey: String?, - timeControl: String?, - ): Boolean = + ): String? = try { val template = - LiveChessGameChallengeEvent.build( - gameId = gameId, - playerColor = playerColor, - opponentPubkey = opponentPubkey, - timeControl = timeControl, - ) - account.signAndComputeBroadcast(template) - true + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null } catch (e: Exception) { - false + println("[AndroidChessPublisher] publishStart failed: ${e.message}") + null } - override suspend fun publishAccept( - gameId: String, - challengeEventId: String, - challengerPubkey: String, - ): Boolean = + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = try { val template = - LiveChessGameAcceptEvent.build( - gameId = gameId, - challengeEventId = challengeEventId, - challengerPubkey = challengerPubkey, - ) - account.signAndComputeBroadcast(template) - true - } catch (e: Exception) { - false - } - - override suspend fun publishMove(move: ChessMoveEvent): Boolean = - try { - val template = - LiveChessMoveEvent.build( - gameId = move.gameId, - moveNumber = move.moveNumber, - san = move.san, + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, fen = move.fen, + history = move.history, opponentPubkey = move.opponentPubkey, ) - account.signAndComputeBroadcast(template) - true + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null } catch (e: Exception) { - false + println("[AndroidChessPublisher] publishMove failed: ${e.message}") + null } + /** + * Publish a game end event (includes result in content). + */ override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = try { val template = - LiveChessGameEndEvent.build( - gameId = gameEnd.gameId, + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, result = gameEnd.result, termination = gameEnd.termination, - winnerPubkey = gameEnd.winnerPubkey, - opponentPubkey = gameEnd.opponentPubkey, - pgn = gameEnd.pgn ?: "", ) - account.signAndComputeBroadcast(template) - true + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + success } catch (e: Exception) { + println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}") false } - override suspend fun publishDrawOffer( - gameId: String, - opponentPubkey: String, - message: String?, - ): Boolean = - try { - val template = - LiveChessDrawOfferEvent.build( - gameId = gameId, - opponentPubkey = opponentPubkey, - message = message ?: "", - ) - account.signAndComputeBroadcast(template) - true - } catch (e: Exception) { - false - } - - override fun getWriteRelayCount(): Int = account.outboxRelays.flow.value.size + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size } /** - * Android implementation of ChessRelayFetcher. - * Uses LocalCache for game state (hybrid approach during migration). + * Android implementation of ChessRelayFetcher using Jester protocol. + * + * Uses direct one-shot relay fetching for reliability: + * - fetchGameEvents: fetches all events for a specific game + * - fetchChallenges: fetches start events (challenges) + * - fetchUserGameIds: discovers games where user is a participant + * - fetchRecentGames: fetches recent games for spectating + * + * Each fetch opens a temporary subscription, waits for EOSE, and returns results. */ class AndroidRelayFetcher( private val account: Account, ) : ChessRelayFetcher { - override suspend fun fetchGameEvents(gameId: String): ChessGameEvents { - // Query LocalCache for all game events - val challengeNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent + private val fetchHelper = ChessRelayFetchHelper(account.client) + private val userPubkey = account.userProfile().pubkeyHex - val acceptNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = + ChessConfig.CHESS_RELAYS.map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + } - val moveNotes = - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val moveEvents = moveNotes.mapNotNull { it.event as? LiveChessMoveEvent } + override fun getRelayUrls(): List { + println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } - val endNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent + /** + * Fetch game events directly from relays. + * Uses one-shot fetch for reliability. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId") - return ChessGameEvents( - challenge = challengeEvent, - accept = acceptEvent, - moves = moveEvents, - end = endEvent, - drawOffers = emptyList(), // TODO: Fetch draw offers + // Filter 1: Fetch the start event by its ID + val startEventFilter = + com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + + // Combine both filters + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val events = fetchHelper.fetchEvents(relayFilters) + + var startEvent: JesterEvent? = null + val moves = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (jesterEvent.isStartEvent() && jesterEvent.id == startEventId) { + startEvent = jesterEvent + } else if (jesterEvent.isMoveEvent() && jesterEvent.startEventId() == startEventId) { + moves.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}") + + return JesterGameEvents( + startEvent = startEvent, + moves = moves, ) } - override suspend fun fetchChallenges(): List { - val challengeNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - note.event is LiveChessGameChallengeEvent + /** + * Fetch challenges (start events) directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays") + + if (relays.isEmpty()) { + println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!") + return emptyList() + } + + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}") + println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}") + + val relayFilters = relays.associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events") + + // Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events + if (events.isEmpty()) { + println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...") + val debugFilter = ChessFilterBuilder.recentGamesFilter() + val debugFilters = relays.associateWith { listOf(debugFilter) } + val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000) + println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events") + debugEvents.take(5).forEach { e -> + println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}") } - return challengeNotes.mapNotNull { it.event as? LiveChessGameChallengeEvent } + } + + val challenges = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (!jesterEvent.isStartEvent()) return@forEach + + // Include challenges that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val isRelevant = + jesterEvent.opponentPubkey() == null || + jesterEvent.opponentPubkey() == userPubkey || + jesterEvent.pubKey == userPubkey + + if (isRelevant) { + challenges.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events") + return challenges } + /** + * Fetch recent games for spectating. + */ override suspend fun fetchRecentGames(): List { - // Query LocalCache for recent active games - // Group moves by game ID and create summaries - val moveNotes = - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - note.event is LiveChessMoveEvent - } - val gameIds = moveNotes.mapNotNull { (it.event as? LiveChessMoveEvent)?.gameId() }.distinct() + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters) - return gameIds.mapNotNull { gameId -> - val events = fetchGameEvents(gameId) - val challenge = events.challenge ?: return@mapNotNull null - val accept = events.accept + // Convert to JesterEvents + val jesterEvents = + events + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } - // Determine white/black pubkeys - val challengerColor = challenge.playerColor() ?: Color.WHITE - val whitePubkey = + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } + + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } + + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = if (challengerColor == Color.WHITE) { - challenge.pubKey + challengerPubkey to opponentPubkey } else { - accept?.pubKey ?: return@mapNotNull null - } - val blackPubkey = - if (challengerColor == Color.BLACK) { - challenge.pubKey - } else { - accept?.pubKey ?: return@mapNotNull null + opponentPubkey to challengerPubkey } - val lastMove = events.moves.maxByOrNull { it.createdAt } + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null RelayGameSummary( - gameId = gameId, + startEventId = startEventId, whitePubkey = whitePubkey, blackPubkey = blackPubkey, - moveCount = events.moves.size, - lastMoveTime = lastMove?.createdAt ?: challenge.createdAt, - isActive = events.end == null, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, ) } } + + /** + * Fetch user's game IDs directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays") + + // Fetch events authored by user AND events tagging user + val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + + val relayFilters = + chessRelayUrls().associateWith { + listOf(authoredFilter, taggedFilter) + } + + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + val gameIds = mutableSetOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + // Check if user is involved (author or tagged) + val isUserInvolved = jesterEvent.pubKey == userPubkey || jesterEvent.opponentPubkey() == userPubkey + if (!isUserInvolved) return@forEach + + if (jesterEvent.isStartEvent()) { + gameIds.add(jesterEvent.id) + } else if (jesterEvent.isMoveEvent()) { + jesterEvent.startEventId()?.let { gameIds.add(it) } + } + } + + println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events") + return gameIds + } } /** 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 72df68b49..5c8d6fd08 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 @@ -59,13 +59,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator /** * Wrapper screen for live chess game @@ -83,9 +88,12 @@ fun ChessGameScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val chessViewModel: ChessViewModel = + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = viewModel( - key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", factory = ChessViewModelFactory( accountViewModel.account, @@ -95,12 +103,18 @@ fun ChessGameScreen( val activeGames by chessViewModel.activeGames.collectAsState() val spectatingGames by chessViewModel.spectatingGames.collectAsState() val error by chessViewModel.error.collectAsState() - val chessStatus by chessViewModel.chessStatus.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() + // Observe state version to force recomposition when game state changes internally + val stateVersion by chessViewModel.stateVersion.collectAsState() var isLoading by remember { mutableStateOf(true) } - var loadedGameState by remember { mutableStateOf(null) } var loadAttempted by remember { mutableStateOf(false) } var showRelaySettings by remember { mutableStateOf(false) } + // Always read directly from maps for auto-refresh support + // stateVersion changes force recomposition, then we get latest from maps + val gameState = activeGames[gameId] ?: spectatingGames[gameId] + // Get relay information val outboxRelays by accountViewModel.account.outboxRelays.flow .collectAsState() @@ -113,44 +127,60 @@ fun ChessGameScreen( // This is critical - without it, no new events will arrive from relays ChessSubscription(accountViewModel, chessViewModel) - // Try to load game from cache if not in activeGames or spectatingGames - LaunchedEffect(gameId, activeGames, spectatingGames) { - val existingState = activeGames[gameId] ?: spectatingGames[gameId] - if (existingState != null) { - loadedGameState = existingState + // Handle initial game loading + // Once game is in maps, gameState above will always have the latest + LaunchedEffect(gameId, gameState) { + if (gameState != null) { + // Game found in maps - done loading isLoading = false loadAttempted = true } else if (!loadAttempted) { - // Try to load from LocalCache - loadAttempted = true - val loaded = chessViewModel.loadGameFromCache(gameId) - loadedGameState = loaded - isLoading = false + // Check if this game was accepted locally (prevents incorrect spectator detection) + val wasAccepted = chessViewModel.wasAccepted(gameId) + + // Brief delay to allow acceptChallenge coroutine to complete + // This handles the race condition where navigation happens before + // the game state is fully propagated to StateFlow collectors + kotlinx.coroutines.delay(150) + + // Check again after delay - game may have been added by acceptChallenge + val stateAfterDelay = chessViewModel.getGameState(gameId) + if (stateAfterDelay != null) { + isLoading = false + loadAttempted = true + } else if (wasAccepted) { + // Game was accepted but not yet in StateFlow - keep waiting, don't fetch from relays + // The state will propagate and trigger this LaunchedEffect again + isLoading = true + } else { + // Game not found locally and not accepted, try to load from relays + loadAttempted = true + chessViewModel.loadGame(gameId) + isLoading = false + } } } - // Update state when games change (e.g., after refresh loads new moves) - LaunchedEffect(activeGames[gameId], spectatingGames[gameId]) { - (activeGames[gameId] ?: spectatingGames[gameId])?.let { - loadedGameState = it - } - } - - // Start polling when screen is visible - DisposableEffect(Unit) { - chessViewModel.startPolling() + // Set focused game mode when screen is visible (only poll this game) + // ViewModel already starts polling in init, so no need to start here + DisposableEffect(gameId) { + chessViewModel.setFocusedGame(gameId) // Focus on just this game onDispose { - // Don't stop polling - let ViewModel manage it + // Don't clear focus here - let lobby handle it when we navigate back } } - val gameState = loadedGameState ?: activeGames[gameId] ?: spectatingGames[gameId] + // Extract human-readable game name + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: "Chess Game" + } Scaffold( topBar = { Column { TopAppBar( - title = { Text("Chess Game") }, + title = { Text(gameName) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { Icon( @@ -169,15 +199,21 @@ fun ChessGameScreen( }, ) - // Status banner below top bar - ChessStatusBanner( - status = chessStatus, + // Sync status banner + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, onTap = { - when (chessStatus) { - is ChessStatus.MoveFailed -> { + when (broadcastStatus) { + is ChessBroadcastStatus.Failed -> { // Could implement retry logic here } - is ChessStatus.Desynced -> { + is ChessBroadcastStatus.Desynced -> { chessViewModel.forceRefresh() } else -> { } @@ -251,17 +287,30 @@ fun ChessGameScreen( } } else -> { + // Resolve opponent display name + val opponentDisplayName = + remember(gameState.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName() + ?: gameState.opponentPubkey.take(8) + } + + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = chessViewModel.wasAccepted(gameId) + val isSpectating = !wasAccepted && spectatingGames.containsKey(gameId) + // Show game with proper padding for status bar // Use state-observing version for automatic refresh on polling updates LiveChessGameScreen( modifier = Modifier.padding(paddingValues), gameState = gameState, - opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name + opponentName = opponentDisplayName, onMoveMade = { from, to, san -> chessViewModel.publishMove(gameId, from, to) }, onResign = { chessViewModel.resign(gameId) }, - onOfferDraw = { chessViewModel.offerDraw(gameId) }, + isSpectatorOverride = isSpectating, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 00e34eaf2..f7250f45e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,12 +31,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -66,22 +66,26 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** * Chess lobby screen showing challenges and active games @@ -92,9 +96,12 @@ fun ChessLobbyScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val chessViewModel: ChessViewModel = + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = viewModel( - key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", factory = ChessViewModelFactory(accountViewModel.account), ) @@ -103,7 +110,8 @@ fun ChessLobbyScreen( val publicGames by chessViewModel.publicGames.collectAsState() val challenges by chessViewModel.challenges.collectAsState() val error by chessViewModel.error.collectAsState() - val chessStatus by chessViewModel.chessStatus.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() val selectedGameId by chessViewModel.selectedGameId.collectAsState() val userPubkey = accountViewModel.account.userProfile().pubkeyHex @@ -138,11 +146,17 @@ fun ChessLobbyScreen( } } - // Start polling when screen is visible + // Set lobby mode (poll all games) when screen is visible + // Don't stop polling in onDispose - ViewModel lifecycle handles that + // This prevents the race where lobby's onDispose fires AFTER game screen enters DisposableEffect(Unit) { + chessViewModel.clearFocusedGame() // Clear any focused game from game screen chessViewModel.startPolling() onDispose { - chessViewModel.stopPolling() + // Don't stop polling here - it causes a race condition: + // 1. Game screen enters and sets focused mode + // 2. Lobby's onDispose fires and stops ALL polling + // ViewModel.onCleared() will stop polling when user leaves chess entirely } } @@ -197,9 +211,16 @@ fun ChessLobbyScreen( .fillMaxSize() .padding(horizontal = 16.dp), ) { + // Sync status banner (shows relay progress during loading) + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + // Broadcast status banner - ChessStatusBanner( - status = chessStatus, + ChessBroadcastBanner( + status = broadcastStatus, onTap = { /* no-op in lobby */ }, modifier = Modifier.padding(bottom = 8.dp), ) @@ -231,8 +252,11 @@ fun ChessLobbyScreen( publicGames = publicGames, userPubkey = userPubkey, accountViewModel = accountViewModel, - onAcceptChallenge = { note -> - chessViewModel.acceptChallenge(note) + onAcceptChallenge = { challenge -> + chessViewModel.acceptChallenge(challenge) + }, + onOpenOwnChallenge = { challenge -> + chessViewModel.openOwnChallenge(challenge) }, onSelectGame = { gameId -> nav.nav(Route.ChessGame(gameId)) @@ -276,13 +300,14 @@ fun ChessLobbyScreen( @Composable fun ChessLobbyContent( - challenges: List, + challenges: List, activeGames: Map, spectatingGames: Map, - publicGames: List, + publicGames: List, userPubkey: String, accountViewModel: AccountViewModel, - onAcceptChallenge: (Note) -> Unit, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, onSelectGame: (String) -> Unit, onWatchGame: (String) -> Unit, ) { @@ -291,14 +316,13 @@ fun ChessLobbyContent( publicGames.isNotEmpty() || challenges.isNotEmpty() if (!hasContent) { - // Empty state - Box( + // Empty state - use LazyColumn so pull-to-refresh works + LazyColumn( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { + item { Text( "No games or challenges", style = MaterialTheme.typography.bodyLarge, @@ -310,12 +334,30 @@ fun ChessLobbyContent( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(Modifier.height(16.dp)) + Text( + "Pull down to refresh", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) } } return } + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(userPubkey) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Active games section (user is participant) @@ -330,16 +372,45 @@ fun ChessLobbyContent( } items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> - ActiveGameCard( + val displayName = + remember(state.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( gameId = gameId, - opponentPubkey = state.opponentPubkey, + opponentName = displayName, isYourTurn = state.isPlayerTurn(), - accountViewModel = accountViewModel, onClick = { onSelectGame(gameId) }, ) } } + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } + if (outgoingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Your Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + val opponentName = + challenge.opponentPubkey?.let { pubkey -> + accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = opponentName, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + ) + } + } + // Games user is spectating if (spectatingGames.isNotEmpty()) { item { @@ -353,21 +424,19 @@ fun ChessLobbyContent( } items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> - SpectatingGameCard( - gameId = gameId, - gameState = state, - accountViewModel = accountViewModel, + val moveCount = + state.moveHistory + .collectAsState() + .value.size + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount, onClick = { onSelectGame(gameId) }, ) } } - // Incoming challenges - val incomingChallenges = - challenges.filter { - val event = it.event as? LiveChessGameChallengeEvent - event?.opponentPubkey() == userPubkey - } + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } if (incomingChallenges.isNotEmpty()) { item { Spacer(Modifier.height(16.dp)) @@ -379,22 +448,24 @@ fun ChessLobbyContent( ) } - items(incomingChallenges, key = { "incoming-${it.idHex}" }) { note -> - ChallengeCard( - note = note, + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = true, - accountViewModel = accountViewModel, - onAccept = { onAcceptChallenge(note) }, + onAccept = { onAcceptChallenge(challenge) }, ) } } // Open challenges from others (can join) - val openChallenges = - challenges.filter { - val event = it.event as? LiveChessGameChallengeEvent - event?.opponentPubkey() == null && it.author?.pubkeyHex != userPubkey - } + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(userPubkey) } if (openChallenges.isNotEmpty()) { item { Spacer(Modifier.height(16.dp)) @@ -406,12 +477,18 @@ fun ChessLobbyContent( ) } - items(openChallenges, key = { "open-${it.idHex}" }) { note -> - ChallengeCard( - note = note, + items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = false, - accountViewModel = accountViewModel, - onAccept = { onAcceptChallenge(note) }, + onAccept = { onAcceptChallenge(challenge) }, ) } } @@ -429,38 +506,17 @@ fun ChessLobbyContent( } items(publicGames, key = { "public-${it.gameId}" }) { game -> - PublicGameCard( - game = game, - accountViewModel = accountViewModel, + val whiteName = game.whiteDisplayName ?: game.whitePubkey.take(8) + val blackName = game.blackDisplayName ?: game.blackPubkey.take(8) + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = whiteName, + blackName = blackName, + moveCount = game.moveCount, onWatch = { onWatchGame(game.gameId) }, ) } } - // User's outgoing challenges (last, as they're just waiting) - val outgoingChallenges = - challenges.filter { - it.author?.pubkeyHex == userPubkey - } - if (outgoingChallenges.isNotEmpty()) { - item { - Spacer(Modifier.height(16.dp)) - Text( - "Your Challenges", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - - items(outgoingChallenges, key = { "outgoing-${it.idHex}" }) { note -> - OutgoingChallengeCard( - note = note, - accountViewModel = accountViewModel, - ) - } - } - // Bottom padding for FAB item { Spacer(Modifier.height(80.dp)) @@ -468,254 +524,6 @@ fun ChessLobbyContent( } } -@Composable -private fun ActiveGameCard( - gameId: String, - opponentPubkey: String, - isYourTurn: Boolean, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - val displayName = - remember(opponentPubkey) { - accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8) - } - - // Extract human-readable game name if available - val gameName = - remember(gameId) { - ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12) - } - - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - border = - if (isYourTurn) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - gameName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "vs $displayName", - style = MaterialTheme.typography.bodyMedium, - ) - } - - Text( - if (isYourTurn) "Your turn" else "Waiting...", - style = MaterialTheme.typography.bodyMedium, - color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - ) - } - } -} - -@Composable -private fun ChallengeCard( - note: Note, - isIncoming: Boolean, - accountViewModel: AccountViewModel, - onAccept: () -> Unit, -) { - val event = note.event as? LiveChessGameChallengeEvent ?: return - val challengerPubkey = note.author?.pubkeyHex ?: return - val playerColor = event.playerColor() - - val displayName = - remember(challengerPubkey) { - accountViewModel.checkGetOrCreateUser(challengerPubkey)?.toBestDisplayName() ?: challengerPubkey.take(8) - } - - val borderColor = - if (isIncoming) { - Color(0xFFFF9800) // Orange for incoming - } else { - Color(0xFF4CAF50) // Green for open - } - - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(2.dp, borderColor), - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - if (isIncoming) "Challenge from $displayName" else "Open challenge by $displayName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Button(onClick = onAccept) { - Text("Accept") - } - } - } -} - -@Composable -private fun OutgoingChallengeCard( - note: Note, - accountViewModel: AccountViewModel, -) { - val event = note.event as? LiveChessGameChallengeEvent ?: return - val opponentPubkey = event.opponentPubkey() - val playerColor = event.playerColor() - - val displayName = - remember(opponentPubkey) { - opponentPubkey?.let { - accountViewModel.checkGetOrCreateUser(it)?.toBestDisplayName() ?: it.take(8) - } - } - - // Not clickable - waiting for opponent to accept - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - if (displayName != null) { - "Challenge to $displayName" - } else { - "Open challenge (awaiting opponent)" - }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Text( - "Waiting...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun SpectatingGameCard( - gameId: String, - gameState: LiveChessGameState, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - val moveCount = - gameState.moveHistory - .collectAsState() - .value.size - - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - border = BorderStroke(1.dp, Color(0xFF9C27B0)), // Purple for spectating - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Watching game", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "$moveCount moves played", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Text( - "Spectating", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF9C27B0), - fontWeight = FontWeight.Medium, - ) - } - } -} - -@Composable -private fun PublicGameCard( - game: PublicGameInfo, - accountViewModel: AccountViewModel, - onWatch: () -> Unit, -) { - val whiteName = - remember(game.whitePubkey) { - accountViewModel.checkGetOrCreateUser(game.whitePubkey)?.toBestDisplayName() ?: game.whitePubkey.take(8) - } - val blackName = - remember(game.blackPubkey) { - accountViewModel.checkGetOrCreateUser(game.blackPubkey)?.toBestDisplayName() ?: game.blackPubkey.take(8) - } - - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(1.dp, Color(0xFF2196F3)), // Blue for live games - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "$whiteName vs $blackName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "${game.moveCount} moves", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - OutlinedButton(onClick = onWatch) { - Text("Watch") - } - } - } -} - /** * Bottom sheet showing relay settings and debug info for chess subscriptions */ @@ -726,12 +534,14 @@ private fun ChessRelaySettingsSheet( globalRelays: List, challengeCount: Int, publicGameCount: Int, + connectedRelayCount: Int = 0, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), ) { Text( text = "Chess Relay Settings", @@ -778,6 +588,32 @@ private fun ChessRelaySettingsSheet( HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) + // Chess relay info + Text( + text = "Active Chess Relays", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Chess uses 3 dedicated relays for fast, reliable queries", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Show chess relays and their connection status + ChessConfig.CHESS_RELAY_NAMES.forEach { relay -> + val isConnected = + inboxRelays.any { it.contains(relay) } || + outboxRelays.any { it.contains(relay) } || + globalRelays.any { it.contains(relay) } + ChessRelayRow(relayUrl = "wss://$relay/", isConnected = isConnected) + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + // Inbox relays (where challenges TO you are fetched) Text( text = "Inbox Relays (${inboxRelays.size})", @@ -880,7 +716,10 @@ private fun ChessRelaySettingsSheet( } @Composable -private fun RelayRow(relayUrl: String) { +private fun RelayRow( + relayUrl: String, + isPreferred: Boolean = false, +) { Row( modifier = Modifier @@ -892,12 +731,73 @@ private fun RelayRow(relayUrl: String) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Connected", - tint = MaterialTheme.colorScheme.primary, + tint = + if (isPreferred) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + }, modifier = Modifier.size(12.dp), ) Text( text = relayUrl.removePrefix("wss://").removePrefix("ws://"), style = MaterialTheme.typography.bodySmall, + fontWeight = if (isPreferred) FontWeight.Medium else FontWeight.Normal, + ) + if (isPreferred) { + Text( + text = "preferred", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + +@Composable +private fun ChessRelayRow( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = + if (isConnected) { + Icons.Default.CheckCircle + } else { + Icons.Default.Close + }, + contentDescription = if (isConnected) "Connected" else "Not connected", + tint = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier.size(16.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.weight(1f)) + Text( + text = if (isConnected) "connected" else "not connected", + style = MaterialTheme.typography.labelSmall, + color = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt index e0ae5c8ca..8aabd38ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -38,22 +39,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * Subscribe to chess events when the Chess screen is active. * - * Always uses global relays to ensure we see all chess events, - * regardless of the user's feed filter setting (Global/Follows). + * Uses Amethyst's subscription system to fetch events into LocalCache, + * then triggers ViewModel refresh to process them. */ @Composable fun ChessSubscription( accountViewModel: AccountViewModel, - chessViewModel: ChessViewModel, + chessViewModel: ChessViewModelNew, ) { // Get active game IDs from the view model for game-specific subscriptions val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle() val activeGameIds = activeGames.keys + spectatingGames.keys - // Extract opponent pubkeys for move filtering + // Extract opponent pubkeys using stable keys (avoid recomposition from LiveChessGameState identity) val opponentPubkeys = - remember(activeGames) { + remember(activeGameIds) { activeGames.values.map { it.opponentPubkey }.toSet() } @@ -72,14 +73,15 @@ fun ChessSubscription( ) } - // Note: Chess subscription is now managed internally by ChessLobbyLogic. - // This composable is kept for backward compatibility but the actual - // subscription is handled by the ViewModel's ChessLobbyLogic instance. - // TODO: Step 7 will integrate this with the new ChessSubscriptionController + // Register subscription with Amethyst's subscription system + KeyDataSourceSubscription(state, accountViewModel.dataSources().chess) + + // Trigger ViewModel refresh when subscription state changes + // This fetches challenges from LocalCache after events arrive DisposableEffect(state) { - chessViewModel.refreshChallenges() + chessViewModel.forceRefresh() onDispose { - // Subscription cleanup handled by ViewModel + // Subscription cleanup handled by KeyDataSourceSubscription } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt deleted file mode 100644 index d727649b6..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModel.kt +++ /dev/null @@ -1,1220 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults -import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.filterIntoSet -import com.vitorpamplona.quartz.nip64Chess.ChessEngine -import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator -import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent -import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.GameResult -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent -import com.vitorpamplona.quartz.nip64Chess.PieceType -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * ViewModel for managing chess game state, challenges, and event publishing - */ -class ChessViewModel( - private val account: Account, -) : ViewModel() { - companion object { - // Challenge expiry: 24 hours - const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L - - // Retry configuration - const val MAX_RETRIES = 3 - const val RETRY_DELAY_MS = 1000L - - // Cleanup interval: check every 5 minutes - const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L - } - - // Active games being played (user is a participant) - private val _activeGames = MutableStateFlow>(emptyMap()) - val activeGames: StateFlow> = _activeGames.asStateFlow() - - // Public games we can spectate (user is NOT a participant) - private val _publicGames = MutableStateFlow>(emptyList()) - val publicGames: StateFlow> = _publicGames.asStateFlow() - - // Spectating games (user is watching but not playing) - private val _spectatingGames = MutableStateFlow>(emptyMap()) - val spectatingGames: StateFlow> = _spectatingGames.asStateFlow() - - // Pending challenges (ALL non-expired challenges) - private val _challenges = MutableStateFlow>(emptyList()) - val challenges: StateFlow> = _challenges.asStateFlow() - - // Badge count for notifications (incoming challenges + your turn games) - private val _badgeCount = MutableStateFlow(0) - val badgeCount: StateFlow = _badgeCount.asStateFlow() - - // Currently selected game (for navigation) - private val _selectedGameId = MutableStateFlow(null) - val selectedGameId: StateFlow = _selectedGameId.asStateFlow() - - // Error state - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() - - // Pending retry operations - private val _pendingRetries = MutableStateFlow>(emptyMap()) - val pendingRetries: StateFlow> = _pendingRetries.asStateFlow() - - // Chess status for UI feedback (broadcasting, syncing, etc.) - private val _chessStatus = MutableStateFlow(ChessStatus.Idle) - val chessStatus: StateFlow = _chessStatus.asStateFlow() - - // Connected relays for display - private val _connectedRelays = MutableStateFlow>(emptyList()) - val connectedRelays: StateFlow> = _connectedRelays.asStateFlow() - - // Polling delegate for periodic refresh - private val pollingDelegate = - ChessPollingDelegate( - config = ChessPollingDefaults.android, - scope = viewModelScope, - onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) }, - onRefreshChallenges = { refreshChallenges() }, - onCleanup = { - cleanupExpiredChallenges() - checkAbandonedGames() - }, - ) - - // Track games currently being created to prevent race conditions (like Desktop) - private val gamesBeingCreated = mutableSetOf() - - init { - subscribeToChessEvents() - // Start polling - will do initial fetch - startPolling() - } - - /** - * Start polling for chess events - */ - fun startPolling() { - pollingDelegate.start() - } - - /** - * Stop polling (call when screen is not visible on Android) - */ - fun stopPolling() { - pollingDelegate.stop() - } - - /** - * Force immediate refresh - */ - fun forceRefresh() { - pollingDelegate.refreshNow() - } - - /** - * Remove expired challenges - */ - private fun cleanupExpiredChallenges() { - val now = TimeUtils.now() - val validChallenges = - _challenges.value.filter { note -> - val event = note.event as? LiveChessGameChallengeEvent - val createdAt = event?.createdAt ?: 0 - (now - createdAt) < CHALLENGE_EXPIRY_SECONDS - } - - if (validChallenges.size != _challenges.value.size) { - _challenges.value = validChallenges - updateBadgeCount() - } - } - - /** - * Check for abandoned games and notify - */ - private fun checkAbandonedGames() { - _activeGames.value.forEach { (gameId, state) -> - if (state.isAbandoned()) { - // Game is abandoned, could auto-claim or notify user - _error.value = "Game $gameId appears abandoned. You can claim victory." - } - } - } - - /** - * Subscribe to incoming chess events from LocalCache - */ - private fun subscribeToChessEvents() { - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - for (note in newNotes) { - when (val event = note.event) { - is LiveChessMoveEvent -> handleIncomingMove(event) - is LiveChessGameAcceptEvent -> handleGameAccepted(event) - is LiveChessGameEndEvent -> handleGameEnded(event) - is LiveChessGameChallengeEvent -> handleNewChallenge(note, event) - } - } - } - } - } - - /** - * Handle incoming move event from opponent - */ - private fun handleIncomingMove(event: LiveChessMoveEvent) { - // Only process moves from opponents (not our own) - if (event.pubKey == account.signer.pubKey) return - - val gameId = event.gameId() ?: return - val gameState = _activeGames.value[gameId] ?: return - - // Verify this move is from our opponent - if (event.pubKey != gameState.opponentPubkey) return - - val san = event.san() ?: return - val fen = event.fen() ?: return - val moveNumber = event.moveNumber() - - gameState.applyOpponentMove(san, fen, moveNumber) - updateBadgeCount() - } - - /** - * Handle game acceptance event - */ - private fun handleGameAccepted(event: LiveChessGameAcceptEvent) { - val gameId = event.gameId() ?: return - - // Skip if game already exists (prevents overwrite) - if (_activeGames.value.containsKey(gameId)) return - - // Check if this is acceptance of our challenge - if (event.challengerPubkey() == account.signer.pubKey) { - startGameFromAcceptance(event) - } - } - - /** - * Handle game end event - */ - private fun handleGameEnded(event: LiveChessGameEndEvent) { - val gameId = event.gameId() ?: return - - // Remove from active games if present - if (_activeGames.value.containsKey(gameId)) { - _activeGames.value = _activeGames.value - gameId - pollingDelegate.removeGameId(gameId) - updateBadgeCount() - } - } - - /** - * Handle new challenge event (for feed display) - */ - private fun handleNewChallenge( - note: Note, - event: LiveChessGameChallengeEvent, - ) { - val gameId = event.gameId() ?: return - - // Skip if this game is already active - if (_activeGames.value.containsKey(gameId)) return - - // Add to challenges if directed at us, from us, or is open - val opponentPubkey = event.opponentPubkey() - val isFromUs = event.pubKey == account.signer.pubKey - val isDirectedAtUs = opponentPubkey == account.signer.pubKey - val isOpenChallenge = opponentPubkey == null - - if (isFromUs || isDirectedAtUs || isOpenChallenge) { - val currentChallenges = _challenges.value.toMutableList() - // Deduplicate by both event ID and game ID - val isDuplicateEvent = currentChallenges.any { it.idHex == note.idHex } - val isDuplicateGame = - currentChallenges.any { - (it.event as? LiveChessGameChallengeEvent)?.gameId() == gameId - } - if (!isDuplicateEvent && !isDuplicateGame) { - currentChallenges.add(note) - _challenges.value = currentChallenges - updateBadgeCount() - } - } - } - - /** - * Create a new chess challenge (open or directed) with retry - */ - fun createChallenge( - opponentPubkey: String? = null, - playerColor: Color = Color.WHITE, - timeControl: String? = null, - ) { - val acc = account - val gameId = generateGameId() - - viewModelScope.launch(Dispatchers.IO) { - val relayCount = acc.outboxRelays.flow.value.size - _chessStatus.value = - ChessStatus.BroadcastingMove( - san = "Challenge", - successCount = 0, - totalRelays = relayCount, - ) - - val success = - retryWithBackoff("challenge-$gameId") { - val template = - LiveChessGameChallengeEvent.build( - gameId = gameId, - playerColor = playerColor, - opponentPubkey = opponentPubkey, - timeControl = timeControl, - ) - - acc.signAndComputeBroadcast(template) - } - - if (success) { - _chessStatus.value = - ChessStatus.MoveSuccess( - san = "Challenge", - relayCount = relayCount, - ) - _error.value = null - refreshChallenges() - delay(3000) - if (_chessStatus.value is ChessStatus.MoveSuccess) { - _chessStatus.value = ChessStatus.Idle - } - } else { - _chessStatus.value = - ChessStatus.MoveFailed( - san = "Challenge", - error = "Failed after $MAX_RETRIES attempts", - ) - _error.value = "Failed to create challenge after $MAX_RETRIES attempts" - } - } - } - - /** - * Accept a chess challenge (simple version for UI callbacks) - */ - fun acceptChallenge( - challengeEventId: String, - gameId: String, - challengerPubkey: String, - playerColor: Color, - ) { - // Mark as being created to prevent duplicate from relay echo - synchronized(gamesBeingCreated) { - if (!gamesBeingCreated.add(gameId)) return // Already being created - } - - viewModelScope.launch(Dispatchers.IO) { - try { - val template = - LiveChessGameAcceptEvent.build( - gameId = gameId, - challengeEventId = challengeEventId, - challengerPubkey = challengerPubkey, - ) - - account.signAndComputeBroadcast(template) - - // Check if game was already created by relay echo while we were broadcasting - if (_activeGames.value.containsKey(gameId)) { - synchronized(gamesBeingCreated) { - gamesBeingCreated.remove(gameId) - } - _selectedGameId.value = gameId - _error.value = null - return@launch - } - - // Create game state - val engine = ChessEngine() - engine.reset() - - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = account.signer.pubKey, - opponentPubkey = challengerPubkey, - playerColor = playerColor, - engine = engine, - ) - - _activeGames.value = _activeGames.value + (gameId to gameState) - synchronized(gamesBeingCreated) { - gamesBeingCreated.remove(gameId) - } - pollingDelegate.addGameId(gameId) - _selectedGameId.value = gameId - _error.value = null - } catch (e: Exception) { - synchronized(gamesBeingCreated) { - gamesBeingCreated.remove(gameId) - } - _error.value = "Failed to accept challenge: ${e.message}" - } - } - } - - /** - * Accept a chess challenge (from Note) - */ - fun acceptChallenge(challengeNote: Note) { - val challengeEvent = challengeNote.event as? LiveChessGameChallengeEvent ?: return - - val gameId = challengeEvent.gameId() ?: return - - // Skip if game already exists (prevents overwrite) - if (_activeGames.value.containsKey(gameId)) { - _selectedGameId.value = gameId - return - } - - val challengerPubkey = challengeEvent.pubKey - val challengerColor = challengeEvent.playerColor() ?: Color.WHITE - val playerColor = challengerColor.opposite() - - acceptChallenge(challengeEvent.id, gameId, challengerPubkey, playerColor) - } - - /** - * Start a game after your challenge was accepted - */ - fun startGameFromAcceptance(acceptEvent: LiveChessGameAcceptEvent) { - val acc = account - - viewModelScope.launch(Dispatchers.IO) { - val gameId = acceptEvent.gameId() ?: return@launch - - // Skip if game already exists or is being created (prevents overwrite) - if (_activeGames.value.containsKey(gameId)) return@launch - synchronized(gamesBeingCreated) { - if (!gamesBeingCreated.add(gameId)) return@launch // Already being created - } - - val opponentPubkey = acceptEvent.pubKey - - // Find our challenge to get player color - val challengeNote = - _challenges.value.find { note -> - (note.event as? LiveChessGameChallengeEvent)?.gameId() == gameId - } - val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent - val playerColor = challengeEvent?.playerColor() ?: Color.WHITE - - val engine = ChessEngine() - engine.reset() - - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = acc.signer.pubKey, - opponentPubkey = opponentPubkey, - playerColor = playerColor, - engine = engine, - ) - - _activeGames.value = _activeGames.value + (gameId to gameState) - synchronized(gamesBeingCreated) { - gamesBeingCreated.remove(gameId) - } - pollingDelegate.addGameId(gameId) - _selectedGameId.value = gameId - } - } - - /** - * Publish a move for the current game (simple version for UI callbacks) - */ - fun publishMove( - gameId: String, - from: String, - to: String, - ) { - val gameState = _activeGames.value[gameId] ?: return - - // Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN) - val (targetSquare, promotion) = parsePromotionFromTarget(to) - - val moveResult = gameState.makeMove(from, targetSquare, promotion) - if (moveResult != null) { - publishMove(gameId, moveResult) - } - } - - /** - * Parse promotion piece from target square string. - * e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null) - */ - private fun parsePromotionFromTarget(to: String): Pair { - if (to.length == 3) { - val square = to.substring(0, 2) - val promotion = - when (to[2].lowercaseChar()) { - 'q' -> PieceType.QUEEN - 'r' -> PieceType.ROOK - 'b' -> PieceType.BISHOP - 'n' -> PieceType.KNIGHT - else -> null - } - return square to promotion - } - return to to null - } - - /** - * Publish a move for the current game (full version with ChessMoveEvent) - */ - fun publishMove( - gameId: String, - moveEvent: ChessMoveEvent, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - // Update status to broadcasting - val relayCount = account.outboxRelays.flow.value.size - _chessStatus.value = - ChessStatus.BroadcastingMove( - san = moveEvent.san, - successCount = 0, - totalRelays = relayCount, - ) - - val template = - LiveChessMoveEvent.build( - gameId = moveEvent.gameId, - moveNumber = moveEvent.moveNumber, - san = moveEvent.san, - fen = moveEvent.fen, - opponentPubkey = moveEvent.opponentPubkey, - ) - - account.signAndComputeBroadcast(template) - - // Update status to success - _chessStatus.value = - ChessStatus.MoveSuccess( - san = moveEvent.san, - relayCount = relayCount, - ) - - // Clear status after delay - delay(3000) - if (_chessStatus.value is ChessStatus.MoveSuccess) { - val gameState = _activeGames.value[gameId] - _chessStatus.value = - if (gameState?.isPlayerTurn() == false) { - ChessStatus.WaitingForOpponent - } else { - ChessStatus.Idle - } - } - - _error.value = null - } catch (e: Exception) { - _chessStatus.value = - ChessStatus.MoveFailed( - san = moveEvent.san, - error = e.message ?: "Unknown error", - ) - _error.value = "Failed to publish move: ${e.message}" - } - } - } - - /** - * Handle incoming move from opponent - */ - fun handleOpponentMove(moveEvent: LiveChessMoveEvent) { - val gameId = moveEvent.gameId() ?: return - val gameState = _activeGames.value[gameId] ?: return - - val san = moveEvent.san() ?: return - val fen = moveEvent.fen() ?: return - - gameState.applyOpponentMove(san, fen) - updateBadgeCount() - } - - /** - * Resign from a game - */ - fun resign(gameId: String) { - val acc = account - val gameState = _activeGames.value[gameId] ?: return - - viewModelScope.launch(Dispatchers.IO) { - try { - val endData = gameState.resign() - - val template = - LiveChessGameEndEvent.build( - gameId = endData.gameId, - result = endData.result, - termination = endData.termination, - winnerPubkey = endData.winnerPubkey, - opponentPubkey = endData.opponentPubkey, - pgn = endData.pgn ?: "", - ) - - acc.signAndComputeBroadcast(template) - - // Remove from active games - _activeGames.value = _activeGames.value - gameId - _error.value = null - } catch (e: Exception) { - _error.value = "Failed to resign: ${e.message}" - } - } - } - - /** - * Offer a draw to opponent - sends a draw offer event that opponent can accept or decline - */ - fun offerDraw(gameId: String) { - val acc = account - val gameState = _activeGames.value[gameId] ?: return - - viewModelScope.launch(Dispatchers.IO) { - try { - val drawOffer = gameState.offerDraw() - - val template = - LiveChessDrawOfferEvent.build( - gameId = drawOffer.gameId, - opponentPubkey = drawOffer.opponentPubkey, - message = drawOffer.message ?: "", - ) - - acc.signAndComputeBroadcast(template) - _error.value = null - } catch (e: Exception) { - _error.value = "Failed to offer draw: ${e.message}" - } - } - } - - /** - * Select a game to view/play - */ - fun selectGame(gameId: String?) { - _selectedGameId.value = gameId - } - - /** - * Get game state for a specific game - */ - fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] - - /** - * Refresh challenges from local cache - * - * Following jesterui pattern: fetch ALL non-expired challenges, - * let UI filter by category (incoming, outgoing, open) - * - * NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables - */ - fun refreshChallenges() { - viewModelScope.launch(Dispatchers.IO) { - val userPubkey = account.signer.pubKey - val now = TimeUtils.now() - - // Query LocalCache.addressables for chess challenge events (kind 30064) - // Chess events are addressable events, not regular notes! - val challengeNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - - // Check if challenge is not expired (24h) - val createdAt = event.createdAt - if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false - - // Check if game isn't already active (accepted) - val gameId = event.gameId() ?: return@filterIntoSet false - if (_activeGames.value.containsKey(gameId)) return@filterIntoSet false - - // Include all challenges: - // - Directed at us - // - Open challenges (no specific opponent) - // - Our own challenges (waiting for accept) - val opponentPubkey = event.opponentPubkey() - val isDirectedAtUs = opponentPubkey == userPubkey - val isOpenChallenge = opponentPubkey == null - val isFromUs = event.pubKey == userPubkey - - isDirectedAtUs || isOpenChallenge || isFromUs - } - - _challenges.value = challengeNotes.toList() - updateBadgeCount() - - // Also refresh public games - refreshPublicGames() - } - } - - /** - * Refresh list of public games that can be spectated - * - * NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables - */ - private fun refreshPublicGames() { - val userPubkey = account.signer.pubKey - val now = TimeUtils.now() - - // Find all games where both challenge and accept exist - // Game ID -> (ChallengeEvent, AcceptEvent) - val gameData = mutableMapOf>() - - // Collect challenges from addressables (kind 30064) - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - val gameId = event.gameId() ?: return@filterIntoSet false - gameData[gameId] = (event to gameData[gameId]?.second) - false // Don't need to collect, just iterate - } - - // Collect accepts from addressables (kind 30065) - LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false - val gameId = event.gameId() ?: return@filterIntoSet false - gameData[gameId] = (gameData[gameId]?.first to event) - false - } - - // Filter to games that are active and user is NOT a participant - val publicGames = mutableListOf() - - for ((gameId, data) in gameData) { - val (challenge, accept) = data - if (challenge == null || accept == null) continue - - // Skip if user is a participant - val challengerPubkey = challenge.pubKey - val acceptorPubkey = accept.pubKey - if (challengerPubkey == userPubkey || acceptorPubkey == userPubkey) continue - - // Skip if already in our active/spectating games - if (_activeGames.value.containsKey(gameId) || _spectatingGames.value.containsKey(gameId)) continue - - // Determine white/black based on challenger color - val challengerColor = challenge.playerColor() ?: Color.WHITE - val (whitePubkey, blackPubkey) = - if (challengerColor == Color.WHITE) { - challengerPubkey to acceptorPubkey - } else { - acceptorPubkey to challengerPubkey - } - - // Count moves and get last move time from addressables (kind 30066) - var moveCount = 0 - var lastMoveTime = accept.createdAt - - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - val moveEvent = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false - if (moveEvent.gameId() != gameId) return@filterIntoSet false - moveCount++ - if (moveEvent.createdAt > lastMoveTime) { - lastMoveTime = moveEvent.createdAt - } - false - } - - // Skip inactive games (no moves in 24 hours) - if ((now - lastMoveTime) > CHALLENGE_EXPIRY_SECONDS) continue - - publicGames.add( - PublicGameInfo( - gameId = gameId, - whitePubkey = whitePubkey, - blackPubkey = blackPubkey, - moveCount = moveCount, - lastMoveTime = lastMoveTime, - ), - ) - } - - // Sort by most recent activity - _publicGames.value = publicGames.sortedByDescending { it.lastMoveTime } - } - - /** - * Load a game as spectator (watch-only mode) - * Returns the game state or null if loading failed - * - * NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables - */ - fun loadGameAsSpectator(gameId: String): LiveChessGameState? { - // Check if already spectating - _spectatingGames.value[gameId]?.let { return it } - - // Check if we're actually a participant (shouldn't load as spectator) - _activeGames.value[gameId]?.let { - _error.value = "You are a participant in this game" - return null - } - - val userPubkey = account.signer.pubKey - - // Find challenge and accept events from addressables - val challengeNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val acceptNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - - val challengeEvent = challengeNotes.firstOrNull()?.event as? LiveChessGameChallengeEvent - val acceptEvent = acceptNotes.firstOrNull()?.event as? LiveChessGameAcceptEvent - - if (challengeEvent == null) { - _error.value = "Challenge not found for game" - return null - } - - if (acceptEvent == null) { - _error.value = "Game not started yet - waiting for opponent" - return null - } - - // Determine white/black - val challengerPubkey = challengeEvent.pubKey - val acceptorPubkey = acceptEvent.pubKey - val challengerColor = challengeEvent.playerColor() ?: Color.WHITE - - val (whitePubkey, blackPubkey) = - if (challengerColor == Color.WHITE) { - challengerPubkey to acceptorPubkey - } else { - acceptorPubkey to challengerPubkey - } - - // Build engine state by replaying moves from addressables - val engine = ChessEngine() - engine.reset() - - val moveNotes = - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - - val sortedMoves = - moveNotes - .mapNotNull { it.event as? LiveChessMoveEvent } - .sortedBy { it.moveNumber() ?: Int.MAX_VALUE } - - for (moveEvent in sortedMoves) { - val san = moveEvent.san() ?: continue - try { - engine.makeMove(san) - } catch (e: Exception) { - _error.value = "Error loading move $san: ${e.message}" - } - } - - // Create spectator game state - always view from white's perspective - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = userPubkey, - opponentPubkey = blackPubkey, // Opponent relative to spectator view (white) - playerColor = Color.WHITE, // Spectators see from white's perspective - engine = engine, - isSpectator = true, - ) - - // Add to spectating games - _spectatingGames.value = _spectatingGames.value + (gameId to gameState) - pollingDelegate.addGameId(gameId) - - _error.value = null - return gameState - } - - /** - * Stop spectating a game - */ - fun stopSpectating(gameId: String) { - if (_spectatingGames.value.containsKey(gameId)) { - _spectatingGames.value = _spectatingGames.value - gameId - pollingDelegate.removeGameId(gameId) - } - } - - /** - * Update badge count based on incoming challenges and games where it's your turn - */ - private fun updateBadgeCount() { - val acc = account - val userPubkey = acc.signer.pubKey - - val incomingChallenges = - _challenges.value.count { note -> - val event = note.event as? LiveChessGameChallengeEvent - event?.opponentPubkey() == userPubkey - } - - val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } - - _badgeCount.value = incomingChallenges + yourTurnGames - } - - /** - * Clear error state - */ - fun clearError() { - _error.value = null - } - - /** - * Claim victory for an abandoned game - */ - fun claimAbandonmentVictory(gameId: String) { - val gameState = _activeGames.value[gameId] ?: return - - viewModelScope.launch(Dispatchers.IO) { - val endData = gameState.claimAbandonmentVictory() ?: return@launch - - val success = - retryWithBackoff("abandon-$gameId") { - val template = - LiveChessGameEndEvent.build( - gameId = endData.gameId, - result = endData.result, - termination = endData.termination, - winnerPubkey = endData.winnerPubkey, - opponentPubkey = endData.opponentPubkey, - pgn = endData.pgn ?: "", - ) - - account.signAndComputeBroadcast(template) - } - - if (success) { - _activeGames.value = _activeGames.value - gameId - _error.value = null - } else { - _error.value = "Failed to claim victory" - } - } - } - - /** - * Force resync a game to opponent's position - */ - fun forceResync( - gameId: String, - fen: String, - ) { - val gameState = _activeGames.value[gameId] ?: return - gameState.forceResync(fen) - } - - /** - * Retry an operation with exponential backoff - */ - private suspend fun retryWithBackoff( - operationId: String, - operation: suspend () -> Unit, - ): Boolean { - var attempt = 0 - while (attempt < MAX_RETRIES) { - try { - _pendingRetries.value = - _pendingRetries.value + (operationId to RetryOperation(operationId, attempt + 1, MAX_RETRIES)) - operation() - _pendingRetries.value = _pendingRetries.value - operationId - return true - } catch (e: Exception) { - attempt++ - if (attempt < MAX_RETRIES) { - delay(RETRY_DELAY_MS * attempt) - } - } - } - _pendingRetries.value = _pendingRetries.value - operationId - return false - } - - /** - * Generate unique game ID with human-readable component - */ - private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now()) - - /** - * Refresh game state from LocalCache for specific game IDs - * Called periodically by polling delegate - * - * NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables - */ - private suspend fun refreshGamesFromCache(gameIds: Set) { - val userPubkey = account.signer.pubKey - - for (gameId in gameIds) { - // Find moves for this game from addressables (kind 30066) - val moveNotes = - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - - // Check active games first - val activeGameState = _activeGames.value[gameId] - if (activeGameState != null) { - // Apply any new moves from opponent - moveNotes - .mapNotNull { it.event as? LiveChessMoveEvent } - .filter { it.pubKey != userPubkey } // Only opponent moves - .sortedBy { it.moveNumber() } - .forEach { moveEvent -> - val san = moveEvent.san() ?: return@forEach - val fen = moveEvent.fen() ?: return@forEach - val moveNumber = moveEvent.moveNumber() - activeGameState.applyOpponentMove(san, fen, moveNumber) - } - } - - // Check spectating games - apply ALL moves (from both players) - val spectatingGameState = _spectatingGames.value[gameId] - if (spectatingGameState != null) { - moveNotes - .mapNotNull { it.event as? LiveChessMoveEvent } - .sortedBy { it.moveNumber() } - .forEach { moveEvent -> - val san = moveEvent.san() ?: return@forEach - val fen = moveEvent.fen() ?: return@forEach - val moveNumber = moveEvent.moveNumber() - spectatingGameState.applyOpponentMove(san, fen, moveNumber) - } - } - - // Game is being polled but not yet active — accept event may have arrived since - // initial load. Retry loading from cache to pick up late-arriving accept events. - if (activeGameState == null && spectatingGameState == null) { - loadGameFromCache(gameId) - } - } - - updateBadgeCount() - } - - /** - * Load or rebuild game state from LocalCache for a specific gameId - * Used when navigating to a game screen - * - * NOTE: Chess events are addressable (kinds 30064+), so we query LocalCache.addressables - * - * @return GameLoadResult with either the game state or an error reason - */ - fun loadGameFromCache(gameId: String): LiveChessGameState? { - // Check if already loaded - _activeGames.value[gameId]?.let { return it } - - val userPubkey = account.signer.pubKey - - // Find the challenge event for this game from addressables (kind 30064) - val challengeNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameChallengeEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val challengeNote = challengeNotes.firstOrNull() - - // Find accept event for this game from addressables (kind 30065) - val acceptNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameAcceptEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val acceptNote = acceptNotes.firstOrNull() - - // Need challenge to understand the game - val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent - if (challengeEvent == null) { - _error.value = "Challenge event not found for game $gameId" - return null - } - - val acceptEvent = acceptNote?.event as? LiveChessGameAcceptEvent - - // Determine if we're a participant - val challengerPubkey = challengeEvent.pubKey - val acceptorPubkey = acceptEvent?.pubKey - val challengerColor = challengeEvent.playerColor() ?: Color.WHITE - - val (playerColor, opponentPubkey) = - when { - challengerPubkey == userPubkey && acceptorPubkey != null -> { - // We created the challenge, someone accepted - challengerColor to acceptorPubkey - } - acceptorPubkey == userPubkey -> { - // We accepted someone's challenge - challengerColor.opposite() to challengerPubkey - } - challengerPubkey == userPubkey && acceptorPubkey == null -> { - // We created challenge but no one accepted yet - show it anyway - // Use targeted opponent or empty string for open challenges - val targetedOpponent = challengeEvent.opponentPubkey() ?: "" - challengerColor to targetedOpponent - } - else -> { - _error.value = "You are not a participant in this game" - return null - } - } - - // Build game state - val engine = ChessEngine() - engine.reset() - - // Find and apply all moves in order from addressables (kind 30066) - val moveNotes = - LocalCache.addressables.filterIntoSet(LiveChessMoveEvent.KIND) { _, note -> - val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - - val sortedMoves = - moveNotes - .mapNotNull { it.event as? LiveChessMoveEvent } - .sortedBy { it.moveNumber() ?: Int.MAX_VALUE } - - // Track move numbers we've loaded - val loadedMoveNumbers = mutableSetOf() - - for (moveEvent in sortedMoves) { - val san = moveEvent.san() ?: continue - val moveNumber = moveEvent.moveNumber() - try { - engine.makeMove(san) - if (moveNumber != null) { - loadedMoveNumbers.add(moveNumber) - } - } catch (e: Exception) { - _error.value = "Error loading move $san: ${e.message}" - // Continue trying to load other moves - } - } - - // Only pending if we're the challenger, no accept event found, AND no moves exist - // (moves prove the game was accepted even if accept event isn't in cache) - val isPendingChallenge = challengerPubkey == userPubkey && acceptorPubkey == null && sortedMoves.isEmpty() - - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = userPubkey, - opponentPubkey = opponentPubkey, - playerColor = playerColor, - engine = engine, - isPendingChallenge = isPendingChallenge, - ) - - // Mark loaded moves as received to prevent re-application during refresh - gameState.markMovesAsReceived(loadedMoveNumbers) - - // Check for end event (kind 30067) — game may have been resigned/finished - val endNotes = - LocalCache.addressables.filterIntoSet(LiveChessGameEndEvent.KIND) { _, note -> - val event = note.event as? LiveChessGameEndEvent ?: return@filterIntoSet false - event.gameId() == gameId - } - val endEvent = endNotes.firstOrNull()?.event as? LiveChessGameEndEvent - if (endEvent != null) { - val result = GameResult.parse(endEvent.result() ?: "*") - gameState.markAsFinished(result) - } - - // Only add to active games if not pending (pending challenges are view-only) - if (!isPendingChallenge) { - _activeGames.value = _activeGames.value + (gameId to gameState) - } - - // Update polling delegate with this game - pollingDelegate.addGameId(gameId) - - // Clear any error since we loaded successfully - _error.value = null - - return gameState - } - - /** - * Called when ViewModel is cleared - */ - override fun onCleared() { - super.onCleared() - pollingDelegate.stop() - } -} - -/** - * Represents a pending retry operation for UI feedback - */ -data class RetryOperation( - val id: String, - val currentAttempt: Int, - val maxAttempts: Int, -) - -/** - * Info about a public game that can be spectated - */ -data class PublicGameInfo( - val gameId: String, - val whitePubkey: String, - val blackPubkey: String, - val moveCount: Int, - val lastMoveTime: Long, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt index 0fc24d44f..b8a048c99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -25,15 +25,16 @@ import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account /** - * Factory for creating ChessViewModel instances + * Factory for creating ChessViewModelNew instances. + * Uses the slim ViewModel that delegates to shared ChessLobbyLogic. */ class ChessViewModelFactory( private val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(ChessViewModel::class.java)) { - return ChessViewModel(account) as T + if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { + return ChessViewModelNew(account) as T } throw IllegalArgumentException("Unknown ViewModel class") } 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 d97f71132..478cef480 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 @@ -26,15 +26,19 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus import com.vitorpamplona.amethyst.commons.chess.CompletedGame import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent import kotlinx.coroutines.flow.StateFlow /** - * Slim Android ViewModel for chess (~130 lines). + * Slim Android ViewModel for chess (~120 lines). * * Delegates all business logic to ChessLobbyLogic. * Only handles Android-specific concerns: @@ -45,6 +49,13 @@ import kotlinx.coroutines.flow.StateFlow class ChessViewModelNew( private val account: Account, ) : ViewModel() { + // Instance ID for debugging ViewModel sharing + val instanceId = System.identityHashCode(this) + + init { + println("[ChessViewModelNew] Created instance $instanceId for ${account.userProfile().pubkeyHex.take(8)}") + } + // Platform adapters private val publisher = AndroidChessPublisher(account) private val fetcher = AndroidRelayFetcher(account) @@ -73,6 +84,9 @@ class ChessViewModelNew( val broadcastStatus: StateFlow = logic.state.broadcastStatus val error: StateFlow = logic.state.error val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val stateVersion: StateFlow = logic.state.stateVersion + val syncStatus: StateFlow = logic.state.syncStatus /** Badge count (incoming challenges + your turn games) - computed property */ val badgeCount: Int get() = logic.state.badgeCount @@ -91,11 +105,39 @@ class ChessViewModelNew( fun forceRefresh() = logic.forceRefresh() + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + override fun onCleared() { super.onCleared() logic.stopPolling() } + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } + // ============================================ // Challenge operations // ============================================ @@ -108,6 +150,8 @@ class ChessViewModelNew( fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + // ============================================ // Game operations // ============================================ @@ -122,12 +166,6 @@ class ChessViewModelNew( fun resign(gameId: String) = logic.resign(gameId) - fun offerDraw(gameId: String) = logic.offerDraw(gameId) - - fun acceptDraw(gameId: String) = logic.acceptDraw(gameId) - - fun declineDraw(gameId: String) = logic.declineDraw(gameId) - fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) // ============================================ @@ -146,6 +184,11 @@ class ChessViewModelNew( fun clearError() = logic.clearError() + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + /** Helper for derived challenge lists */ fun incomingChallenges(): List = logic.state.incomingChallenges() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index b56431d64..b10d7918d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -33,8 +33,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew /** * Floating action button for creating new chess game challenges @@ -46,9 +46,9 @@ fun NewChessGameButton( ) { var showDialog by remember { mutableStateOf(false) } - val chessViewModel: ChessViewModel = + val chessViewModel: ChessViewModelNew = viewModel( - key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", factory = ChessViewModelFactory(accountViewModel.account), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt new file mode 100644 index 000000000..4268e4be8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Global registry of accepted game IDs. + * + * This singleton ensures that accepted game state is shared across all ViewModel instances. + * Without this, different ViewModels (e.g., lobby vs game screen) would have separate + * acceptedGameIds sets, causing the game screen to incorrectly load as spectator. + */ +object AcceptedGamesRegistry { + private val acceptedGameIds = mutableSetOf() + + fun markAsAccepted(gameId: String) { + acceptedGameIds.add(gameId) + } + + fun wasAccepted(gameId: String): Boolean = acceptedGameIds.contains(gameId) + + fun clear() { + acceptedGameIds.clear() + } + + /** Remove old entries - call periodically to prevent memory leak */ + fun clearOldEntries(keepGameIds: Set) { + acceptedGameIds.retainAll(keepGameIds) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt new file mode 100644 index 000000000..fb3f2061a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Global chess configuration shared across Android and Desktop. + * + * These relays are the primary relays used by Jester and other Nostr chess apps. + * Using a small, fixed set ensures fast queries and reliable game discovery. + */ +object ChessConfig { + /** + * The 3 main relays for chess events. + * These are used for both fetching and publishing chess events. + */ + val CHESS_RELAYS = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + ) + + /** + * Display names for the chess relays (without protocol prefix) + */ + val CHESS_RELAY_NAMES = + listOf( + "relay.damus.io", + "nos.lol", + "relay.primal.net", + ) + + /** + * Timeout for relay queries in milliseconds. + * With only 3 relays, we can wait for all of them. + */ + const val FETCH_TIMEOUT_MS = 10_000L +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt new file mode 100644 index 000000000..1f26d3409 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -0,0 +1,266 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Collects and aggregates Jester chess events for a game from any source. + * + * This class provides a unified way to collect events regardless of whether + * they come from LocalCache (Android), direct relay (Desktop), or test fixtures. + * It handles: + * - Deduplication by event ID + * - Thread-safe concurrent updates + * - Producing JesterGameEvents snapshots for reconstruction + * + * Jester Protocol: + * - All chess events use kind 30 + * - Start events (content.kind=0) reference START_POSITION_HASH via e-tag + * - Move events (content.kind=1) reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * + * Usage: + * ``` + * val collector = ChessEventCollector(startEventId) + * + * // Add events as they arrive from any source + * collector.addEvent(jesterEvent) + * + * // Get current state for reconstruction + * val events = collector.getEvents() + * val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + * ``` + */ +class ChessEventCollector( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, +) { + // Start event (only one per game) + private val _startEvent = MutableStateFlow(null) + val startEvent: StateFlow = _startEvent.asStateFlow() + + // Move events (deduplicated by event ID) + private val moves = ConcurrentHashMap() + + // Track all processed event IDs for fast deduplication + private val processedEventIds = ConcurrentHashMap.newKeySet() + + // Flow that emits when any event is added (for reactive updates) + private val _eventCount = MutableStateFlow(0) + val eventCount: StateFlow = _eventCount.asStateFlow() + + /** + * Check if an event has already been processed. + */ + fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId) + + /** + * Add a Jester event for this game. + * Automatically categorizes as start or move based on content.kind. + * + * @return true if the event was added, false if already exists or invalid + */ + fun addEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + + // Check if this event belongs to our game + val eventStartId = event.startEventId() + val isStartEvent = event.isStartEvent() && event.id == startEventId + val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId + + if (!isStartEvent && !isMoveEvent) return false + + return if (isStartEvent) { + addStartEvent(event) + } else { + addMoveEvent(event) + } + } + + /** + * Add a raw Event if it's a valid Jester event for this game. + * + * @return true if the event was added, false if invalid or not for this game + */ + fun addRawEvent(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + val jesterEvent = event.toJesterEvent() ?: return false + return addEvent(jesterEvent) + } + + /** + * Add the start event for this game. + * Only the first valid start is kept. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addStartEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isStartEvent()) return false + if (event.id != startEventId) return false + + if (_startEvent.compareAndSet(null, event)) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Add a move event for this game. + * Moves are deduplicated by event ID. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addMoveEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isMoveEvent()) return false + if (event.startEventId() != startEventId) return false + + if (moves.putIfAbsent(event.id, event) == null) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Get a snapshot of all collected events for reconstruction. + */ + fun getEvents(): JesterGameEvents = + JesterGameEvents( + startEvent = _startEvent.value, + moves = moves.values.toList(), + ) + + /** + * Check if the game has a start event. + */ + fun hasStartEvent(): Boolean = _startEvent.value != null + + /** + * Check if the game has any moves (indicates game is active). + */ + fun hasMoves(): Boolean = moves.isNotEmpty() + + /** + * Check if the game has ended (has a move with result). + */ + fun hasEnded(): Boolean = moves.values.any { it.result() != null } + + /** + * Get the number of moves collected. + */ + fun moveCount(): Int = moves.size + + /** + * Get the latest move (with longest history). + */ + fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size } + + /** + * Clear all collected events. + */ + fun clear() { + _startEvent.value = null + moves.clear() + processedEventIds.clear() + _eventCount.value = 0 + } + + private fun incrementEventCount() { + _eventCount.value = processedEventIds.size + } +} + +/** + * Manager for multiple game collectors. + * + * This is useful when managing multiple concurrent games, + * such as in a chess lobby or when spectating multiple games. + */ +class ChessEventCollectorManager { + private val collectors = ConcurrentHashMap() + + /** + * Get or create a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) } + + /** + * Get a collector if it exists. + * + * @param startEventId The start event ID (game identifier) + */ + fun get(startEventId: String): ChessEventCollector? = collectors[startEventId] + + /** + * Remove a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun remove(startEventId: String): ChessEventCollector? = collectors.remove(startEventId) + + /** + * Get all active game IDs (start event IDs). + */ + fun activeGameIds(): Set = collectors.keys.toSet() + + /** + * Clear all collectors. + */ + fun clear() { + collectors.values.forEach { it.clear() } + collectors.clear() + } + + /** + * Add an event to the appropriate collector (auto-routing). + * Creates a collector if needed for start events. + * + * @return true if the event was added to a collector + */ + fun addEvent(event: JesterEvent): Boolean { + // For start events, create collector with event ID + if (event.isStartEvent()) { + val collector = getOrCreate(event.id) + return collector.addEvent(event) + } + + // For move events, find the collector by startEventId + val startId = event.startEventId() ?: return false + val collector = collectors[startId] ?: return false + return collector.addEvent(event) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt index 1568d897a..8a81bfbe0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -49,19 +49,19 @@ data class ChessPollingConfig( * Platform-specific defaults */ object ChessPollingDefaults { - /** Android: shorter intervals, no background polling */ + /** Android: moderate intervals, no background polling */ val android = ChessPollingConfig( - activeGamePollInterval = 10_000L, - challengePollInterval = 30_000L, + activeGamePollInterval = 5_000L, // 5 seconds for responsive gameplay + challengePollInterval = 15_000L, // 15 seconds for challenges pollInBackground = false, ) - /** Desktop: can poll in background */ + /** Desktop: fast polling for responsive gameplay */ val desktop = ChessPollingConfig( - activeGamePollInterval = 10_000L, - challengePollInterval = 30_000L, + activeGamePollInterval = 2_000L, // 2 seconds for fast updates + challengePollInterval = 10_000L, // 10 seconds for challenges pollInBackground = true, ) } @@ -94,11 +94,52 @@ class ChessPollingDelegate( private var gamePollingJob: Job? = null private var challengePollingJob: Job? = null private var cleanupJob: Job? = null + private var manualRefreshJob: Job? = null private val _isPolling = MutableStateFlow(false) val isPolling: StateFlow = _isPolling.asStateFlow() - private val activeGameIdsFlow = MutableStateFlow>(emptySet()) + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + internal val activeGameIdsFlow = MutableStateFlow>(emptySet()) + + /** + * Focused game ID - when set, only this game is polled for updates. + * Used when viewing a specific game screen to avoid refreshing unrelated games. + * When null, all games in activeGameIdsFlow are polled (lobby mode). + */ + private val _focusedGameId = MutableStateFlow(null) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen. + * Pass null to return to lobby mode (poll all games). + */ + fun setFocusedGame(gameId: String?) { + _focusedGameId.value = gameId + } + + /** + * Get current focused game ID (null = lobby mode, polls all games) + */ + fun getFocusedGameId(): String? = _focusedGameId.value + + /** + * Get the effective game IDs to poll based on focused mode. + * In focused mode: only the focused game. + * In lobby mode: all active games. + */ + private fun getEffectiveGameIds(): Set { + val focused = _focusedGameId.value + return if (focused != null) { + // Focused mode - only poll this game + setOf(focused) + } else { + // Lobby mode - poll all games + activeGameIdsFlow.value + } + } /** * Update the set of game IDs to poll for @@ -125,30 +166,41 @@ class ChessPollingDelegate( * Start polling for chess events */ fun start() { - if (_isPolling.value) return + if (_isPolling.value) { + return + } _isPolling.value = true // Poll for active games gamePollingJob = scope.launch { while (isActive) { - val gameIds = activeGameIdsFlow.value + val gameIds = getEffectiveGameIds() if (gameIds.isNotEmpty()) { - onRefreshGames(gameIds) + try { + onRefreshGames(gameIds) + } catch (_: Exception) { + // Error during refresh - continue polling + } } delay(config.activeGamePollInterval) } } - // Poll for challenges + // Poll for challenges (only in lobby mode) challengePollingJob = scope.launch { - // Initial fetch - onRefreshChallenges() + // Initial fetch (only if not in focused mode) + if (_focusedGameId.value == null) { + onRefreshChallenges() + } while (isActive) { delay(config.challengePollInterval) - onRefreshChallenges() + // Skip challenge polling in focused mode - game screen doesn't need it + if (_focusedGameId.value == null) { + onRefreshChallenges() + } } } @@ -194,16 +246,34 @@ class ChessPollingDelegate( } /** - * Force an immediate refresh + * Force an immediate refresh (debounced - ignores calls if refresh already in progress) */ fun refreshNow() { - scope.launch { - onRefreshChallenges() - val gameIds = activeGameIdsFlow.value - if (gameIds.isNotEmpty()) { - onRefreshGames(gameIds) - } + // Debounce: skip if already refreshing + if (_isRefreshing.value) { + return } + + // Cancel any pending manual refresh + manualRefreshJob?.cancel() + + manualRefreshJob = + scope.launch { + _isRefreshing.value = true + try { + // In focused mode, skip challenge refresh (game screen doesn't need it) + val focusedId = _focusedGameId.value + if (focusedId == null) { + onRefreshChallenges() + } + val gameIds = getEffectiveGameIds() + if (gameIds.isNotEmpty()) { + onRefreshGames(gameIds) + } + } finally { + _isRefreshing.value = false + } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt new file mode 100644 index 000000000..e3458667c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult +import com.vitorpamplona.quartz.nip64Chess.ViewerRole + +/** + * Converts a ReconstructedGameState (from ChessStateReconstructor) into a + * LiveChessGameState (used by ViewModels for reactive updates). + * + * This bridges the gap between the deterministic reconstruction algorithm + * and the stateful game management that ViewModels need. + * + * Usage: + * ``` + * // Collect events from any source + * val collector = ChessEventCollector(startEventId) + * collector.addEvent(startEvent) + * collector.addEvent(moveEvent1) + * collector.addEvent(moveEvent2) + * + * // Reconstruct using shared algorithm + * val result = ChessStateReconstructor.reconstruct(collector.getEvents(), viewerPubkey) + * + * // Convert to LiveChessGameState for ViewModel use + * val gameState = ChessGameLoader.toLiveGameState(result, viewerPubkey) + * ``` + */ +object ChessGameLoader { + /** + * Convert a ReconstructionResult to a LiveChessGameState for ViewModel use. + * + * @param result The result from ChessStateReconstructor + * @param viewerPubkey The pubkey of the user viewing the game + * @return LiveChessGameState or null if reconstruction failed + */ + fun toLiveGameState( + result: ReconstructionResult, + viewerPubkey: String, + ): LiveChessGameState? { + if (result !is ReconstructionResult.Success) return null + + val state = result.state + val engine = result.engine + + val (playerPubkey, opponentPubkey) = + when (state.viewerRole) { + ViewerRole.WHITE_PLAYER -> viewerPubkey to (state.blackPubkey ?: "") + ViewerRole.BLACK_PLAYER -> viewerPubkey to (state.whitePubkey ?: "") + ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "") + } + + return LiveChessGameState( + startEventId = state.startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = state.playerColor, + engine = engine, + createdAt = state.challengeCreatedAt, + isSpectator = state.viewerRole == ViewerRole.SPECTATOR, + isPendingChallenge = state.isPendingChallenge, + initialHeadEventId = state.headEventId, + ).also { gameState -> + // Mark all loaded moves as received to prevent re-application during polling + gameState.markMovesAsReceived(state.appliedMoveNumbers) + + // Mark finished if the game has ended + if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { + gameState.markAsFinished( + (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result, + ) + } + + // Handle pending draw offer + val drawOfferer = state.pendingDrawOffer + if (drawOfferer != null && drawOfferer != viewerPubkey) { + gameState.receiveDrawOffer(drawOfferer) + } + } + } + + /** + * Load a game using the deterministic reconstruction algorithm. + * + * @param events Collected Jester events for the game + * @param viewerPubkey The pubkey of the user viewing the game + * @return Pair of (LiveChessGameState, ReconstructedGameState) or null if failed + */ + fun loadGame( + events: JesterGameEvents, + viewerPubkey: String, + ): LoadGameResult { + val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + + return when (result) { + is ReconstructionResult.Success -> { + val liveState = toLiveGameState(result, viewerPubkey) + if (liveState != null) { + LoadGameResult.Success(liveState, result.state) + } else { + LoadGameResult.Error("Failed to convert reconstructed state to live state") + } + } + is ReconstructionResult.Error -> LoadGameResult.Error(result.message) + } + } + + /** + * Create a new game state without any events (for when accepting a challenge locally). + * + * @param startEventId The start event ID (game identifier) + * @param playerPubkey The player's pubkey + * @param opponentPubkey The opponent's pubkey + * @param playerColor The player's color + * @return A fresh LiveChessGameState + */ + fun createNewGame( + startEventId: String, + playerPubkey: String, + opponentPubkey: String, + playerColor: Color, + isPendingChallenge: Boolean = false, + ): LiveChessGameState { + val engine = ChessEngine() + engine.reset() + + return LiveChessGameState( + startEventId = startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + isPendingChallenge = isPendingChallenge, + ) + } +} + +/** + * Result of loading a game. + */ +sealed class LoadGameResult { + data class Success( + val liveState: LiveChessGameState, + val reconstructedState: ReconstructedGameState, + ) : LoadGameResult() + + data class Error( + val message: String, + ) : LoadGameResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): LiveChessGameState? = (this as? Success)?.liveState +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt new file mode 100644 index 000000000..b070673e2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -0,0 +1,339 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator + +// Color constants for card borders +private val IncomingChallengeColor = Color(0xFFFF9800) // Orange +private val OpenChallengeColor = Color(0xFF4CAF50) // Green +private val SpectatingColor = Color(0xFF9C27B0) // Purple +private val LiveGameColor = Color(0xFF2196F3) // Blue + +/** + * Card for an active game where the user is a participant + */ +@Composable +fun ActiveGameCard( + gameId: String, + opponentName: String, + isYourTurn: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12) + } + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = + if (isYourTurn) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + gameName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyMedium, + ) + } + + Text( + if (isYourTurn) "Your turn" else "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +/** + * Card for an incoming or open challenge + */ +@Composable +fun ChallengeCard( + challengerName: String, + challengerPlaysWhite: Boolean, + isIncoming: Boolean, + onAccept: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val borderColor = if (isIncoming) IncomingChallengeColor else OpenChallengeColor + + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(2.dp, borderColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Challenger plays ${if (challengerPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button(onClick = onAccept) { + Text("Accept") + } + } + } +} + +/** + * Card for user's outgoing challenge (waiting for acceptance) + * Clickable so user can open the game board and make the first move when ready + */ +@Composable +fun OutgoingChallengeCard( + opponentName: String?, + userPlaysWhite: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (opponentName != null) { + "Challenge to $opponentName" + } else { + "Open challenge (awaiting opponent)" + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "You play ${if (userPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Card for a game the user is spectating + */ +@Composable +fun SpectatingGameCard( + moveCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(1.dp, SpectatingColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Watching game", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves played", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Spectating", + style = MaterialTheme.typography.bodyMedium, + color = SpectatingColor, + fontWeight = FontWeight.Medium, + ) + } + } +} + +/** + * Card for a public live game that can be watched + */ +@Composable +fun PublicGameCard( + whiteName: String, + blackName: String, + moveCount: Int, + onWatch: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(1.dp, LiveGameColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "$whiteName vs $blackName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedButton(onClick = onWatch) { + Text("Watch") + } + } + } +} + +/** + * Card for a completed game in history + */ +@Composable +fun CompletedGameCard( + opponentName: String, + result: String, + didUserWin: Boolean, + isDraw: Boolean, + moveCount: Int, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val resultText = + when { + isDraw -> "Draw" + didUserWin -> "Won" + else -> "Lost" + } + + val resultColor = + when { + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + didUserWin -> Color(0xFF4CAF50) // Green + else -> Color(0xFFF44336) // Red + } + + Card( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves • $result", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) + } + } +} 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 cedbdb951..32e1b4311 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 @@ -20,84 +20,94 @@ */ package com.vitorpamplona.amethyst.commons.chess -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.UUID /** - * Interface for platform-specific chess event publishing + * Interface for platform-specific chess event publishing using Jester protocol. + * + * All chess events use kind 30 with JSON content: + * - Start events: content.kind=0 + * - Move events: content.kind=1 with full history */ interface ChessEventPublisher { - suspend fun publishChallenge( - gameId: String, + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + suspend fun publishStart( playerColor: Color, opponentPubkey: String?, - timeControl: String?, - ): Boolean + ): String? - suspend fun publishAccept( - gameId: String, - challengeEventId: String, - challengerPubkey: String, - ): Boolean - - suspend fun publishMove(move: ChessMoveEvent): Boolean + /** + * Publish a move event. + * Move events include full history and link to previous move. + */ + suspend fun publishMove(move: ChessMoveEvent): String? + /** + * Publish a game end event (includes result in content). + */ suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean - suspend fun publishDrawOffer( - gameId: String, - opponentPubkey: String, - message: String?, - ): Boolean - + /** + * Get count of write relays for UI feedback. + */ fun getWriteRelayCount(): Int } /** - * Relay-first fetcher interface. Platforms provide one-shot relay queries. + * Relay-first fetcher interface for Jester protocol. + * Platforms provide one-shot relay queries. * * Every fetch does: one-shot REQ → collect events → EOSE → close. * No caching — relays are the single source of truth. */ interface ChessRelayFetcher { - /** Fetch all events for a specific game */ - suspend fun fetchGameEvents(gameId: String): ChessGameEvents + /** Fetch all events for a specific game by startEventId */ + suspend fun fetchGameEvents(startEventId: String): JesterGameEvents - /** Fetch recent challenge events */ - suspend fun fetchChallenges(): List + /** Fetch recent start/challenge events with optional progress callback */ + suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)? = null): List /** Fetch recent public game summaries for spectating */ suspend fun fetchRecentGames(): List + + /** Fetch game IDs (startEventIds) where user is a participant */ + suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set + + /** Get the list of relay URLs that will be used for fetching */ + fun getRelayUrls(): List } /** * Summary of a game found on relays (for lobby display / spectating) */ data class RelayGameSummary( - val gameId: String, + val startEventId: String, val whitePubkey: String, val blackPubkey: String, val moveCount: Int, val lastMoveTime: Long, val isActive: Boolean, -) +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} /** - * Shared chess lobby logic — relay-first architecture. + * Shared chess lobby logic — relay-first architecture with Jester protocol. * * Both Android and Desktop use this identically. * Platform-specific code only implements: @@ -128,38 +138,67 @@ class ChessLobbyLogic( // Lifecycle // ======================================== - fun startPolling() = pollingDelegate.start() + fun startPolling() { + pollingDelegate.start() + } - fun stopPolling() = pollingDelegate.stop() + fun stopPolling() { + pollingDelegate.stop() + } - fun forceRefresh() = pollingDelegate.refreshNow() + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen to guarantee polling is active for that game. + */ + fun ensureGamePolling(gameId: String) { + pollingDelegate.addGameId(gameId) + } + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + * Also ensures the game is in the polling set. + */ + fun setFocusedGame(gameId: String) { + pollingDelegate.addGameId(gameId) + pollingDelegate.setFocusedGame(gameId) + } + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() { + pollingDelegate.setFocusedGame(null) + } + + fun forceRefresh() { + pollingDelegate.refreshNow() + } // ======================================== // Incoming event routing (real-time / optimistic) // ======================================== /** - * Route an incoming relay event to the appropriate handler. + * Route an incoming Jester event to the appropriate handler. * Called by platform subscription callbacks for real-time updates. */ - fun handleIncomingEvent(event: Event) { - when (event) { - is LiveChessGameChallengeEvent -> handleChallenge(event) - is LiveChessGameAcceptEvent -> handleAccept(event) - is LiveChessMoveEvent -> handleMove(event) - is LiveChessGameEndEvent -> handleGameEnd(event) - is LiveChessDrawOfferEvent -> handleDrawOffer(event) + fun handleIncomingEvent(event: JesterEvent) { + when { + event.isStartEvent() -> handleStartEvent(event) + event.isMoveEvent() -> handleMoveEvent(event) } } - private fun handleChallenge(event: LiveChessGameChallengeEvent) { - val gameId = event.gameId() ?: return + private fun handleStartEvent(event: JesterEvent) { + val startEventId = event.id val challengerColor = event.playerColor() ?: Color.WHITE val challenge = ChessChallenge( eventId = event.id, - gameId = gameId, + gameId = startEventId, // In Jester, gameId = startEventId challengerPubkey = event.pubKey, challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), @@ -170,54 +209,51 @@ class ChessLobbyLogic( state.addChallenge(challenge) } - private fun handleAccept(event: LiveChessGameAcceptEvent) { - val gameId = event.gameId() ?: return - - // If this is an accept for our challenge, auto-load the game - val ourChallenge = state.outgoingChallenges().find { it.gameId == gameId } - if (ourChallenge != null) { - handleGameAccepted(gameId) - } - } - - private fun handleMove(event: LiveChessMoveEvent) { - val gameId = event.gameId() ?: return - val san = event.san() ?: return + private fun handleMoveEvent(event: JesterEvent) { + val startEventId = event.startEventId() ?: return + val san = event.move() ?: return val fen = event.fen() ?: return - val moveNumber = event.moveNumber() + val history = event.history() + val moveNumber = history.size - val gameState = state.getGameState(gameId) ?: return + // Check if this is our game (we're either the author or tagged as opponent) + val opponentFromTag = event.opponentPubkey() + val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey + + var gameState = state.getGameState(startEventId) + + // If this is our game but we haven't loaded it yet, load it now + // This happens when someone accepts our challenge (makes first move) + if (gameState == null && isOurGame) { + handleGameAccepted(startEventId) + return // handleGameAccepted will load the game and poll for events + } + + if (gameState == null) return + + // Check for game end + val result = event.result() + 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 + else -> null + } + if (gameResult != null) { + gameState.markAsFinished(gameResult) + state.moveToCompleted(startEventId, result, event.termination()) + pollingDelegate.removeGameId(startEventId) + return + } + } // Only apply opponent moves optimistically if (event.pubKey != userPubkey) { gameState.applyOpponentMove(san, fen, moveNumber) - } - } - - private fun handleGameEnd(event: LiveChessGameEndEvent) { - val gameId = event.gameId() ?: return - val gameState = state.getGameState(gameId) ?: return - - val resultStr = event.result() - val result = - when (resultStr) { - "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 - else -> return - } - - gameState.markAsFinished(result) - state.moveToCompleted(gameId, result.notation, event.termination()) - pollingDelegate.removeGameId(gameId) - } - - private fun handleDrawOffer(event: LiveChessDrawOfferEvent) { - val gameId = event.gameId() ?: return - val gameState = state.getGameState(gameId) ?: return - - if (event.pubKey != userPubkey) { - gameState.receiveDrawOffer(event.pubKey) + // Update head event ID for move linking + gameState.updateHeadEventId(event.id) } } @@ -228,10 +264,8 @@ class ChessLobbyLogic( fun createChallenge( opponentPubkey: String? = null, playerColor: Color = Color.WHITE, - timeControl: String? = null, + timeControl: String? = null, // Not supported in Jester, kept for API compatibility ) { - val gameId = generateGameId() - scope.launch(Dispatchers.Default) { state.setBroadcastStatus( ChessBroadcastStatus.Broadcasting( @@ -241,9 +275,24 @@ class ChessLobbyLogic( ), ) - val success = retryWithBackoff { publisher.publishChallenge(gameId, playerColor, opponentPubkey, timeControl) } + val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) } + + if (startEventId != null) { + // Add challenge to local state - shows in "Your Challenges" section + val challenge = + ChessChallenge( + eventId = startEventId, + gameId = startEventId, + challengerPubkey = userPubkey, + challengerDisplayName = metadataProvider.getDisplayName(userPubkey), + challengerAvatarUrl = metadataProvider.getPictureUrl(userPubkey), + opponentPubkey = opponentPubkey, + challengerColor = playerColor, + createdAt = TimeUtils.now(), + ) + state.addChallenge(challenge) + pollingDelegate.addGameId(startEventId) - if (success) { state.setBroadcastStatus( ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()), ) @@ -259,51 +308,84 @@ class ChessLobbyLogic( } } + /** + * Accept a challenge by loading the game and making the first move (if we're black) + * or waiting for opponent's move (if we're white). + * + * In Jester protocol, acceptance is implicit - we just track the game locally. + */ fun acceptChallenge(challenge: ChessChallenge) { - scope.launch(Dispatchers.Default) { - val success = - retryWithBackoff { - publisher.publishAccept( - gameId = challenge.gameId, - challengeEventId = challenge.eventId, - challengerPubkey = challenge.challengerPubkey, - ) - } + // Mark as accepted SYNCHRONOUSLY before launching coroutine + // This prevents race where navigation happens before coroutine runs, + // which would cause loadGame() to incorrectly mark as spectator + state.markAsAccepted(challenge.gameId) - if (success) { - val playerColor = challenge.challengerColor.opposite() - val gameState = - ChessGameLoader.createNewGame( - gameId = challenge.gameId, - playerPubkey = userPubkey, - opponentPubkey = challenge.challengerPubkey, - playerColor = playerColor, - ) - state.addActiveGame(challenge.gameId, gameState) - pollingDelegate.addGameId(challenge.gameId) - state.selectGame(challenge.gameId) - state.setError(null) - } else { - state.setError("Failed to accept challenge") - } + state.removeChallenge(challenge.gameId) + + // Add to polling delegate FIRST - before adding to activeGames + // This prevents race where Compose sees the game but forceRefresh() doesn't include it + pollingDelegate.addGameId(challenge.gameId) + + scope.launch(Dispatchers.Default) { + val playerColor = challenge.challengerColor.opposite() + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, // gameId = startEventId in Jester + playerPubkey = userPubkey, + opponentPubkey = challenge.challengerPubkey, + playerColor = playerColor, + ) + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + state.setError(null) + + // Immediately fetch game from relays to load any existing moves + // This ensures moves are loaded without waiting for polling interval + refreshGame(challenge.gameId) } } /** - * When we detect our challenge was accepted, load game from relays. + * Open user's own outgoing challenge to view the board and make moves. + * Creates game state and navigates to game view. */ - fun handleGameAccepted(gameId: String) { + fun openOwnChallenge(challenge: ChessChallenge) { + // Add to polling delegate FIRST - before adding to activeGames + pollingDelegate.addGameId(challenge.gameId) + + // Create game state with user's chosen color + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, + playerPubkey = userPubkey, + opponentPubkey = challenge.opponentPubkey ?: "", + playerColor = challenge.challengerColor, + isPendingChallenge = true, + ) + + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + + // Fetch from relays in case opponent has already made moves + scope.launch(Dispatchers.Default) { + refreshGame(challenge.gameId) + } + } + + /** + * When we detect our challenge was accepted (opponent made first move), load game from relays. + */ + fun handleGameAccepted(startEventId: String) { scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) - val events = fetcher.fetchGameEvents(gameId) + val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - state.addActiveGame(gameId, result.liveState) - pollingDelegate.addGameId(gameId) - state.selectGame(gameId) + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) state.setError(null) } @@ -320,18 +402,21 @@ class ChessLobbyLogic( // ======================================== fun publishMove( - gameId: String, + startEventId: String, from: String, to: String, ) { - val gameState = state.getGameState(gameId) ?: return + val gameState = state.getGameState(startEventId) ?: return - if (state.isSpectating(gameId)) { + if (state.isSpectating(startEventId)) { state.setError("Cannot move while spectating") return } - val moveResult = gameState.makeMove(from, to) ?: return + // Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN) + val (actualTo, promotion) = parsePromotionFromTo(to) + + val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return scope.launch(Dispatchers.Default) { state.setBroadcastStatus( @@ -342,15 +427,18 @@ class ChessLobbyLogic( ), ) - val success = retryWithBackoff { publisher.publishMove(moveResult) } + val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) } + + if (newEventId != null) { + // Update head event ID for next move linking + gameState.updateHeadEventId(newEventId) - if (success) { state.setBroadcastStatus( ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()), ) delay(3000) - val currentState = state.getGameState(gameId) + val currentState = state.getGameState(startEventId) state.setBroadcastStatus( if (currentState?.isPlayerTurn() == false) { ChessBroadcastStatus.WaitingForOpponent @@ -360,18 +448,21 @@ class ChessLobbyLogic( ) state.setError(null) } else { + // Revert the move since publishing failed + gameState.undoLastMove() + state.setBroadcastStatus( ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"), ) - state.setError("Failed to publish move") + state.setError("Failed to publish move - move reverted") } } } - fun resign(gameId: String) { - val gameState = state.getGameState(gameId) ?: return + fun resign(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return - if (state.isSpectating(gameId)) { + if (state.isSpectating(startEventId)) { state.setError("Cannot resign while spectating") return } @@ -381,8 +472,8 @@ class ChessLobbyLogic( val success = retryWithBackoff { publisher.publishGameEnd(endData) } if (success) { - state.moveToCompleted(gameId, endData.result.notation, endData.termination.name.lowercase()) - pollingDelegate.removeGameId(gameId) + state.moveToCompleted(startEventId, endData.result.notation, endData.termination.name.lowercase()) + pollingDelegate.removeGameId(startEventId) state.setError(null) } else { state.setError("Failed to resign") @@ -390,65 +481,16 @@ class ChessLobbyLogic( } } - fun offerDraw(gameId: String) { - val gameState = state.getGameState(gameId) ?: return - - if (state.isSpectating(gameId)) { - state.setError("Cannot offer draw while spectating") - return - } - - scope.launch(Dispatchers.Default) { - val drawOffer = gameState.offerDraw() - val success = - retryWithBackoff { - publisher.publishDrawOffer( - gameId = drawOffer.gameId, - opponentPubkey = drawOffer.opponentPubkey, - message = drawOffer.message, - ) - } - - if (success) { - state.setError(null) - } else { - state.setError("Failed to offer draw") - } - } - } - - fun acceptDraw(gameId: String) { - val gameState = state.getGameState(gameId) ?: return - val endData = gameState.acceptDraw() ?: return - - scope.launch(Dispatchers.Default) { - val success = retryWithBackoff { publisher.publishGameEnd(endData) } - - if (success) { - state.moveToCompleted(gameId, endData.result.notation, "draw_agreement") - pollingDelegate.removeGameId(gameId) - state.setError(null) - } else { - state.setError("Failed to accept draw") - } - } - } - - fun declineDraw(gameId: String) { - val gameState = state.getGameState(gameId) ?: return - gameState.declineDraw() - } - - fun claimAbandonmentVictory(gameId: String) { - val gameState = state.getGameState(gameId) ?: return + fun claimAbandonmentVictory(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return val endData = gameState.claimAbandonmentVictory() ?: return scope.launch(Dispatchers.Default) { val success = retryWithBackoff { publisher.publishGameEnd(endData) } if (success) { - state.moveToCompleted(gameId, endData.result.notation, "abandonment") - pollingDelegate.removeGameId(gameId) + state.moveToCompleted(startEventId, endData.result.notation, "abandonment") + pollingDelegate.removeGameId(startEventId) state.setError(null) } else { state.setError("Failed to claim abandonment victory") @@ -460,20 +502,21 @@ class ChessLobbyLogic( // Spectator mode // ======================================== - fun loadGameAsSpectator(gameId: String) { + fun loadGameAsSpectator(startEventId: String) { scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) - val events = fetcher.fetchGameEvents(gameId) + val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - state.addSpectatingGame(gameId, result.liveState) - pollingDelegate.addGameId(gameId) - state.selectGame(gameId) + state.addSpectatingGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) state.setError(null) + // Auto-select the game for Desktop (Android uses route navigation) + state.selectGame(startEventId) } is LoadGameResult.Error -> { state.setError("Failed to load game: ${result.message}") @@ -483,31 +526,36 @@ class ChessLobbyLogic( } } - fun loadGame(gameId: String) { + fun loadGame(startEventId: String) { scope.launch(Dispatchers.Default) { - if (state.getGameState(gameId) != null) { - state.selectGame(gameId) + // Don't load if game already exists or was accepted (acceptChallenge will handle it) + if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) { return@launch } state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) - val events = fetcher.fetchGameEvents(gameId) + val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { if (result.liveState.isSpectator) { - state.addSpectatingGame(gameId, result.liveState) + state.addSpectatingGame(startEventId, result.liveState) } else { - state.addActiveGame(gameId, result.liveState) + state.addActiveGame(startEventId, result.liveState) } - pollingDelegate.addGameId(gameId) - state.selectGame(gameId) + pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) state.setError(null) } is LoadGameResult.Error -> { + // Check again if game was added while we were fetching + // (e.g., by acceptChallenge completing in parallel) + if (state.getGameState(startEventId) != null) { + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + return@launch + } state.setError("Failed to load game: ${result.message}") state.setBroadcastStatus(ChessBroadcastStatus.Idle) } @@ -519,23 +567,20 @@ class ChessLobbyLogic( // Relay-first refresh (periodic reconstruction) // ======================================== - /** - * Full reconstruction refresh for active games. - * One-shot REQ → transient collector → reconstruct → diff + update. - */ private suspend fun refreshGames(gameIds: Set) { for (gameId in gameIds) { refreshGame(gameId) } } - private suspend fun refreshGame(gameId: String) { - val events = fetcher.fetchGameEvents(gameId) + private suspend fun refreshGame(startEventId: String) { + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - state.replaceGameState(gameId, result.liveState) + state.replaceGameState(startEventId, result.liveState) } is LoadGameResult.Error -> { // Don't overwrite error for periodic refresh failures @@ -544,16 +589,72 @@ class ChessLobbyLogic( } private suspend fun refreshChallenges() { - val challengeEvents = fetcher.fetchChallenges() + state.setRefreshing(true) + try { + refreshChallengesInternal() + } finally { + state.setRefreshing(false) + } + } + + private suspend fun refreshChallengesInternal() { + val relayUrls = fetcher.getRelayUrls() + val relayStatesMap = mutableMapOf() + relayUrls.forEach { url -> + val displayName = url.substringAfter("://").substringBefore("/") + relayStatesMap[url] = RelaySyncState(url, displayName, RelaySyncStatus.CONNECTING, 0) + } + var totalEvents = 0 + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = 0, + ), + ) + + val startEvents = + fetcher.fetchChallenges { progress -> + val relayUrl = progress.relay.url + val displayName = relayUrl.substringAfter("://").substringBefore("/") + val status = + when (progress.status) { + RelayFetchStatus.WAITING -> RelaySyncStatus.WAITING + RelayFetchStatus.RECEIVING -> RelaySyncStatus.RECEIVING + RelayFetchStatus.EOSE_RECEIVED -> RelaySyncStatus.EOSE_RECEIVED + RelayFetchStatus.TIMEOUT -> RelaySyncStatus.FAILED + } + relayStatesMap[relayUrl] = + RelaySyncState( + relayUrl, + displayName, + status, + progress.eventCount, + ) + totalEvents = relayStatesMap.values.sumOf { it.eventsReceived } + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + } + + val fetchedChallenges = + startEvents.mapNotNull { event -> + if (!event.isStartEvent()) return@mapNotNull null + val startEventId = event.id + + // Skip challenges that user has already accepted + if (state.wasAccepted(startEventId)) return@mapNotNull null - val challenges = - challengeEvents.mapNotNull { event -> - val gameId = event.gameId() ?: return@mapNotNull null val challengerColor = event.playerColor() ?: Color.WHITE ChessChallenge( eventId = event.id, - gameId = gameId, + gameId = startEventId, challengerPubkey = event.pubKey, challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), @@ -563,14 +664,38 @@ class ChessLobbyLogic( ) } - state.updateChallenges(challenges) + // Merge only RECENT optimistic challenges (created in last 5 minutes, not yet propagated) + // This prevents stale challenges from accumulating across sessions + // Also exclude challenges that user has accepted (tracked in _acceptedGameIds) + val now = TimeUtils.now() + val recentThreshold = 5 * 60L // 5 minutes + val fetchedGameIds = fetchedChallenges.map { it.gameId }.toSet() + val optimisticChallenges = + state.challenges.value.filter { challenge -> + val isRecent = (now - challenge.createdAt) < recentThreshold + val notFetched = challenge.gameId !in fetchedGameIds + val notAccepted = !state.wasAccepted(challenge.gameId) + isRecent && notFetched && notAccepted + } + val mergedChallenges = fetchedChallenges + optimisticChallenges + + state.updateChallenges(mergedChallenges) + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "games", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + + discoverUserGames() - // Also refresh public games val recentGames = fetcher.fetchRecentGames() val publicGames = recentGames.map { summary -> PublicGame( - gameId = summary.gameId, + gameId = summary.startEventId, whitePubkey = summary.whitePubkey, whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey), blackPubkey = summary.blackPubkey, @@ -581,6 +706,59 @@ class ChessLobbyLogic( ) } state.updatePublicGames(publicGames) + + val failedCount = relayStatesMap.values.count { it.status == RelaySyncStatus.FAILED } + val activeGamesCount = state.activeGames.value.size + + if (failedCount > 0 && failedCount < relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "$failedCount relay(s) timed out", + ), + ) + } else if (failedCount == relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "All relays failed", + ), + ) + } else { + state.setSyncStatus( + ChessSyncStatus.Synced( + relayStates = relayStatesMap.values.toList(), + challengeCount = mergedChallenges.size, + gameCount = activeGamesCount, + totalEventsReceived = totalEvents, + ), + ) + } + } + + private suspend fun discoverUserGames() { + val discoveredGameIds = fetcher.fetchUserGameIds() + val currentActiveIds = state.activeGames.value.keys + val currentSpectatingIds = state.spectatingGames.value.keys + + val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds + + for (startEventId in newGameIds) { + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (!result.liveState.isSpectator) { + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + } + } + is LoadGameResult.Error -> { + // Failed to load game - continue with others + } + } + } } private fun cleanupExpiredChallenges() { @@ -596,16 +774,6 @@ class ChessLobbyLogic( // Utilities // ======================================== - private fun generateGameId(): String { - val timestamp = TimeUtils.now() - val random = UUID.randomUUID().toString().take(8) - return "chess-$timestamp-$random" - } - - /** - * Retry a publish operation with exponential backoff. - * Returns true if any attempt succeeds. - */ private suspend fun retryWithBackoff( maxRetries: Int = 3, initialDelayMs: Long = 1000, @@ -622,6 +790,23 @@ class ChessLobbyLogic( return false } + private suspend fun retryWithBackoffResult( + maxRetries: Int = 3, + initialDelayMs: Long = 1000, + action: suspend () -> T?, + ): T? { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + val result = action() + if (result != null) return result + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return null + } + fun clearError() { state.setError(null) } @@ -630,3 +815,26 @@ class ChessLobbyLogic( state.selectGame(gameId) } } + +/** + * Parse promotion piece from a "to" square string. + * For example: "e8q" -> Pair("e8", PieceType.QUEEN) + * Regular moves: "e4" -> Pair("e4", null) + */ +private fun parsePromotionFromTo(to: String): Pair { + if (to.length == 3) { + val square = to.take(2) + val promotion = + when (to.last().lowercaseChar()) { + 'q' -> PieceType.QUEEN + 'r' -> PieceType.ROOK + 'b' -> PieceType.BISHOP + 'n' -> PieceType.KNIGHT + else -> null + } + if (promotion != null) { + return square to promotion + } + } + return to to 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 9c7143d79..75b458172 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 @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import java.util.concurrent.atomic.AtomicLong /** * Challenge expiry: 24 hours @@ -100,6 +101,63 @@ data class CompletedGame( val isDraw: Boolean get() = result == "1/2-1/2" } +/** + * Individual relay status during sync + */ +@Immutable +data class RelaySyncState( + val url: String, + val displayName: String, + val status: RelaySyncStatus, + val eventsReceived: Int = 0, +) + +@Immutable +enum class RelaySyncStatus { + CONNECTING, + WAITING, + RECEIVING, + EOSE_RECEIVED, + FAILED, +} + +/** + * Sync status for incoming events (subscription-side) + */ +@Immutable +sealed class ChessSyncStatus { + data object Idle : ChessSyncStatus() + + data class Syncing( + val phase: String, + val relayStates: List, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val connectedCount: Int get() = relayStates.count { it.status != RelaySyncStatus.FAILED } + val eoseCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class Synced( + val relayStates: List, + val challengeCount: Int, + val gameCount: Int, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class PartialSync( + val relayStates: List, + val message: String, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val failedCount: Int get() = relayStates.count { it.status == RelaySyncStatus.FAILED } + val totalCount: Int get() = relayStates.size + } +} + /** * Chess status for UI feedback */ @@ -163,6 +221,9 @@ class ChessLobbyState( private val _completedGames = MutableStateFlow>(emptyList()) val completedGames: StateFlow> = _completedGames.asStateFlow() + // Game IDs that user has accepted - uses global singleton to share across ViewModel instances + // This is critical because lobby and game screen may have different ViewModel instances + // Broadcast status private val _broadcastStatus = MutableStateFlow(ChessBroadcastStatus.Idle) val broadcastStatus: StateFlow = _broadcastStatus.asStateFlow() @@ -171,10 +232,24 @@ class ChessLobbyState( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + // Loading/refreshing state + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + // Sync status for subscription banner + private val _syncStatus = MutableStateFlow(ChessSyncStatus.Idle) + val syncStatus: StateFlow = _syncStatus.asStateFlow() + // Selected game ID for navigation private val _selectedGameId = MutableStateFlow(null) val selectedGameId: StateFlow = _selectedGameId.asStateFlow() + // State version counter - increments on every game state update + // UI can observe this to force recomposition when internal state changes + private val stateVersionCounter = AtomicLong(0) + private val _stateVersion = MutableStateFlow(0L) + val stateVersion: StateFlow = _stateVersion.asStateFlow() + // Badge count (incoming challenges + your turn games) val badgeCount: Int get() { @@ -228,7 +303,20 @@ class ChessLobbyState( gameId: String, state: LiveChessGameState, ) { - _activeGames.update { it + (gameId to state) } + _activeGames.update { current -> + val existing = current[gameId] + if (existing != null) { + // Only replace if new state has at least as many moves + val existingMoves = existing.moveHistory.value.size + val newMoves = state.moveHistory.value.size + if (newMoves < existingMoves) { + return@update current + } + } + current + (gameId to state) + } + // Track as accepted to prevent refresh from re-adding as optimistic challenge + AcceptedGamesRegistry.markAsAccepted(gameId) // Remove from challenges if present removeChallenge(gameId) } @@ -251,15 +339,55 @@ class ChessLobbyState( /** * Replace game state entirely after full reconstruction from relays. * Preserves game location (active vs spectating). + * + * IMPORTANT: Only replaces if new state has >= moves than current state. + * This prevents race conditions where polling refresh could revert + * a user's move before it propagates to relays. + * + * IMPORTANT: Never replace a participant game with a spectator state. + * This prevents the race where relay fetch returns before acceptance propagates, + * which would incorrectly mark an accepted game as spectating. */ fun replaceGameState( gameId: String, newState: LiveChessGameState, ) { - if (_activeGames.value.containsKey(gameId)) { - _activeGames.update { it + (gameId to newState) } - } else if (_spectatingGames.value.containsKey(gameId)) { - _spectatingGames.update { it + (gameId to newState) } + val inActiveGames = _activeGames.value.containsKey(gameId) + val inSpectatingGames = _spectatingGames.value.containsKey(gameId) + + val currentState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + val currentMoveCount = currentState?.moveHistory?.value?.size ?: 0 + val newMoveCount = newState.moveHistory.value.size + + // Only replace if new state has at least as many moves + // This prevents reverting user's local moves during refresh + if (newMoveCount < currentMoveCount) { + return + } + + // Handle case where new state incorrectly has isSpectator=true but current is participant + // This happens with open challenges where opponent can't be determined from relay events alone + // Solution: Apply moves from new state while preserving participant status from current + val stateToUse = + if (currentState != null && !currentState.isSpectator && newState.isSpectator && newMoveCount > currentMoveCount) { + // Apply opponent's moves to current state's engine + currentState.applyMovesFrom(newState) + currentState // Keep using current state with updated engine + } else if (currentState != null && !currentState.isSpectator && newState.isSpectator) { + return + } else { + newState + } + + if (inActiveGames) { + _activeGames.update { it + (gameId to stateToUse) } + // Increment version to force UI recomposition even if map equals() returns true + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion + } else if (inSpectatingGames) { + _spectatingGames.update { it + (gameId to stateToUse) } + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion } } @@ -329,6 +457,12 @@ class ChessLobbyState( gameId: String, state: LiveChessGameState, ) { + // Never add to spectating if this game was accepted - user is a participant + if (AcceptedGamesRegistry.wasAccepted(gameId)) { + // Add to active games instead + _activeGames.update { it + (gameId to state) } + return + } _spectatingGames.update { it + (gameId to state) } } @@ -344,6 +478,14 @@ class ChessLobbyState( _error.value = error } + fun setRefreshing(refreshing: Boolean) { + _isRefreshing.value = refreshing + } + + fun setSyncStatus(status: ChessSyncStatus) { + _syncStatus.value = status + } + fun selectGame(gameId: String?) { _selectedGameId.value = gameId } @@ -354,12 +496,21 @@ class ChessLobbyState( fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId) + /** Whether a game ID was accepted (prevents refresh from re-adding as challenge) */ + fun wasAccepted(gameId: String): Boolean = AcceptedGamesRegistry.wasAccepted(gameId) + + /** Mark a game as accepted synchronously (call before async game creation) */ + fun markAsAccepted(gameId: String) { + AcceptedGamesRegistry.markAsAccepted(gameId) + } + fun clearAll() { _activeGames.value = emptyMap() _publicGames.value = emptyList() _challenges.value = emptyList() _spectatingGames.value = emptyMap() _completedGames.value = emptyList() + AcceptedGamesRegistry.clear() _broadcastStatus.value = ChessBroadcastStatus.Idle _error.value = null _selectedGameId.value = null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt index 1e0e3dfda..e9b05b77f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -30,6 +30,22 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap +/** + * Progress callback for relay fetch operations + */ +data class RelayFetchProgress( + val relay: NormalizedRelayUrl, + val status: RelayFetchStatus, + val eventCount: Int, +) + +enum class RelayFetchStatus { + WAITING, + RECEIVING, + EOSE_RECEIVED, + TIMEOUT, +} + /** * One-shot relay fetch helper for chess events. * @@ -47,21 +63,30 @@ class ChessRelayFetchHelper( * Fetch events matching filters from relays, waiting for EOSE. * * @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription) - * @param timeoutMs Max time to wait for all relays to send EOSE + * @param timeoutMs Max time to wait for relays to respond (default from ChessConfig) + * @param onProgress Optional callback for progress updates per relay * @return Deduplicated list of events received before timeout/EOSE */ suspend fun fetchEvents( filters: Map>, - timeoutMs: Long = 30_000, + timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS, + onProgress: ((RelayFetchProgress) -> Unit)? = null, ): List { if (filters.isEmpty()) return emptyList() val events = ConcurrentHashMap() val relayCount = filters.keys.size val eoseReceived = ConcurrentHashMap.newKeySet() + val relayEventCounts = ConcurrentHashMap() val allEose = CompletableDeferred() val subId = newSubId() + // Initialize all relays as WAITING + filters.keys.forEach { relay -> + relayEventCounts[relay] = 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0)) + } + val listener = object : IRequestListener { override fun onEvent( @@ -71,6 +96,8 @@ class ChessRelayFetchHelper( forFilters: List?, ) { events[event.id] = event + val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) } override fun onEose( @@ -78,6 +105,9 @@ class ChessRelayFetchHelper( forFilters: List?, ) { eoseReceived.add(relay) + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count)) + // Complete when all relays respond if (eoseReceived.size >= relayCount) { allEose.complete(Unit) } @@ -85,7 +115,18 @@ class ChessRelayFetchHelper( } client.openReqSubscription(subId, filters, listener) - withTimeoutOrNull(timeoutMs) { allEose.await() } + val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() } + + // Mark timed-out relays + if (eoseResult == null) { + filters.keys.forEach { relay -> + if (relay !in eoseReceived) { + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count)) + } + } + } + client.close(subId) return events.values.toList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt new file mode 100644 index 000000000..5db641ed9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt @@ -0,0 +1,311 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess sync/subscription status with expandable relay details. + * Shows incoming event progress from relays during refresh. + */ +@Composable +fun ChessSyncBanner( + status: ChessSyncStatus, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessSyncStatus.Idle + var isExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getSyncStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .animateContentSize() + .clickable { + if (status is ChessSyncStatus.PartialSync) { + onRetry() + } else { + isExpanded = !isExpanded + } + }, + ) { + // Main status row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = getSyncStatusIcon(status), + contentDescription = null, + tint = getSyncStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getSyncStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getSyncStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getSyncStatusDetailColor(status), + ) + } + + // Progress bar for syncing + if (status is ChessSyncStatus.Syncing) { + Spacer(Modifier.height(4.dp)) + val progress = status.eoseCount.toFloat() / status.totalCount.coerceAtLeast(1) + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "syncProgress", + ) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + // Expand/collapse icon + if (status !is ChessSyncStatus.Idle && getRelayStates(status).isNotEmpty()) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + + // Expandable relay details + AnimatedVisibility( + visible = isExpanded && getRelayStates(status).isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + RelayStatusList( + relayStates = getRelayStates(status), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun RelayStatusList( + relayStates: List, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + relayStates.forEach { relay -> + RelayStatusRow(relay) + } + } +} + +@Composable +private fun RelayStatusRow(relay: RelaySyncState) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = getRelayStatusIcon(relay.status), + contentDescription = null, + tint = getRelayStatusColor(relay.status), + modifier = Modifier.size(14.dp), + ) + + Text( + text = relay.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = "${relay.eventsReceived} events", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +private fun getRelayStates(status: ChessSyncStatus): List = + when (status) { + is ChessSyncStatus.Syncing -> status.relayStates + is ChessSyncStatus.Synced -> status.relayStates + is ChessSyncStatus.PartialSync -> status.relayStates + is ChessSyncStatus.Idle -> emptyList() + } + +private fun getRelayStatusIcon(status: RelaySyncStatus): ImageVector = + when (status) { + RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty + RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty + RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload + RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle + RelaySyncStatus.FAILED -> Icons.Default.Error + } + +@Composable +private fun getRelayStatusColor(status: RelaySyncStatus): Color = + when (status) { + RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary + RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary + RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error + } + +@Composable +private fun getSyncStatusBackgroundColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getSyncStatusIcon(status: ChessSyncStatus): ImageVector = + when (status) { + is ChessSyncStatus.Syncing -> Icons.Default.CloudDownload + is ChessSyncStatus.Synced -> Icons.Default.CheckCircle + is ChessSyncStatus.PartialSync -> Icons.Default.Warning + is ChessSyncStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getSyncStatusIconColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getSyncStatusText(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "Syncing ${status.phase}..." + is ChessSyncStatus.Synced -> "Synced" + is ChessSyncStatus.PartialSync -> status.message + is ChessSyncStatus.Idle -> "" + } + +private fun getSyncStatusDetail(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "${status.eoseCount}/${status.totalCount} relays • ${status.totalEventsReceived} events" + is ChessSyncStatus.Synced -> "${status.successCount}/${status.totalCount} relays • ${status.challengeCount} challenges • ${status.gameCount} games" + is ChessSyncStatus.PartialSync -> "Tap to retry" + is ChessSyncStatus.Idle -> "" + } + +@Composable +private fun getSyncStatusDetailColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt index 0ae312ae6..8326d778a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor * @param boardSize Total size of the board in dp * @param flipped If true, renders from black's perspective (rank 1 at top) * @param playerColor The color the player is playing as (only allows moving these pieces) + * @param isSpectator If true, disables all interaction (spectator/watch mode) * @param onMoveMade Callback when a valid move is made, receives (from, to, san) * NOTE: This callback should make the actual move - this component only validates */ @@ -73,6 +74,7 @@ fun InteractiveChessBoard( boardSize: Dp = 400.dp, flipped: Boolean = false, playerColor: ChessColor? = null, // null = allow both (for local play) + isSpectator: Boolean = false, positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size) onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, ) { @@ -94,8 +96,8 @@ fun InteractiveChessBoard( pendingPromotion = null } - // Can only interact if it's your turn (or playerColor is null for local play) - val canInteract = playerColor == null || sideToMove == playerColor + // Can only interact if not spectating and it's your turn (or playerColor is null for local play) + val canInteract = !isSpectator && (playerColor == null || sideToMove == playerColor) val squareSize = boardSize / 8 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 0743f66c2..08ed60340 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -20,7 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.chess +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,11 +37,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -49,14 +57,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameStatus import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import androidx.compose.ui.graphics.Color as ComposeColor /** * Dialog for creating a new chess game challenge @@ -175,7 +189,9 @@ fun NewChessGameDialog( * @param opponentName Opponent's display name * @param onMoveMade Callback when player makes a move (from, to, san) * @param onResign Callback when player resigns - * @param onOfferDraw Callback when player offers draw + * @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use this when + * spectator status is determined by which map the game is in (activeGames vs spectatingGames) + * rather than the potentially stale isSpectator flag from relay reconstruction. */ @Composable fun LiveChessGameScreen( @@ -184,69 +200,97 @@ fun LiveChessGameScreen( opponentName: String, onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, - onOfferDraw: () -> Unit, + isSpectatorOverride: Boolean? = null, + onGameEndDismiss: (() -> Unit)? = null, ) { // Observe state flows for automatic recomposition on updates val currentPosition by gameState.currentPosition.collectAsState() val moveHistory by gameState.moveHistory.collectAsState() + val gameStatus by gameState.gameStatus.collectAsState() + + // Use override if provided, otherwise fall back to gameState flag + val isSpectator = isSpectatorOverride ?: gameState.isSpectator // Pending challenges and spectators cannot make moves - val canMakeMoves = !gameState.isSpectator && !gameState.isPendingChallenge + val canMakeMoves = !isSpectator && !gameState.isPendingChallenge - BoxWithConstraints( - modifier = modifier.fillMaxSize(), - ) { - // Calculate board size based on available space - // Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding - val availableWidth = maxWidth - 32.dp // Account for horizontal padding - val availableHeight = maxHeight - 250.dp // Account for other UI elements - val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp) + // Track if game end overlay was dismissed + var gameEndDismissed by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), + Box(modifier = modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), ) { - // Game info - use currentPosition.activeColor for turn display - GameInfoHeader( - gameId = gameState.gameId, - opponentName = opponentName, - playerColor = gameState.playerColor, - currentTurn = currentPosition.activeColor, - isSpectator = gameState.isSpectator, - isPendingChallenge = gameState.isPendingChallenge, - ) + // Calculate board size dynamically based on available space + // Use a percentage of available height to leave room for other UI elements + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + // Reserve ~35% of height for header, history, controls, and any banners + val availableHeight = maxHeight * 0.65f + val boardSize = min(availableWidth, availableHeight).coerceIn(150.dp, 520.dp) - // Interactive chess board - flip when playing black (spectators see from white's view) - // Auto-sized to fit available space - InteractiveChessBoard( - engine = gameState.engine, - boardSize = boardSize, - flipped = !gameState.isSpectator && gameState.playerColor == Color.BLACK, - positionVersion = moveHistory.size, - onMoveMade = - if (canMakeMoves) { - onMoveMade - } else { - { _, _, _ -> } // No-op for spectators and pending challenges - }, - ) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Game info - use currentPosition.activeColor for turn display + GameInfoHeader( + gameId = gameState.gameId, + opponentName = opponentName, + playerColor = gameState.playerColor, + currentTurn = currentPosition.activeColor, + isSpectator = isSpectator, + isPendingChallenge = gameState.isPendingChallenge, + gameStatus = gameStatus, + ) - // Move history (scrollable) - observed from state flow - MoveHistoryDisplay( - moves = moveHistory, - ) + // Interactive chess board - flip when playing black (spectators see from white's view) + // Auto-sized to fit available space + InteractiveChessBoard( + engine = gameState.engine, + boardSize = boardSize, + flipped = !isSpectator && gameState.playerColor == Color.BLACK, + playerColor = gameState.playerColor, + isSpectator = !canMakeMoves, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) - // Show appropriate controls based on game state - when { - gameState.isPendingChallenge -> PendingChallengeInfo() - gameState.isSpectator -> SpectatorInfo() - else -> GameControls(onResign = onResign, onOfferDraw = onOfferDraw) + // Move history (scrollable) - observed from state flow + MoveHistoryDisplay( + moves = moveHistory, + ) + + // Show appropriate controls based on game state + when { + gameStatus is GameStatus.Finished -> + GameEndInfo( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + ) + gameState.isPendingChallenge -> PendingChallengeInfo() + isSpectator -> SpectatorInfo() + else -> GameControls(onResign = onResign) + } } } + + // Game end celebration overlay + if (gameStatus is GameStatus.Finished && !gameEndDismissed) { + GameEndOverlay( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + onDismiss = { + gameEndDismissed = true + onGameEndDismiss?.invoke() + }, + ) + } } } @@ -260,7 +304,6 @@ fun LiveChessGameScreen( * @param opponentName Opponent's display name * @param onMoveMade Callback when player makes a move (from, to, san) * @param onResign Callback when player resigns - * @param onOfferDraw Callback when player offers draw */ @Composable fun LiveChessGameScreen( @@ -271,7 +314,6 @@ fun LiveChessGameScreen( opponentName: String, onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, - onOfferDraw: () -> Unit, ) { BoxWithConstraints( modifier = modifier.fillMaxSize(), @@ -316,7 +358,6 @@ fun LiveChessGameScreen( // Game controls GameControls( onResign = onResign, - onOfferDraw = onOfferDraw, ) } } @@ -333,6 +374,7 @@ private fun GameInfoHeader( currentTurn: Color, isSpectator: Boolean = false, isPendingChallenge: Boolean = false, + gameStatus: GameStatus = GameStatus.InProgress, ) { // Extract human-readable game name if available val gameName = @@ -407,24 +449,53 @@ private fun GameInfoHeader( style = MaterialTheme.typography.bodyMedium, ) - val turnText = - if (currentTurn == playerColor) { - "Your turn" - } else { - "Opponent's turn" + // Show turn or game result + when (gameStatus) { + is GameStatus.Finished -> { + val result = (gameStatus as GameStatus.Finished).result + val resultText = + when { + result == GameResult.DRAW -> "Draw" + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!" + else -> "You lost" + } + val resultColor = + when { + result == GameResult.DRAW -> MaterialTheme.colorScheme.secondary + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> + ComposeColor(0xFF4CAF50) + else -> MaterialTheme.colorScheme.error + } + Text( + text = resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) } + else -> { + val turnText = + if (currentTurn == playerColor) { + "Your turn" + } else { + "Opponent's turn" + } - Text( - text = turnText, - style = MaterialTheme.typography.bodyMedium, - color = - if (currentTurn == playerColor) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, - ) + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = + if (currentTurn == playerColor) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, + ) + } + } } } } @@ -567,23 +638,242 @@ private fun MoveHistoryDisplay(moves: List) { } /** - * Game control buttons (Resign, Offer Draw) + * Game control buttons (Resign) + * Note: Draw offers not supported in Jester protocol */ @Composable -private fun GameControls( - onResign: () -> Unit, - onOfferDraw: () -> Unit, -) { +private fun GameControls(onResign: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), ) { - OutlinedButton(onClick = onOfferDraw) { - Text("Offer Draw") - } - OutlinedButton(onClick = onResign) { Text("Resign") } } } + +/** + * Game end celebration overlay + */ +@Composable +private fun GameEndOverlay( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, + onDismiss: () -> Unit, +) { + val playerWon = + when (result) { + GameResult.WHITE_WINS -> playerColor == Color.WHITE + GameResult.BLACK_WINS -> playerColor == Color.BLACK + GameResult.DRAW, GameResult.IN_PROGRESS -> false + } + + val isDraw = result == GameResult.DRAW + + // Determine display based on result + val (emoji, title, subtitle, backgroundColor) = + when { + isSpectator -> { + val winnerText = + when (result) { + GameResult.WHITE_WINS -> "White wins!" + GameResult.BLACK_WINS -> "Black wins!" + GameResult.DRAW -> "Draw!" + GameResult.IN_PROGRESS -> "Game Over" + } + Quadruple("", "Game Over", winnerText, MaterialTheme.colorScheme.surfaceVariant) + } + playerWon -> + Quadruple( + "", + "Victory!", + "Congratulations!", + ComposeColor(0xFF4CAF50).copy(alpha = 0.95f), + ) + isDraw -> + Quadruple( + "", + "Draw", + "Game ended in a draw", + MaterialTheme.colorScheme.surfaceVariant, + ) + else -> + Quadruple( + "", + "Defeat", + "Better luck next time!", + ComposeColor(0xFFE57373).copy(alpha = 0.95f), + ) + } + + // Animated visibility + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(animationSpec = tween(300)), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(ComposeColor.Black.copy(alpha = 0.7f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = + Modifier + .padding(32.dp) + .clickable { /* prevent dismiss on card click */ }, + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors( + containerColor = backgroundColor, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = + Modifier + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Trophy/emoji based on result + val displayEmoji = + when { + isSpectator -> "GG" + playerWon -> "GG" + isDraw -> "=" + else -> "GG" + } + + Box( + modifier = + Modifier + .size(80.dp) + .background( + when { + isSpectator -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + playerWon -> ComposeColor(0xFFFFD700).copy(alpha = 0.3f) + isDraw -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f) + }, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayEmoji, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.primary + playerWon -> ComposeColor(0xFFFFD700) + isDraw -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.error + }, + ) + } + + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White + }, + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White.copy(alpha = 0.9f) + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White.copy(alpha = 0.9f) + }, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onDismiss, + ) { + Text("Continue") + } + } + } + } + } +} + +/** + * Compact game end info shown in controls area + */ +@Composable +private fun GameEndInfo( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, +) { + val resultText = + when { + isSpectator -> + when (result) { + GameResult.WHITE_WINS -> "White wins" + GameResult.BLACK_WINS -> "Black wins" + GameResult.DRAW -> "Draw" + GameResult.IN_PROGRESS -> "In progress" + } + result == GameResult.DRAW -> "Game drawn" + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!" + else -> "You lost" + } + + val backgroundColor = + when { + isSpectator -> MaterialTheme.colorScheme.surfaceVariant + result == GameResult.DRAW -> MaterialTheme.colorScheme.secondaryContainer + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> + ComposeColor(0xFF4CAF50).copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(8.dp)) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Game Over - $resultText", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** + * Helper data class for quadruple values + */ +private data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt index ed6fc9344..603e50321 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -23,39 +23,28 @@ package com.vitorpamplona.amethyst.commons.chess.subscription import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol import com.vitorpamplona.quartz.utils.TimeUtils /** - * Shared filter builder for chess subscriptions. + * Shared filter builder for chess subscriptions using Jester protocol. * Used by both Android and Desktop to ensure identical subscription behavior. * + * Jester Protocol (kind 30): + * - All chess events use kind 30 + * - Start events: e-tag references START_POSITION_HASH + * - Move events: e-tags [startEventId, headEventId] + * - Content JSON determines event type (kind: 0=start, 1=move, 2=chat) + * * Builds 4 types of filters: * 1. Personal filters - Events tagged with user's pubkey - * 2. Challenge filters - All challenges (like jesterui pattern) - * 3. Active game filters - Game-specific move/end subscriptions - * 4. Recent game filters - For spectating discovery (7 day window) + * 2. Start/Challenge filters - Events referencing START_POSITION_HASH + * 3. Active game filters - Events referencing specific game startEventIds + * 4. Recent game filters - For spectating discovery */ object ChessFilterBuilder { - /** Challenge and accept event kinds (for lobby display) */ - private val CHALLENGE_KINDS = - listOf( - LiveChessGameChallengeEvent.KIND, - LiveChessGameAcceptEvent.KIND, - ) - - /** Move and end event kinds (for active games) */ - private val GAME_EVENT_KINDS = - listOf( - LiveChessMoveEvent.KIND, - LiveChessGameEndEvent.KIND, - ) - - /** All chess event kinds */ - private val ALL_CHESS_KINDS = CHALLENGE_KINDS + GAME_EVENT_KINDS + /** Jester protocol kind for all chess events */ + private const val JESTER_KIND = JesterProtocol.KIND /** * Build all chess filters for a given subscription state. @@ -77,9 +66,9 @@ object ChessFilterBuilder { buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now), ) - // Filter 2: All challenges (for lobby display) + // Filter 2: All start/challenge events (for lobby display) filters.addAll( - buildChallengeFilters(state.relays, sinceForRelay, now), + buildStartEventFilters(state.relays, sinceForRelay, now), ) // Filter 3: Active game subscriptions (game-specific) @@ -104,7 +93,7 @@ object ChessFilterBuilder { /** * Personal events - tagged with user's pubkey. - * Catches: challenges directed at us, moves in our games, game ends. + * Catches: challenges directed at us, moves in our games. */ fun buildPersonalFilters( userPubkey: String, @@ -114,7 +103,7 @@ object ChessFilterBuilder { ): List { val filter = Filter( - kinds = ALL_CHESS_KINDS, + kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), limit = 100, ) @@ -131,18 +120,19 @@ object ChessFilterBuilder { } /** - * All challenge events (like jesterui pattern). - * No author/tag restriction - fetch everything, filter client-side. - * This ensures we see open challenges and public games. + * Start/challenge events (Jester pattern). + * In Jester protocol, start events reference the START_POSITION_HASH. + * This fetches all game starts for lobby display. */ - fun buildChallengeFilters( + fun buildStartEventFilters( relays: Set, sinceForRelay: (NormalizedRelayUrl) -> Long?, now: Long = TimeUtils.now(), ): List { val filter = Filter( - kinds = CHALLENGE_KINDS, + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), limit = 100, ) @@ -161,63 +151,58 @@ object ChessFilterBuilder { * Active game events - game-specific subscriptions. * These filters ensure moves are received for games the user is playing. * - * Note: Move d-tags are "gameId-moveNumber" so we can't filter by #d for moves. - * We use two strategies: - * 1. Filter by authors (opponent pubkeys) - catches moves they make - * 2. Filter by #p tag (opponent tagged us) - catches moves tagged with us - * - * End events use gameId as d-tag so we can filter those directly. + * In Jester protocol: + * - Move events have e-tags: [startEventId, headEventId] + * - We filter by the first e-tag (startEventId) to get all moves for a game + * - We also filter by opponent authors and p-tag for redundancy */ fun buildActiveGameFilters( - gameIds: Set, + startEventIds: Set, userPubkey: String, opponentPubkeys: Set, relays: Set, ): List { - if (gameIds.isEmpty()) return emptyList() - - println("[ChessFilterBuilder] Building filters for ${gameIds.size} games, ${opponentPubkeys.size} opponents: $opponentPubkeys") + if (startEventIds.isEmpty()) return emptyList() val filters = mutableListOf() - // End events: filter by d-tag (gameId) - val endFilter = + // Game events: filter by e-tag (startEventId) + // This catches all moves/events for these games + val gameFilter = Filter( - kinds = listOf(LiveChessGameEndEvent.KIND), - tags = mapOf("d" to gameIds.toList()), - limit = 50, + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to startEventIds.toList()), + limit = 500, ) - // Move events: filter by p-tag (opponent tagged us) - // This catches all moves for games where we're a participant - val moveFilterByTag = + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = gameFilter)) + } + + // Also filter by p-tag (opponent tagged us) for redundancy + val tagFilter = Filter( - kinds = listOf(LiveChessMoveEvent.KIND), + kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), limit = 200, ) relays.forEach { relay -> - filters.add(RelayBasedFilter(relay = relay, filter = endFilter)) - filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByTag)) + filters.add(RelayBasedFilter(relay = relay, filter = tagFilter)) } - // Move events: filter by authors (opponent pubkeys) - // This directly catches moves made by our opponents + // Also filter by authors (opponent pubkeys) for redundancy if (opponentPubkeys.isNotEmpty()) { - println("[ChessFilterBuilder] Adding author filter for opponents: $opponentPubkeys") - val moveFilterByAuthor = + val authorFilter = Filter( - kinds = listOf(LiveChessMoveEvent.KIND), + kinds = listOf(JESTER_KIND), authors = opponentPubkeys.toList(), limit = 200, ) relays.forEach { relay -> - filters.add(RelayBasedFilter(relay = relay, filter = moveFilterByAuthor)) + filters.add(RelayBasedFilter(relay = relay, filter = authorFilter)) } - } else { - println("[ChessFilterBuilder] WARNING: No opponent pubkeys provided!") } return filters @@ -234,7 +219,7 @@ object ChessFilterBuilder { ): List { val filter = Filter( - kinds = GAME_EVENT_KINDS, + kinds = listOf(JESTER_KIND), limit = 100, ) @@ -256,22 +241,27 @@ object ChessFilterBuilder { /** * Filter for all events related to a specific game. * Used for one-shot fetch when loading a game. + * + * In Jester protocol, all game events reference the startEventId via e-tag. */ - fun gameEventsFilter(gameId: String): Filter = + fun gameEventsFilter(startEventId: String): Filter = Filter( - kinds = ALL_CHESS_KINDS + listOf(com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent.KIND), - tags = mapOf("d" to listOf(gameId)), + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(startEventId)), limit = 500, ) /** - * Filter for challenge events in the last 24 hours. + * Filter for start/challenge events in the last 24 hours. * Used for one-shot fetch to populate lobby. + * + * Start events reference the START_POSITION_HASH. */ - fun challengesFilter(userPubkey: String): Filter { + fun challengesFilter(userPubkey: String? = null): Filter { val now = TimeUtils.now() return Filter( - kinds = CHALLENGE_KINDS, + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS, limit = 100, ) @@ -279,12 +269,40 @@ object ChessFilterBuilder { /** * Filter for recent game activity for spectating discovery. - * Fetches move events from the last 7 days. + * Fetches events from the last 7 days. */ fun recentGamesFilter(): Filter { val now = TimeUtils.now() return Filter( - kinds = ALL_CHESS_KINDS, + kinds = listOf(JESTER_KIND), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for user's own chess events (events they authored). + * Used to discover games the user is participating in. + */ + fun userGamesFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + authors = listOf(userPubkey), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for events tagged with user's pubkey (games they're participating in). + * Complements userGamesFilter to find games where user is the opponent. + */ + fun userTaggedFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, limit = 200, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt new file mode 100644 index 000000000..ab2932f14 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.profile + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing profile metadata broadcast progress. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ProfileBroadcastBanner( + status: ProfileBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ProfileBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 10.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Progress bar for broadcasting + if (status is ProfileBroadcastStatus.Broadcasting) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = status.progress, + animationSpec = tween(300), + label = "broadcastProgress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f) + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getStatusIcon(status: ProfileBroadcastStatus): ImageVector = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ProfileBroadcastStatus.Success -> Icons.Default.CheckCircle + is ProfileBroadcastStatus.Failed -> Icons.Default.Error + is ProfileBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getStatusText(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "Updating ${status.fieldName}..." + is ProfileBroadcastStatus.Success -> "Updated ${status.fieldName}" + is ProfileBroadcastStatus.Failed -> "Failed to update ${status.fieldName}" + is ProfileBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ProfileBroadcastStatus.Success -> "${status.relayCount} relays" + is ProfileBroadcastStatus.Failed -> "Tap to retry" + is ProfileBroadcastStatus.Idle -> "" + } + +@Composable +private fun getStatusDetailColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt new file mode 100644 index 000000000..58bb239e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.profile + +/** + * Status of profile metadata broadcast to relays. + */ +sealed class ProfileBroadcastStatus { + data object Idle : ProfileBroadcastStatus() + + data class Broadcasting( + val fieldName: String, + val successCount: Int, + val totalRelays: Int, + ) : ProfileBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Success( + val fieldName: String, + val relayCount: Int, + ) : ProfileBroadcastStatus() + + data class Failed( + val fieldName: String, + val error: String, + ) : ProfileBroadcastStatus() +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt new file mode 100644 index 000000000..8c3332230 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import kotlinx.coroutines.delay + +/** + * Result of broadcasting an event + */ +data class BroadcastResult( + val success: Boolean, + val relayResults: Map, + val message: String, +) + +/** + * Helper for broadcasting chess events to relays with reliable delivery. + * + * Uses sendAndWaitForResponse to get actual OK confirmations from relays, + * ensuring the event was actually received and accepted. + */ +class ChessEventBroadcaster( + private val client: INostrClient, +) { + /** + * Broadcast an event to the chess relays with confirmation. + * + * This method: + * 1. Triggers relay connections via a dummy subscription + * 2. Waits for relays to connect + * 3. Sends the event and waits for OK responses + * + * @param event The signed event to broadcast + * @param timeoutSeconds Maximum time to wait for relay responses + * @return BroadcastResult with success status + */ + suspend fun broadcast( + event: Event, + timeoutSeconds: Long = 15L, + ): BroadcastResult { + val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet() + val subId = newSubId() + + println("[ChessEventBroadcaster] Broadcasting event ${event.id.take(8)} to ${targetRelays.size} relays") + + // Step 1: Check which relays are already connected + val initialConnected = client.connectedRelaysFlow().value + val alreadyConnected = targetRelays.intersect(initialConnected) + val needsConnection = targetRelays - alreadyConnected + + println("[ChessEventBroadcaster] Already connected: ${alreadyConnected.size}, needs connection: ${needsConnection.size}") + + // Step 2: If some relays need connection, open a subscription to trigger it + if (needsConnection.isNotEmpty()) { + val dummyFilter = + Filter( + kinds = listOf(JesterProtocol.KIND), + ids = listOf("trigger_connection_${System.currentTimeMillis()}"), + limit = 1, + ) + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + println("[ChessEventBroadcaster] EOSE from ${relay.url}") + } + } + + // Open subscription to all target relays (triggers connection) + val filterMap = targetRelays.associateWith { listOf(dummyFilter) } + client.openReqSubscription(subId, filterMap, listener) + + // Wait for relays to connect (poll with timeout) + val connected = waitForRelays(targetRelays, 5000L) + println("[ChessEventBroadcaster] After waiting: ${connected.size}/${targetRelays.size} connected") + + // Close the dummy subscription + client.close(subId) + } + + // Step 3: Send the event and wait for OK responses + println("[ChessEventBroadcaster] Sending event with sendAndWaitForResponse...") + val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds) + + println("[ChessEventBroadcaster] Broadcast complete: success=$success") + + return BroadcastResult( + success = success, + relayResults = targetRelays.associateWith { success }, + message = if (success) "Event accepted by relay(s)" else "No relay accepted the event", + ) + } + + /** + * Wait for target relays to appear in connectedRelaysFlow. + */ + private suspend fun waitForRelays( + targetRelays: Set, + timeoutMs: Long, + ): Set { + val startTime = System.currentTimeMillis() + val pollInterval = 200L + + while (System.currentTimeMillis() - startTime < timeoutMs) { + val connected = client.connectedRelaysFlow().value + val targetConnected = targetRelays.intersect(connected) + + if (targetConnected.size == targetRelays.size) { + return targetConnected + } + + // At least one connected is good enough after half the timeout + if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) { + return targetConnected + } + + delay(pollInterval) + } + + return client.connectedRelaysFlow().value.intersect(targetRelays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 8d46318f3..0e3150d8c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -20,10 +20,9 @@ */ package com.vitorpamplona.amethyst.desktop.chess -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -35,23 +34,26 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,8 +65,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.CompletedGame import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.commons.data.UserMetadataCache import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -73,7 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionW import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** * Desktop chess screen with challenge list and game view @@ -87,41 +96,52 @@ fun ChessScreen( val scope = rememberCoroutineScope() val viewModel = remember(account.pubKeyHex) { - DesktopChessViewModel(account, relayManager, scope) + DesktopChessViewModelNew(account, relayManager, scope) } val relayStatuses by relayManager.relayStatuses.collectAsState() - val refreshKey by viewModel.refreshKey.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() + val broadcastStatus by viewModel.broadcastStatus.collectAsState() val activeGames by viewModel.activeGames.collectAsState() + // Observe state version to force recomposition when game state changes + val stateVersion by viewModel.stateVersion.collectAsState() - // Extract opponent pubkeys from active games for move filtering + // Ensure chess relays are added to the relay manager for broadcasting + LaunchedEffect(Unit) { + ChessConfig.CHESS_RELAYS.forEach { relayUrl -> + relayManager.addRelay(relayUrl) + } + println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager") + } + + // Derive stable keys to avoid recomposition from LiveChessGameState identity changes + val activeGameIds = remember(activeGames.keys) { activeGames.keys.toSet() } val opponentPubkeys = - remember(activeGames) { - val pubkeys = activeGames.values.map { it.opponentPubkey }.toSet() - println("[ChessScreen] Active games: ${activeGames.keys}, Opponent pubkeys: $pubkeys") - pubkeys + remember(activeGameIds) { + activeGames.values.map { it.opponentPubkey }.toSet() } - // Subscribe to chess events from relays - // Re-subscribes when relays, refreshKey, or active games change - rememberSubscription(relayStatuses, account, refreshKey, activeGames.keys, opponentPubkeys, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { - createChessSubscriptionWithGames( - relays = configuredRelays, - userPubkey = account.pubKeyHex, - activeGameIds = activeGames.keys, - opponentPubkeys = opponentPubkeys, - onEvent = { event, _, _, _ -> - viewModel.handleIncomingEvent(event) - }, - onEose = { _, _ -> - viewModel.onLoadComplete() - }, - ) - } else { - null + // Subscribe to chess events from dedicated chess relays + // Re-subscribes when active games change + val chessRelays = + remember { + ChessConfig.CHESS_RELAYS + .map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + }.toSet() } + rememberSubscription(chessRelays, account, activeGameIds, opponentPubkeys, relayManager = relayManager) { + createChessSubscriptionWithGames( + relays = chessRelays, + userPubkey = account.pubKeyHex, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + onEose = { _, _ -> + // ChessLobbyLogic handles loading state internally + }, + ) } // Subscribe to user metadata for pubkeys that need it @@ -142,11 +162,15 @@ fun ChessScreen( } val challenges by viewModel.challenges.collectAsState() + val spectatingGames by viewModel.spectatingGames.collectAsState() + val publicGames by viewModel.publicGames.collectAsState() val completedGames by viewModel.completedGames.collectAsState() // Observe metadata changes to trigger recomposition val userMetadata by viewModel.userMetadataCache.metadata.collectAsState() val selectedGameId by viewModel.selectedGameId.collectAsState() val error by viewModel.error.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val syncStatus by viewModel.syncStatus.collectAsState() var showNewGameDialog by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { @@ -176,18 +200,8 @@ fun ChessScreen( verticalAlignment = Alignment.CenterVertically, ) { // Refresh button - IconButton( - onClick = { viewModel.refresh() }, - enabled = !isLoading, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Icon(Icons.Default.Refresh, "Refresh") - } + IconButton(onClick = { viewModel.forceRefresh() }) { + Icon(Icons.Default.Refresh, "Refresh") } // New Game button @@ -202,6 +216,19 @@ fun ChessScreen( } } + // Sync status banner (shown in both lobby and game views) + ChessSyncBanner( + status = syncStatus, + onRetry = { viewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { viewModel.forceRefresh() }, + ) + // Error display error?.let { errorMsg -> Card( @@ -223,31 +250,64 @@ fun ChessScreen( // Main content if (selectedGameId != null) { - val gameState = viewModel.getGameState(selectedGameId!!) + // Set focused game mode - only poll this game, not others + LaunchedEffect(selectedGameId) { + viewModel.setFocusedGame(selectedGameId!!) + } + + // Use stateVersion to ensure recomposition when game state changes + val gameState = + remember(selectedGameId, stateVersion) { + viewModel.getGameState(selectedGameId!!) + } if (gameState != null) { + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = viewModel.wasAccepted(selectedGameId!!) + val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId) + DesktopChessGameLayout( gameState = gameState, opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey), onMoveMade = { from, to, _ -> - viewModel.publishMove(gameState.gameId, from, to) + viewModel.publishMove(gameState.startEventId, from, to) }, - onResign = { viewModel.resign(gameState.gameId) }, - onOfferDraw = { viewModel.offerDraw(gameState.gameId) }, - onAcceptDraw = { viewModel.acceptDraw(gameState.gameId) }, - onDeclineDraw = { viewModel.declineDraw(gameState.gameId) }, + onResign = { viewModel.resign(gameState.startEventId) }, + isSpectatorOverride = isSpectating, ) } } else { + // Clear focused game mode when returning to lobby - poll all games + LaunchedEffect(Unit) { + viewModel.clearFocusedGame() + } + + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(account.pubKeyHex) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + ChessLobby( challenges = challenges, activeGames = activeGames, + spectatingGames = spectatingGames, + publicGames = publicGames, completedGames = completedGames, userPubkey = account.pubKeyHex, - isLoading = isLoading, metadataCache = viewModel.userMetadataCache, onAcceptChallenge = { viewModel.acceptChallenge(it) }, + onOpenOwnChallenge = { viewModel.openOwnChallenge(it) }, + onWatchGame = { viewModel.loadGameAsSpectator(it) }, onSelectGame = { viewModel.selectGame(it) }, + listState = listState, ) } } @@ -269,23 +329,57 @@ fun ChessScreen( */ @Composable private fun ChessLobby( - challenges: List, + challenges: List, activeGames: Map, + spectatingGames: Map, + publicGames: List, completedGames: List, - isLoading: Boolean, userPubkey: String, metadataCache: UserMetadataCache, - onAcceptChallenge: (LiveChessGameChallengeEvent) -> Unit, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onWatchGame: (String) -> Unit, onSelectGame: (String) -> Unit, + listState: LazyListState = rememberLazyListState(), ) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + + if (!hasContent) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game or refresh to load from relays", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - // Active games section + // Active games section (user is participant) if (activeGames.isNotEmpty()) { item { Text( - "Active Games", + "Your Games", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), @@ -293,43 +387,24 @@ private fun ChessLobby( } items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> - ActiveGameCard( + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( gameId = gameId, - opponentPubkey = state.opponentPubkey, opponentName = metadataCache.getDisplayName(state.opponentPubkey), - opponentPicture = metadataCache.getPictureUrl(state.opponentPubkey), isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, + avatar = { + UserAvatar( + userHex = state.opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey), + size = 40.dp, + ) + }, ) } } - // Incoming challenges - val incomingChallenges = challenges.filter { it.opponentPubkey() == userPubkey } - if (incomingChallenges.isNotEmpty()) { - item { - Spacer(Modifier.height(16.dp)) - Text( - "Incoming Challenges", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp), - ) - } - - items(incomingChallenges, key = { it.id }) { challenge -> - ChallengeCard( - challenge = challenge, - challengerName = metadataCache.getDisplayName(challenge.pubKey), - challengerPicture = metadataCache.getPictureUrl(challenge.pubKey), - isIncoming = true, - onAccept = { onAcceptChallenge(challenge) }, - ) - } - } - - // User's outgoing challenges - val outgoingChallenges = challenges.filter { it.pubKey == userPubkey } + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } if (outgoingChallenges.isNotEmpty()) { item { Spacer(Modifier.height(16.dp)) @@ -341,18 +416,78 @@ private fun ChessLobby( ) } - items(outgoingChallenges, key = { it.id }) { challenge -> - val opponentPubkey = challenge.opponentPubkey() - OutgoingChallengeCard( - challenge = challenge, - opponentName = opponentPubkey?.let { metadataCache.getDisplayName(it) }, - opponentPicture = opponentPubkey?.let { metadataCache.getPictureUrl(it) }, + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) }, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + avatar = + challenge.opponentPubkey?.let { pubkey -> + { + UserAvatar( + userHex = pubkey, + pictureUrl = metadataCache.getPictureUrl(pubkey), + size = 40.dp, + ) + } + }, ) } } - // Open challenges from others - val openChallenges = challenges.filter { it.opponentPubkey() == null && it.pubKey != userPubkey } + // Games user is spectating + if (spectatingGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Watching", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> + val moveCount by state.moveHistory.collectAsState() + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount.size, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Open challenges from others (can join) + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(userPubkey) } if (openChallenges.isNotEmpty()) { item { Spacer(Modifier.height(16.dp)) @@ -364,13 +499,41 @@ private fun ChessLobby( ) } - items(openChallenges, key = { it.id }) { challenge -> - ChallengeCard( - challenge = challenge, - challengerName = metadataCache.getDisplayName(challenge.pubKey), - challengerPicture = metadataCache.getPictureUrl(challenge.pubKey), + items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Live games to watch (public games user is not part of) + if (publicGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Live Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(publicGames, key = { "public-${it.gameId}" }) { game -> + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey), + blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey), + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, ) } } @@ -391,283 +554,38 @@ private fun ChessLobby( completedGames.distinctBy { it.gameId }.take(10), key = { "completed-${it.gameId}-${it.completedAt}" }, ) { game -> - CompletedGameCard( - game = game, - userPubkey = userPubkey, - opponentName = metadataCache.getDisplayName(game.opponentPubkey), - opponentPicture = metadataCache.getPictureUrl(game.opponentPubkey), - ) - } - } - - // Empty state or loading - if (activeGames.isEmpty() && challenges.isEmpty()) { - item { - Column( - modifier = Modifier.fillMaxWidth().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (isLoading) { - CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Loading games from relays...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Derive opponent pubkey based on who the user is + val opponentPubkey = + if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey + com.vitorpamplona.amethyst.commons.chess.CompletedGameCard( + opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey), + result = game.result, + didUserWin = game.didUserWin(userPubkey), + isDraw = game.isDraw, + moveCount = game.moveCount, + avatar = { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(opponentPubkey), + size = 40.dp, ) - } else { - Text( - "No games or challenges", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Create a new game or refresh to load from relays", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } -} - -@Composable -private fun ActiveGameCard( - gameId: String, - opponentPubkey: String, - opponentName: String, - opponentPicture: String?, - isYourTurn: Boolean, - onClick: () -> Unit, -) { - // Extract human-readable game name if available - val gameName = - remember(gameId) { - ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12) - } - - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - border = - if (isYourTurn) { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } else { - null - }, - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 40.dp, + }, ) - Column { - Text( - gameName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - Text( - if (isYourTurn) "Your turn" else "Waiting...", - style = MaterialTheme.typography.bodyMedium, - color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - ) - } - } -} - -@Composable -private fun OutgoingChallengeCard( - challenge: LiveChessGameChallengeEvent, - opponentName: String?, - opponentPicture: String?, -) { - val opponentPubkey = challenge.opponentPubkey() - val playerColor = challenge.playerColor() - - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (opponentPubkey != null) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 40.dp, - ) - } - Column { - Text( - if (opponentName != null) { - "Challenge to $opponentName" - } else { - "Open challenge (awaiting opponent)" - }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Text( - "Waiting...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ChallengeCard( - challenge: LiveChessGameChallengeEvent, - challengerName: String, - challengerPicture: String?, - isIncoming: Boolean, - onAccept: () -> Unit, -) { - val borderColor = - if (isIncoming) { - Color(0xFFFF9800) // Orange for incoming - } else { - Color(0xFF4CAF50) // Green for open - } - - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(2.dp, borderColor), - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - UserAvatar( - userHex = challenge.pubKey, - pictureUrl = challengerPicture, - size = 40.dp, - ) - Column { - Text( - if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - val playerColor = challenge.playerColor() - Text( - "Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Button(onClick = onAccept) { - Text("Accept") } } - } -} -@Composable -private fun CompletedGameCard( - game: CompletedGame, - userPubkey: String, - opponentName: String, - opponentPicture: String?, -) { - val resultColor = - when (game.didWin(userPubkey)) { - true -> Color(0xFF4CAF50) // Green for win - false -> Color(0xFFF44336) // Red for loss - null -> Color(0xFF9E9E9E) // Gray for draw - } - - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - ), - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - UserAvatar( - userHex = game.opponentPubkey, - pictureUrl = opponentPicture, - size = 40.dp, - ) - Column { - Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - "${game.moveCount} moves - ${game.termination}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Text( - game.resultText(userPubkey), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = resultColor, - ) + // Bottom padding + item { + Spacer(Modifier.height(16.dp)) } } } /** * Desktop-optimized chess game layout with board on left, info/controls on right + * + * @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use when spectator + * status is determined by which map the game is in (activeGames vs spectatingGames). */ @Composable private fun DesktopChessGameLayout( @@ -676,266 +594,230 @@ private fun DesktopChessGameLayout( opponentPicture: String?, onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, - onOfferDraw: () -> Unit, - onAcceptDraw: () -> Unit, - onDeclineDraw: () -> Unit, + isSpectatorOverride: Boolean? = null, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() val moveHistory by gameState.moveHistory.collectAsState() - val pendingDrawOffer by gameState.pendingDrawOffer.collectAsState() val engine = gameState.engine val playerColor = gameState.playerColor - val gameId = gameState.gameId + val startEventId = gameState.startEventId val opponentPubkey = gameState.opponentPubkey - val hasOpponentDrawOffer = pendingDrawOffer == opponentPubkey - val hasOurDrawOffer = pendingDrawOffer == gameState.playerPubkey + val isSpectator = isSpectatorOverride ?: gameState.isSpectator - Row( + BoxWithConstraints( modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), ) { - // Left side: Chess board - Box( - modifier = Modifier.fillMaxHeight(), - contentAlignment = Alignment.Center, - ) { - InteractiveChessBoard( - engine = engine, - boardSize = 520.dp, - flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, - playerColor = playerColor, - positionVersion = moveHistory.size, - onMoveMade = onMoveMade, - ) - } + // Calculate board size based on available space + // Leave room for the info panel (300dp + 24dp spacing) + val infoPanelWidth = 300.dp + 24.dp + val availableWidth = maxWidth - infoPanelWidth + val availableHeight = maxHeight + // Board should fit within available space, maintaining square aspect ratio + val boardSize = min(availableWidth, availableHeight).coerceIn(200.dp, 520.dp) - // Right side: Game info, moves, controls - Column( - modifier = - Modifier - .width(300.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(24.dp), ) { - // Extract human-readable game name - val gameName = - remember(gameId) { - ChessGameNameGenerator.extractDisplayName(gameId) - } - - // Game info card - Card( - modifier = Modifier.fillMaxWidth(), + // Left side: Chess board + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Show readable game name if available - if (gameName != null) { - Text( - gameName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - "Game Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, + playerColor = playerColor, + isSpectator = isSpectator, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + } + + // Right side: Game info, moves, controls + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Extract human-readable game name + val gameName = + remember(startEventId) { + ChessGameNameGenerator.extractDisplayName(startEventId) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 48.dp, - ) - Column { + // Show readable game name if available + if (gameName != null) { Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, + gameName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, ) + } else { Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + if (isSpectator) { + Text( + "Spectating", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } else { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + + if (isSpectator) { + Text( + "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else { + val isYourTurn = currentTurn == playerColor + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - - // Use currentPosition to derive turn (triggers recomposition on move) - val currentTurn = currentPosition.activeColor - val isYourTurn = currentTurn == playerColor - - Text( - if (isYourTurn) "Your turn" else "Opponent's turn", - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - color = - if (isYourTurn) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) } - } - // Move history card - if (moveHistory.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + // Game controls card (only for participants, not spectators) + if (!isSpectator) { + Card( + modifier = Modifier.fillMaxWidth(), ) { - Text( - "Move History", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) - // Format moves as numbered pairs - val moveText = - moveHistory - .chunked(2) - .mapIndexed { index, pair -> - "${index + 1}. ${pair.joinToString(" ")}" - }.joinToString("\n") - - Text( - moveText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + OutlinedButton( + onClick = onResign, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resign") + } + } } - } - } - - // Draw offer notification (if opponent offered) - if (hasOpponentDrawOffer) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = Color(0xFFFF9800).copy(alpha = 0.15f), - ), - border = BorderStroke(2.dp, Color(0xFFFF9800)), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + } else { + // Spectator info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), ) { - Text( - "Draw Offered", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color(0xFFFF9800), - ) - Text( - "$opponentName has offered a draw", - style = MaterialTheme.typography.bodyMedium, - ) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button( - onClick = onAcceptDraw, - modifier = Modifier.weight(1f), - ) { - Text("Accept") - } - OutlinedButton( - onClick = onDeclineDraw, - modifier = Modifier.weight(1f), - ) { - Text("Decline") - } + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) } } } + + // Game ID (small footer) + Text( + "Game: ${startEventId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - - // Our draw offer pending notification - if (hasOurDrawOffer) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - ), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - "Draw offer sent", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - "Waiting for $opponentName to respond...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // Game controls card - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "Actions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = onOfferDraw, - modifier = Modifier.weight(1f), - enabled = !hasOurDrawOffer && !hasOpponentDrawOffer, - ) { - Text(if (hasOurDrawOffer) "Offer Sent" else "Offer Draw") - } - - OutlinedButton( - onClick = onResign, - modifier = Modifier.weight(1f), - ) { - Text("Resign") - } - } - } - } - - // Game ID (small footer) - Text( - "Game: ${gameId.take(16)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt index 027bb60db..7774a57e3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -20,133 +20,141 @@ */ package com.vitorpamplona.amethyst.desktop.chess +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder import com.vitorpamplona.amethyst.commons.data.UserMetadataCache import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent /** - * Desktop implementation of ChessEventPublisher. + * Desktop implementation of ChessEventPublisher using Jester protocol. * Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content */ class DesktopChessPublisher( private val account: AccountState.LoggedIn, private val relayManager: DesktopRelayConnectionManager, ) : ChessEventPublisher { - override suspend fun publishChallenge( - gameId: String, + private val broadcaster = ChessEventBroadcaster(relayManager.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[DesktopChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( playerColor: Color, opponentPubkey: String?, - timeControl: String?, - ): Boolean = + ): String? = try { val template = - LiveChessGameChallengeEvent.build( - gameId = gameId, - playerColor = playerColor, - opponentPubkey = opponentPubkey, - timeControl = timeControl, - ) + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - true + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null } catch (e: Exception) { - false + println("[DesktopChessPublisher] publishStart failed: ${e.message}") + null } - override suspend fun publishAccept( - gameId: String, - challengeEventId: String, - challengerPubkey: String, - ): Boolean = + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = try { val template = - LiveChessGameAcceptEvent.build( - gameId = gameId, - challengeEventId = challengeEventId, - challengerPubkey = challengerPubkey, - ) - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - true - } catch (e: Exception) { - false - } - - override suspend fun publishMove(move: ChessMoveEvent): Boolean = - try { - val template = - LiveChessMoveEvent.build( - gameId = move.gameId, - moveNumber = move.moveNumber, - san = move.san, + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, fen = move.fen, + history = move.history, opponentPubkey = move.opponentPubkey, ) val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - true + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null } catch (e: Exception) { - false + println("[DesktopChessPublisher] publishMove failed: ${e.message}") + null } + /** + * Publish a game end event (includes result in content). + */ override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = try { val template = - LiveChessGameEndEvent.build( - gameId = gameEnd.gameId, + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, result = gameEnd.result, termination = gameEnd.termination, - winnerPubkey = gameEnd.winnerPubkey, - opponentPubkey = gameEnd.opponentPubkey, - pgn = gameEnd.pgn ?: "", ) val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - true + broadcastToChessRelays(signedEvent) } catch (e: Exception) { + println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}") false } - override suspend fun publishDrawOffer( - gameId: String, - opponentPubkey: String, - message: String?, - ): Boolean = - try { - val template = - LiveChessDrawOfferEvent.build( - gameId = gameId, - opponentPubkey = opponentPubkey, - message = message ?: "", - ) - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - true - } catch (e: Exception) { - false - } - - override fun getWriteRelayCount(): Int = relayManager.connectedRelays.value.size + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size } /** - * Desktop implementation of ChessRelayFetcher. - * Uses ChessRelayFetchHelper for one-shot relay queries. + * Desktop implementation of ChessRelayFetcher using Jester protocol. + * + * Hybrid approach (like Android's LocalCache pattern): + * 1. Query DesktopChessEventCache first (populated by subscription) + * 2. Also do relay queries and merge results for completeness + * + * This solves the "missing games" issue where pure one-shot relay queries + * would miss events due to timing or slow relays. */ class DesktopRelayFetcher( private val relayManager: DesktopRelayConnectionManager, @@ -154,266 +162,223 @@ class DesktopRelayFetcher( ) : ChessRelayFetcher { private val fetchHelper = ChessRelayFetchHelper(relayManager.client) - override suspend fun fetchGameEvents(gameId: String): ChessGameEvents { - val filters = ChessFilterBuilder.gameEventsFilter(gameId) - val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) } + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) } - val events = fetchHelper.fetchEvents(relayFilters) + override fun getRelayUrls(): List { + println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } - val challengeEvent = - events - .filterIsInstance() - .firstOrNull { it.gameId() == gameId } - ?: events - .filter { it.kind == LiveChessGameChallengeEvent.KIND } - .mapNotNull { event -> - LiveChessGameChallengeEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ).takeIf { it.gameId() == gameId } - }.firstOrNull() + /** + * Fetch game events - queries cache first, supplements with relay query. + * Similar to Android's LocalCache.addressables pattern. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId") - val acceptEvent = - events - .filterIsInstance() - .firstOrNull { it.gameId() == gameId } - ?: events - .filter { it.kind == LiveChessGameAcceptEvent.KIND } - .mapNotNull { event -> - LiveChessGameAcceptEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ).takeIf { it.gameId() == gameId } - }.firstOrNull() + // Query cache first (populated by subscription) + val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId) + println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}") - val moveEvents = - events - .filterIsInstance() - .filter { it.gameId() == gameId } - .ifEmpty { - events - .filter { it.kind == LiveChessMoveEvent.KIND } - .mapNotNull { event -> - LiveChessMoveEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ).takeIf { it.gameId() == gameId } - } - } + // Also do relay query to catch any new events and populate cache + // Filter 1: Fetch the start event by its ID + val startEventFilter = + Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters) - val endEvent = - events - .filterIsInstance() - .firstOrNull { it.gameId() == gameId } - ?: events - .filter { it.kind == LiveChessGameEndEvent.KIND } - .mapNotNull { event -> - LiveChessGameEndEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ).takeIf { it.gameId() == gameId } - }.firstOrNull() + // Add relay events to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } - val drawOffers = - events - .filterIsInstance() - .filter { it.gameId() == gameId } - .ifEmpty { - events - .filter { it.kind == LiveChessDrawOfferEvent.KIND } - .mapNotNull { event -> - LiveChessDrawOfferEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ).takeIf { it.gameId() == gameId } - } - } + // Convert to JesterEvents and merge + val relayJesterEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } - return ChessGameEvents( - challenge = challengeEvent, - accept = acceptEvent, - moves = moveEvents, - end = endEvent, - drawOffers = drawOffers, + val relayStartEvent = + relayJesterEvents + .firstOrNull { it.isStartEvent() && it.id == startEventId } + + val relayMoves = + relayJesterEvents + .filter { it.isMoveEvent() && it.startEventId() == startEventId } + + // Merge: prefer cache start event, merge all moves + val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id } + + println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}") + + return JesterGameEvents( + startEvent = cachedEvents.startEvent ?: relayStartEvent, + moves = allMoves, ) } - override suspend fun fetchChallenges(): List { - val filters = ChessFilterBuilder.challengesFilter(userPubkey) - val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) } + /** + * Fetch start events (challenges) - queries cache first. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays") - val events = fetchHelper.fetchEvents(relayFilters) - - return events - .filterIsInstance() - .ifEmpty { - events - .filter { it.kind == LiveChessGameChallengeEvent.KIND } - .map { event -> - LiveChessGameChallengeEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } + // Query cache for start events that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val cachedStartEvents = + DesktopChessEventCache.queryStartEvents { event -> + event.opponentPubkey() == null || + event.opponentPubkey() == userPubkey || + event.pubKey == userPubkey } + + println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache") + + // Also do relay query to catch any new challenges + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(filter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + val relayStartEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .filter { it.isStartEvent() } + + // Merge and deduplicate + val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id } + println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge") + return allStartEvents } + /** + * Fetch user game IDs (startEventIds) - queries cache first. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays") + + val gameIds = mutableSetOf() + + // Query cache for start events created by user or directed at user + DesktopChessEventCache + .queryStartEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { gameIds.add(it.id) } + + // Query cache for moves by user or tagged with user + DesktopChessEventCache + .queryMoveEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { event -> + event.startEventId()?.let { gameIds.add(it) } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache") + + // Also do relay query to catch any new game IDs + val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(userFilter, taggedFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + // Extract game IDs from relay events + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .forEach { event -> + if (event.isStartEvent()) { + gameIds.add(event.id) + } else if (event.isMoveEvent()) { + event.startEventId()?.let { gameIds.add(it) } + } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge") + return gameIds + } + + /** + * Fetch recent games for spectating. + */ override suspend fun fetchRecentGames(): List { - val filters = ChessFilterBuilder.recentGamesFilter() - val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) } + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } val events = fetchHelper.fetchEvents(relayFilters) - // Group by game ID and create summaries - val gameIds = + // Cache events so they're available when watching + events.forEach { DesktopChessEventCache.add(it) } + + // Convert to JesterEvents + val jesterEvents = events - .filter { it.kind == LiveChessMoveEvent.KIND } - .mapNotNull { event -> - val move = - if (event is LiveChessMoveEvent) { - event - } else { - LiveChessMoveEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } - move.gameId() - }.distinct() + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } - return gameIds.mapNotNull { gameId -> - // Find challenge and accept for this game - val challenge = - events - .filter { it.kind == LiveChessGameChallengeEvent.KIND } - .mapNotNull { event -> - val e = - if (event is LiveChessGameChallengeEvent) { - event - } else { - LiveChessGameChallengeEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } - e.takeIf { it.gameId() == gameId } - }.firstOrNull() ?: return@mapNotNull null + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } - val accept = - events - .filter { it.kind == LiveChessGameAcceptEvent.KIND } - .mapNotNull { event -> - val e = - if (event is LiveChessGameAcceptEvent) { - event - } else { - LiveChessGameAcceptEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } - e.takeIf { it.gameId() == gameId } - }.firstOrNull() + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } - val challengerColor = challenge.playerColor() ?: Color.WHITE - val whitePubkey = + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = if (challengerColor == Color.WHITE) { - challenge.pubKey + challengerPubkey to opponentPubkey } else { - accept?.pubKey ?: return@mapNotNull null - } - val blackPubkey = - if (challengerColor == Color.BLACK) { - challenge.pubKey - } else { - accept?.pubKey ?: return@mapNotNull null + opponentPubkey to challengerPubkey } - val moves = - events - .filter { it.kind == LiveChessMoveEvent.KIND } - .mapNotNull { event -> - val m = - if (event is LiveChessMoveEvent) { - event - } else { - LiveChessMoveEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } - m.takeIf { it.gameId() == gameId } - } - - val endEvent = - events - .filter { it.kind == LiveChessGameEndEvent.KIND } - .mapNotNull { event -> - val e = - if (event is LiveChessGameEndEvent) { - event - } else { - LiveChessGameEndEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - } - e.takeIf { it.gameId() == gameId } - }.firstOrNull() - - val lastMove = moves.maxByOrNull { it.createdAt } + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null RelayGameSummary( - gameId = gameId, + startEventId = startEventId, whitePubkey = whitePubkey, blackPubkey = blackPubkey, - moveCount = moves.size, - lastMoveTime = lastMove?.createdAt ?: challenge.createdAt, - isActive = endEvent == null, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt new file mode 100644 index 000000000..34009233a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Local event cache for Desktop chess events using Jester protocol. + * + * Mimics Android's LocalCache.addressables behavior: + * - Stores events by ID (deduplication) + * - Queryable by kind with custom filters + * - Thread-safe via ConcurrentHashMap + * - Populated from subscription callbacks + * - Persists across fetcher queries (unlike one-shot relay queries) + * + * Jester Protocol Notes: + * - All chess events use kind 30 + * - Start events: content.kind=0, e-tag references START_POSITION_HASH + * - Move events: content.kind=1, e-tags=[startEventId, headEventId] + */ +object DesktopChessEventCache { + // Store all Jester events by ID for deduplication + @PublishedApi + internal val events = ConcurrentHashMap() + + // Index start events by event ID (for quick game lookup) + private val startEvents = ConcurrentHashMap() + + // Index move events by startEventId (game ID) for quick game reconstruction + private val movesByGame = ConcurrentHashMap>() + + /** + * Add an event to the cache. + * Returns true if the event was new, false if it was a duplicate. + */ + fun add(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + + val jesterEvent = event.toJesterEvent() ?: return false + return addJesterEvent(jesterEvent) + } + + /** + * Add a JesterEvent directly. + */ + fun addJesterEvent(event: JesterEvent): Boolean { + val isNew = events.putIfAbsent(event.id, event) == null + if (isNew) { + if (event.isStartEvent()) { + startEvents[event.id] = event + } else if (event.isMoveEvent()) { + val startId = event.startEventId() + if (startId != null) { + movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id) + } + } + } + return isNew + } + + /** + * Get an event by ID. + */ + fun get(id: String): JesterEvent? = events[id] + + /** + * Get all start events (challenges). + */ + fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Get all move events for a specific game. + */ + fun getMovesForGame(startEventId: String): List { + val moveIds = movesByGame[startEventId] ?: return emptyList() + return moveIds.mapNotNull { events[it] } + } + + /** + * Get all events for a specific game (start + moves). + */ + fun getGameEvents(startEventId: String): JesterGameEvents { + val startEvent = startEvents[startEventId] + val moves = getMovesForGame(startEventId) + return JesterGameEvents(startEvent, moves) + } + + /** + * Query events with custom filter. + */ + fun query(filter: (JesterEvent) -> Boolean): List = events.values.filter(filter) + + /** + * Query start events with custom filter. + */ + fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Query move events with custom filter. + */ + fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List = events.values.filter { it.isMoveEvent() && filter(it) } + + /** + * Get all game IDs (startEventIds) in the cache. + */ + fun getAllGameIds(): Set = startEvents.keys.toSet() + + /** + * Get count of events. + */ + fun size(): Int = events.size + + /** + * Get count of start events. + */ + fun startEventCount(): Int = startEvents.size + + /** + * Clear all cached events. + */ + fun clear() { + events.clear() + startEvents.clear() + movesByGame.clear() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt deleted file mode 100644 index 02f6a55a4..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModel.kt +++ /dev/null @@ -1,959 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.chess - -import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults -import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate -import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController -import com.vitorpamplona.amethyst.commons.data.UserMetadataCache -import com.vitorpamplona.amethyst.desktop.account.AccountState -import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip64Chess.ChessEngine -import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator -import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent -import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.GameResult -import com.vitorpamplona.quartz.nip64Chess.GameTermination -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent -import com.vitorpamplona.quartz.nip64Chess.PieceType -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * Desktop ViewModel for managing chess game state and event publishing. - * Adapts the Android ChessViewModel patterns for Desktop's relay architecture. - */ -class DesktopChessViewModel( - private val account: AccountState.LoggedIn, - private val relayManager: DesktopRelayConnectionManager, - private val scope: CoroutineScope, -) { - companion object { - const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L - const val MAX_RETRIES = 3 - const val RETRY_DELAY_MS = 1000L - } - - // Active games being played - private val _activeGames = MutableStateFlow>(emptyMap()) - val activeGames: StateFlow> = _activeGames.asStateFlow() - - // Pending challenges - private val _challenges = MutableStateFlow>(emptyList()) - val challenges: StateFlow> = _challenges.asStateFlow() - - // Badge count - private val _badgeCount = MutableStateFlow(0) - val badgeCount: StateFlow = _badgeCount.asStateFlow() - - // Currently selected game - private val _selectedGameId = MutableStateFlow(null) - val selectedGameId: StateFlow = _selectedGameId.asStateFlow() - - // Error state - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() - - // Loading state - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() - - // Refresh key - incrementing this triggers re-subscription - private val _refreshKey = MutableStateFlow(0) - val refreshKey: StateFlow = _refreshKey.asStateFlow() - - // Completed games history - private val _completedGames = MutableStateFlow>(emptyList()) - val completedGames: StateFlow> = _completedGames.asStateFlow() - - // Shared user metadata cache - val userMetadataCache = UserMetadataCache() - - // Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber)) - private val pendingMoves = mutableMapOf>>() - - // Challenge events indexed by gameId for lookup during accept processing - private val challengesByGameId = mutableMapOf() - - // Pending accept events waiting for challenge to arrive (gameId -> accept event) - private val pendingAccepts = mutableMapOf() - - // Track event IDs we've already processed to avoid duplicates - // Uses ConcurrentHashMap for thread-safe check-and-add - private val processedEventIds = - java.util.concurrent.ConcurrentHashMap - .newKeySet() - - // Track games currently being created to prevent race conditions - private val gamesBeingCreated = mutableSetOf() - - // Subscription controller for dynamic filter updates - private var subscriptionController: ChessSubscriptionController? = null - - // Polling delegate for periodic refresh (fallback when relay events are missed) - private val pollingDelegate = - ChessPollingDelegate( - config = ChessPollingDefaults.desktop, - scope = scope, - onRefreshGames = { gameIds -> refreshGamesFromRelay(gameIds) }, - onRefreshChallenges = { /* Subscriptions handle challenges */ }, - onCleanup = { cleanupExpiredChallenges() }, - ) - - init { - // Start polling for move updates - pollingDelegate.start() - } - - /** - * Set the subscription controller for dynamic filter updates. - * Should be called after creating the ViewModel. - */ - fun setSubscriptionController(controller: ChessSubscriptionController) { - subscriptionController = controller - } - - /** - * Notify subscription controller and polling delegate of active game changes. - * Call this whenever _activeGames is modified. - */ - private fun notifyActiveGamesChanged() { - val gameIds = _activeGames.value.keys - subscriptionController?.updateActiveGames( - activeGameIds = gameIds, - spectatingGameIds = emptySet(), - ) - pollingDelegate.setActiveGameIds(gameIds) - } - - /** - * Process incoming chess event from relay subscription - */ - fun handleIncomingEvent(event: Event) { - // Skip already processed events (atomic check-and-add) - if (!processedEventIds.add(event.id)) return - - // Check by kind since events may not be cast to specific types - when (event.kind) { - MetadataEvent.KIND -> { - handleMetadataEvent(event) - return - } - LiveChessGameChallengeEvent.KIND -> { - if (event is LiveChessGameChallengeEvent) { - handleNewChallenge(event) - } else { - // Manually parse if not already the right type - val challenge = - LiveChessGameChallengeEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - handleNewChallenge(challenge) - } - } - LiveChessGameAcceptEvent.KIND -> { - if (event is LiveChessGameAcceptEvent) { - handleGameAccepted(event) - } else { - val accept = - LiveChessGameAcceptEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - handleGameAccepted(accept) - } - } - LiveChessMoveEvent.KIND -> { - if (event is LiveChessMoveEvent) { - handleIncomingMove(event) - } else { - val move = - LiveChessMoveEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - handleIncomingMove(move) - } - } - LiveChessGameEndEvent.KIND -> { - if (event is LiveChessGameEndEvent) { - handleGameEnded(event) - } else { - val end = - LiveChessGameEndEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - handleGameEnded(end) - } - } - LiveChessDrawOfferEvent.KIND -> { - if (event is LiveChessDrawOfferEvent) { - handleDrawOffer(event) - } else { - val drawOffer = - LiveChessDrawOfferEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - handleDrawOffer(drawOffer) - } - } - } - } - - private fun handleMetadataEvent(event: Event) { - try { - val metadataEvent = - MetadataEvent( - event.id, - event.pubKey, - event.createdAt, - event.tags, - event.content, - event.sig, - ) - val metadata = metadataEvent.contactMetaData() - if (metadata != null) { - userMetadataCache.put(event.pubKey, metadata) - } - } catch (e: Exception) { - // Ignore parse errors - } - } - - private fun handleIncomingMove(event: LiveChessMoveEvent) { - println("[Chess] Received move event: gameId=${event.gameId()}, san=${event.san()}, moveNum=${event.moveNumber()}, from=${event.pubKey.take(8)}") - - val gameId = event.gameId() ?: return - val san = event.san() ?: return - val fen = event.fen() ?: return - val moveNumber = event.moveNumber() - - val gameState = _activeGames.value[gameId] - - if (gameState == null) { - // Game doesn't exist yet - buffer the move for later (including our own for FEN sync) - println("[Chess] Game $gameId not found, buffering move $san (fen for sync)") - val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() } - moveList.add(Triple(san, fen, moveNumber)) - return - } - - // Don't process our own moves for active games (already applied locally) - if (event.pubKey == account.pubKeyHex) { - println("[Chess] Skipping own move (already applied locally)") - return - } - - if (event.pubKey != gameState.opponentPubkey) { - println("[Chess] Move not from opponent (expected ${gameState.opponentPubkey.take(8)}, got ${event.pubKey.take(8)})") - return - } - - println("[Chess] Applying opponent move: $san (move #$moveNumber)") - gameState.applyOpponentMove(san, fen, moveNumber) - updateBadgeCount() - } - - private fun handleGameAccepted(event: LiveChessGameAcceptEvent) { - val gameId = event.gameId() ?: return - - // Skip if game already exists - if (_activeGames.value.containsKey(gameId)) return - - val challengerPubkey = event.challengerPubkey() ?: return - val accepterPubkey = event.pubKey - - // Check if we have the challenge event for color info - val challengeEvent = challengesByGameId[gameId] - if (challengeEvent == null) { - // Challenge hasn't arrived yet - buffer this accept for later - if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) { - pendingAccepts[gameId] = event - } - return - } - - // Handle if user is the challenger or accepter - if (challengerPubkey == account.pubKeyHex) { - startGameFromAcceptance(event, isChallenger = true) - } else if (accepterPubkey == account.pubKeyHex) { - startGameFromAcceptance(event, isChallenger = false) - } - } - - private fun handleGameEnded(event: LiveChessGameEndEvent) { - val gameId = event.gameId() ?: return - - // Store game in history before removing (skip if already in completed list) - val alreadyCompleted = _completedGames.value.any { it.gameId == gameId } - val gameState = _activeGames.value[gameId] - if (gameState != null && !alreadyCompleted) { - val completedGame = - CompletedGame( - gameId = gameId, - opponentPubkey = gameState.opponentPubkey, - playerColor = gameState.playerColor, - result = event.result() ?: "?", - termination = event.termination() ?: "unknown", - winnerPubkey = event.winnerPubkey(), - completedAt = event.createdAt, - moveCount = gameState.moveHistory.value.size, - ) - _completedGames.value = listOf(completedGame) + _completedGames.value - } - - // Remove from active games - if (_activeGames.value.containsKey(gameId)) { - _activeGames.value = _activeGames.value - gameId - notifyActiveGamesChanged() - } - - // Clean up challenge references - _challenges.value = _challenges.value.filter { it.gameId() != gameId } - challengesByGameId.remove(gameId) - pendingAccepts.remove(gameId) - pendingMoves.remove(gameId) - - updateBadgeCount() - } - - private fun handleDrawOffer(event: LiveChessDrawOfferEvent) { - // Only process draw offers from opponent - if (event.pubKey == account.pubKeyHex) return - - val gameId = event.gameId() ?: return - val gameState = _activeGames.value[gameId] ?: return - - // Verify this is from our opponent in this game - if (event.pubKey != gameState.opponentPubkey) return - - // Pass to game state to track - gameState.receiveDrawOffer(event.pubKey) - updateBadgeCount() - } - - private fun handleNewChallenge(event: LiveChessGameChallengeEvent) { - val gameId = event.gameId() ?: return - val opponentPubkey = event.opponentPubkey() - val isUserChallenge = event.pubKey == account.pubKeyHex - val isOpenChallenge = opponentPubkey == null - val isDirectedAtUser = opponentPubkey == account.pubKeyHex - - // Request metadata for challenger - userMetadataCache.request(event.pubKey) - opponentPubkey?.let { userMetadataCache.request(it) } - - // Always store in lookup map for accept event processing (even if game exists) - challengesByGameId[gameId] = event - - // Skip if game already exists (challenge was already accepted) - if (_activeGames.value.containsKey(gameId)) return - - // Check if there's a pending accept waiting for this challenge - val pendingAccept = pendingAccepts.remove(gameId) - if (pendingAccept != null) { - // Now we can properly process the accept - val isChallenger = event.pubKey == account.pubKeyHex - val isAccepter = pendingAccept.pubKey == account.pubKeyHex - if (isChallenger || isAccepter) { - startGameFromAcceptance(pendingAccept, isChallenger = isChallenger) - return // Don't add to challenges list since game is starting - } - } - - // Show challenges that are: - // - Created by user (their outgoing challenges) - // - Open challenges (anyone can accept) - // - Directed at user (incoming challenges) - if (isUserChallenge || isOpenChallenge || isDirectedAtUser) { - val currentChallenges = _challenges.value.toMutableList() - // Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts - val isDuplicateEvent = currentChallenges.any { it.id == event.id } - val isDuplicateGame = currentChallenges.any { it.gameId() == gameId } - if (!isDuplicateEvent && !isDuplicateGame) { - currentChallenges.add(event) - _challenges.value = currentChallenges - updateBadgeCount() - } - } - } - - /** - * Create a new chess challenge - */ - fun createChallenge( - opponentPubkey: String? = null, - playerColor: Color = Color.WHITE, - timeControl: String? = null, - ) { - val gameId = generateGameId() - - scope.launch(Dispatchers.IO) { - val success = - retryWithBackoff { - val template = - LiveChessGameChallengeEvent.build( - gameId = gameId, - playerColor = playerColor, - opponentPubkey = opponentPubkey, - timeControl = timeControl, - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (success) { - _error.value = null - } else { - _error.value = "Failed to create challenge after $MAX_RETRIES attempts" - } - } - } - - /** - * Accept a chess challenge - */ - fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) { - val gameId = challengeEvent.gameId() ?: return - val challengerPubkey = challengeEvent.pubKey - val challengerColor = challengeEvent.playerColor() ?: Color.WHITE - val playerColor = challengerColor.opposite() - - // Store in lookup map for consistent access - challengesByGameId[gameId] = challengeEvent - - // Mark as being created to prevent duplicate from relay echo - if (!gamesBeingCreated.add(gameId)) return // Already being created - - scope.launch(Dispatchers.IO) { - val success = - retryWithBackoff { - val template = - LiveChessGameAcceptEvent.build( - gameId = gameId, - challengeEventId = challengeEvent.id, - challengerPubkey = challengerPubkey, - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (success) { - // Check if game was already created by relay echo while we were broadcasting - if (_activeGames.value.containsKey(gameId)) { - gamesBeingCreated.remove(gameId) - _selectedGameId.value = gameId - _challenges.value = _challenges.value.filter { it.id != challengeEvent.id } - _error.value = null - return@launch - } - - val engine = ChessEngine() - engine.reset() - - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = account.pubKeyHex, - opponentPubkey = challengerPubkey, - playerColor = playerColor, - engine = engine, - ) - - _activeGames.value = _activeGames.value + (gameId to gameState) - notifyActiveGamesChanged() - gamesBeingCreated.remove(gameId) - _selectedGameId.value = gameId - _challenges.value = _challenges.value.filter { it.id != challengeEvent.id } - _error.value = null - } else { - gamesBeingCreated.remove(gameId) - _error.value = "Failed to accept challenge" - } - } - } - - private fun startGameFromAcceptance( - acceptEvent: LiveChessGameAcceptEvent, - isChallenger: Boolean, - ) { - scope.launch(Dispatchers.IO) { - val gameId = acceptEvent.gameId() ?: return@launch - val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch - val accepterPubkey = acceptEvent.pubKey - - println("[Chess] startGameFromAcceptance: gameId=$gameId, isChallenger=$isChallenger") - println("[Chess] Pending moves in buffer: ${pendingMoves.keys}") - - // Skip if game already exists or is being created - if (_activeGames.value.containsKey(gameId)) { - println("[Chess] Game $gameId already exists, skipping") - return@launch - } - if (!gamesBeingCreated.add(gameId)) { - println("[Chess] Game $gameId already being created, skipping") - return@launch // Returns false if already present - } - - val opponentPubkey: String - val playerColor: Color - - // Use challengesByGameId for reliable lookup (not affected by UI filtering) - val challengeEvent = challengesByGameId[gameId] - - if (isChallenger) { - // User is challenger, opponent is accepter - opponentPubkey = accepterPubkey - playerColor = challengeEvent?.playerColor() ?: Color.WHITE - } else { - // User is accepter, opponent is challenger - opponentPubkey = challengerPubkey - // Accepter gets opposite color of challenger - val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE - playerColor = challengerColor.opposite() - } - - val engine = ChessEngine() - engine.reset() - - val gameState = - LiveChessGameState( - gameId = gameId, - playerPubkey = account.pubKeyHex, - opponentPubkey = opponentPubkey, - playerColor = playerColor, - engine = engine, - ) - - println("[Chess] Adding game $gameId to active games, opponent=$opponentPubkey") - _activeGames.value = _activeGames.value + (gameId to gameState) - notifyActiveGamesChanged() - _challenges.value = _challenges.value.filter { it.gameId() != gameId } - gamesBeingCreated.remove(gameId) - - // Apply any pending moves that arrived before the game was created - println("[Chess] About to apply pending moves for $gameId, buffer has: ${pendingMoves[gameId]?.size ?: 0} moves") - applyPendingMoves(gameId, gameState) - } - } - - private fun applyPendingMoves( - gameId: String, - gameState: LiveChessGameState, - ) { - val moves = pendingMoves.remove(gameId) - if (moves == null) { - println("[Chess] No pending moves for game $gameId") - return - } - - println("[Chess] Processing ${moves.size} pending moves for game $gameId") - - // Sort by move number to find the latest move - val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE } - - if (sortedMoves.isEmpty()) return - - // Find the move with highest move number - its FEN has the current board state - val latestMove = sortedMoves.maxByOrNull { it.third ?: 0 } - if (latestMove != null) { - val (san, fen, moveNumber) = latestMove - println("[Chess] Syncing to latest position from move #$moveNumber: $san") - println("[Chess] FEN: $fen") - - // Use forceResync to set the board to the correct position - gameState.forceResync(fen) - - // Mark all received move numbers as processed to avoid duplicates - sortedMoves.forEach { (_, _, num) -> - if (num != null) { - gameState.markMovesAsReceived(setOf(num)) - } - } - - println("[Chess] Board synced to position after ${sortedMoves.size} moves") - } - - updateBadgeCount() - } - - /** - * Make and publish a move - */ - fun publishMove( - gameId: String, - from: String, - to: String, - ) { - val gameState = _activeGames.value[gameId] ?: return - - // Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN) - val (targetSquare, promotion) = parsePromotionFromTarget(to) - - val moveResult = gameState.makeMove(from, targetSquare, promotion) - if (moveResult != null) { - publishMoveEvent(gameId, moveResult) - } - } - - /** - * Parse promotion piece from target square string. - * e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null) - */ - private fun parsePromotionFromTarget(to: String): Pair { - if (to.length == 3) { - val square = to.substring(0, 2) - val promotion = - when (to[2].lowercaseChar()) { - 'q' -> PieceType.QUEEN - 'r' -> PieceType.ROOK - 'b' -> PieceType.BISHOP - 'n' -> PieceType.KNIGHT - else -> null - } - return square to promotion - } - return to to null - } - - private fun publishMoveEvent( - gameId: String, - moveEvent: ChessMoveEvent, - ) { - scope.launch(Dispatchers.IO) { - val success = - retryWithBackoff { - val template = - LiveChessMoveEvent.build( - gameId = moveEvent.gameId, - moveNumber = moveEvent.moveNumber, - san = moveEvent.san, - fen = moveEvent.fen, - opponentPubkey = moveEvent.opponentPubkey, - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (!success) { - _error.value = "Failed to publish move" - } - } - } - - /** - * Resign from a game - */ - fun resign(gameId: String) { - val gameState = _activeGames.value[gameId] ?: return - - scope.launch(Dispatchers.IO) { - val endData = gameState.resign() - - val success = - retryWithBackoff { - val template = - LiveChessGameEndEvent.build( - gameId = endData.gameId, - result = endData.result, - termination = endData.termination, - winnerPubkey = endData.winnerPubkey, - opponentPubkey = endData.opponentPubkey, - pgn = endData.pgn ?: "", - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (success) { - // Store in history (skip if already completed) - val alreadyCompleted = _completedGames.value.any { it.gameId == gameId } - if (!alreadyCompleted) { - val completedGame = - CompletedGame( - gameId = gameId, - opponentPubkey = gameState.opponentPubkey, - playerColor = gameState.playerColor, - result = endData.result.notation, - termination = endData.termination.name.lowercase(), - winnerPubkey = endData.winnerPubkey, - completedAt = TimeUtils.now(), - moveCount = gameState.moveHistory.value.size, - ) - _completedGames.value = listOf(completedGame) + _completedGames.value - } - _activeGames.value = _activeGames.value - gameId - notifyActiveGamesChanged() - _error.value = null - } else { - _error.value = "Failed to resign" - } - } - } - - /** - * Offer draw - sends a draw offer event that opponent can accept or decline - */ - fun offerDraw(gameId: String) { - val gameState = _activeGames.value[gameId] ?: return - - scope.launch(Dispatchers.IO) { - val drawOffer = gameState.offerDraw() - - val success = - retryWithBackoff { - val template = - LiveChessDrawOfferEvent.build( - gameId = drawOffer.gameId, - opponentPubkey = drawOffer.opponentPubkey, - message = drawOffer.message ?: "", - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (!success) { - _error.value = "Failed to offer draw" - } - } - } - - /** - * Accept opponent's draw offer - */ - fun acceptDraw(gameId: String) { - val gameState = _activeGames.value[gameId] ?: return - - scope.launch(Dispatchers.IO) { - val endData = gameState.acceptDraw() - if (endData == null) { - _error.value = "No draw offer to accept" - return@launch - } - - val success = - retryWithBackoff { - val template = - LiveChessGameEndEvent.build( - gameId = endData.gameId, - result = endData.result, - termination = endData.termination, - winnerPubkey = endData.winnerPubkey, - opponentPubkey = endData.opponentPubkey, - pgn = endData.pgn ?: "", - ) - - val signedEvent = account.signer.sign(template) - relayManager.broadcastToAll(signedEvent) - } - - if (success) { - // Store in history and remove from active (skip if already completed) - val alreadyCompleted = _completedGames.value.any { it.gameId == gameId } - if (!alreadyCompleted) { - val completedGame = - CompletedGame( - gameId = gameId, - opponentPubkey = gameState.opponentPubkey, - playerColor = gameState.playerColor, - result = GameResult.DRAW.notation, - termination = GameTermination.DRAW_AGREEMENT.name.lowercase(), - winnerPubkey = null, - completedAt = TimeUtils.now(), - moveCount = gameState.moveHistory.value.size, - ) - _completedGames.value = listOf(completedGame) + _completedGames.value - } - _activeGames.value = _activeGames.value - gameId - notifyActiveGamesChanged() - _error.value = null - } else { - _error.value = "Failed to accept draw" - } - } - } - - /** - * Decline opponent's draw offer - */ - fun declineDraw(gameId: String) { - val gameState = _activeGames.value[gameId] ?: return - gameState.declineDraw() - } - - fun selectGame(gameId: String?) { - _selectedGameId.value = gameId - } - - fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] - - fun clearError() { - _error.value = null - } - - /** - * Trigger a refresh of chess data from relays. - * Preserves existing game state (including local moves) while refreshing challenges. - */ - fun refresh() { - _isLoading.value = true - - // Clear challenges - they'll be rebuilt from relay events - _challenges.value = emptyList() - - // Clear event tracking caches to allow re-processing - processedEventIds.clear() - challengesByGameId.clear() - pendingAccepts.clear() - pendingMoves.clear() - gamesBeingCreated.clear() - - // DON'T clear _activeGames - preserve existing game state with local moves - // Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers - - _refreshKey.value++ - } - - /** - * Called when EOSE is received from relay, indicating initial load complete - */ - fun onLoadComplete() { - _isLoading.value = false - } - - /** - * Refresh game states - polling callback. - * Triggers a subscription refresh to catch any missed events. - * The fixed filter (with opponent authors) will fetch opponent moves. - */ - private suspend fun refreshGamesFromRelay(gameIds: Set) { - if (gameIds.isEmpty()) return - - // Trigger subscription controller refresh to re-fetch with current filters - // The filter now includes opponent pubkeys as authors, so it will catch their moves - subscriptionController?.forceRefresh() - } - - /** - * Clean up expired challenges (older than 24 hours) - */ - private fun cleanupExpiredChallenges() { - val now = TimeUtils.now() - _challenges.value = - _challenges.value.filter { challenge -> - (now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS - } - } - - private fun updateBadgeCount() { - val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex } - val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } - _badgeCount.value = incomingChallenges + yourTurnGames - } - - private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean { - var attempt = 0 - while (attempt < MAX_RETRIES) { - try { - operation() - return true - } catch (e: Exception) { - attempt++ - if (attempt < MAX_RETRIES) { - delay(RETRY_DELAY_MS * attempt) - } - } - } - return false - } - - private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now()) -} - -/** - * Represents a completed chess game for history display - */ -data class CompletedGame( - val gameId: String, - val opponentPubkey: String, - val playerColor: Color, - val result: String, - val termination: String, - val winnerPubkey: String?, - val completedAt: Long, - val moveCount: Int, -) { - fun didWin(playerPubkey: String): Boolean? = - when { - result == "1/2-1/2" -> null // Draw - winnerPubkey == playerPubkey -> true - winnerPubkey != null -> false - else -> null - } - - fun resultText(playerPubkey: String): String = - when (didWin(playerPubkey)) { - true -> "Won" - false -> "Lost" - null -> "Draw" - } -} 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 11730b9a8..7ae6555c3 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 @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus import com.vitorpamplona.amethyst.commons.chess.CompletedGame import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.commons.data.UserMetadataCache @@ -31,7 +32,9 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow @@ -80,6 +83,9 @@ class DesktopChessViewModelNew( val broadcastStatus: StateFlow = logic.state.broadcastStatus val error: StateFlow = logic.state.error val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val syncStatus: StateFlow = logic.state.syncStatus + val stateVersion: StateFlow = logic.state.stateVersion /** Badge count (incoming challenges + your turn games) - computed property */ val badgeCount: Int get() = logic.state.badgeCount @@ -98,11 +104,33 @@ class DesktopChessViewModelNew( fun forceRefresh() = logic.forceRefresh() + /** + * Ensure a game ID is being polled for updates. + * Call this when viewing a game. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when viewing a game to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby view. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + // ============================================ // Incoming event routing (from relay subscriptions) // ============================================ - fun handleIncomingEvent(event: Event) = logic.handleIncomingEvent(event) + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } // ============================================ // Challenge operations @@ -116,6 +144,8 @@ class DesktopChessViewModelNew( fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + // ============================================ // Game operations // ============================================ @@ -130,12 +160,6 @@ class DesktopChessViewModelNew( fun resign(gameId: String) = logic.resign(gameId) - fun offerDraw(gameId: String) = logic.offerDraw(gameId) - - fun acceptDraw(gameId: String) = logic.acceptDraw(gameId) - - fun declineDraw(gameId: String) = logic.declineDraw(gameId) - fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) // ============================================ @@ -156,6 +180,9 @@ class DesktopChessViewModelNew( fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + /** Helper for derived challenge lists */ fun incomingChallenges(): List = logic.state.incomingChallenges() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt new file mode 100644 index 000000000..967446d56 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Chess event kinds for cache filtering */ +private val CHESS_EVENT_KINDS = + setOf( + LiveChessGameChallengeEvent.KIND, + LiveChessGameAcceptEvent.KIND, + LiveChessMoveEvent.KIND, + LiveChessGameEndEvent.KIND, + LiveChessDrawOfferEvent.KIND, + ) + +/** + * Desktop implementation of [ChessSubscriptionController]. + * Uses the shared [ChessFilterBuilder] for consistent filter construction + * with dynamic updates when active games change. + */ +class DesktopChessSubscriptionController( + private val relayManager: RelayConnectionManager, + private val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + private val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +) : ChessSubscriptionController { + private val _currentState = MutableStateFlow(null) + override val currentState: StateFlow = _currentState.asStateFlow() + + // EOSE tracking per relay for efficient re-subscription + private val relayEoseTimes = mutableMapOf() + + // Current subscription ID + private var currentSubId: String? = null + + override fun subscribe(state: ChessSubscriptionState) { + // Unsubscribe previous if exists + unsubscribe() + + if (state.relays.isEmpty()) return + + _currentState.value = state + currentSubId = state.subscriptionId() + + // Build filters using shared logic + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { relay -> + relayEoseTimes[relay] + } + + // Group filters by relay + val filtersByRelay = relayBasedFilters.groupByRelay() + + // Subscribe on each relay with its specific filters + val allFilters = filtersByRelay.values.flatten().distinct() + + relayManager.subscribe( + subId = currentSubId!!, + filters = allFilters, + relays = state.relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Add chess events to local cache for persistence + if (event.kind in CHESS_EVENT_KINDS) { + val isNew = DesktopChessEventCache.add(event) + if (isNew) { + println("[ChessSubscription] Cached new event: kind=${event.kind}, id=${event.id.take(8)}") + } + } + onEvent(event, isLive, relay, forFilters) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Track EOSE time for this relay for efficient re-subscription + relayEoseTimes[relay] = TimeUtils.now() + onEose(relay, forFilters) + } + }, + ) + } + + override fun unsubscribe() { + currentSubId?.let { relayManager.unsubscribe(it) } + currentSubId = null + _currentState.value = null + } + + override fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set, + ) { + val current = _currentState.value ?: return + + // Create new state with updated game IDs + val newState = + current.copy( + activeGameIds = activeGameIds, + spectatingGameIds = spectatingGameIds, + ) + + // Only re-subscribe if game IDs actually changed + if (newState.subscriptionId() != current.subscriptionId()) { + subscribe(newState) + } + } + + override fun forceRefresh() { + // Clear EOSE cache to re-fetch full history + relayEoseTimes.clear() + _currentState.value?.let { subscribe(it) } + } +} + +/** + * Creates a subscription config for chess events with support for active game filtering. + * Uses the shared [ChessFilterBuilder] for consistent filter construction. + * + * @param relays Set of relays to subscribe to + * @param userPubkey User's public key for filtering personal events + * @param activeGameIds Set of game IDs the user is actively playing (for game-specific filters) + * @param opponentPubkeys Set of opponent pubkeys for active games (for move filtering) + * @param onEvent Callback for incoming events + * @param onEose Callback for EOSE (End of Stored Events) + */ +fun createChessSubscriptionWithGames( + relays: Set, + userPubkey: String, + activeGameIds: Set = emptySet(), + opponentPubkeys: Set = emptySet(), + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty()) return null + + val state = + ChessSubscriptionState( + userPubkey = userPubkey, + relays = relays, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + ) + + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { null } + + val filters = + relayBasedFilters + .groupByRelay() + .values + .flatten() + .distinct() + + return SubscriptionConfig( + subId = state.subscriptionId(), + filters = filters, + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use createChessSubscriptionWithGames instead + */ +@Deprecated( + "Use createChessSubscriptionWithGames for active game support", + ReplaceWith("createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)"), +) +fun createChessSubscription( + relays: Set, + userPubkey: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 41824f2d9..6c8a9b503 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -46,7 +48,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -60,6 +64,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscript import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers @@ -111,6 +118,13 @@ fun UserProfileScreen( var followersCount by remember { mutableStateOf(0) } var followingCount by remember { mutableStateOf(0) } + // Profile editing state (only for own profile) + val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex + var showEditDialog by remember { mutableStateOf(false) } + var editingDisplayName by remember { mutableStateOf("") } + var broadcastStatus by remember { mutableStateOf(ProfileBroadcastStatus.Idle) } + var latestMetadataEvent by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() // User's posts @@ -192,6 +206,14 @@ fun UserProfileScreen( displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") about = extractJsonField(content, "about") picture = extractJsonField(content, "picture") + + // Store MetadataEvent for editing (only for own profile) + if (isOwnProfile && event is MetadataEvent) { + val current = latestMetadataEvent + if (current == null || event.createdAt > current.createdAt) { + latestMetadataEvent = event + } + } } catch (e: Exception) { // Ignore parse errors } @@ -281,6 +303,19 @@ fun UserProfileScreen( } Column(modifier = Modifier.fillMaxSize()) { + // Broadcast banner for profile updates + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + // Clear banner on tap (could add retry logic for failed) + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + // Header with back button Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), @@ -298,6 +333,25 @@ fun UserProfileScreen( ) } + // Edit button for own profile + if (isOwnProfile && account?.isReadOnly == false) { + OutlinedButton( + onClick = { + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Follow/Unfollow button for other profiles if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { Column(horizontalAlignment = Alignment.End) { Button( @@ -586,6 +640,52 @@ fun UserProfileScreen( } } } + + // Edit Profile Dialog + if (showEditDialog && account != null) { + AlertDialog( + onDismissRequest = { showEditDialog = false }, + title = { Text("Edit Profile") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = editingDisplayName, + onValueChange = { editingDisplayName = it }, + label = { Text("Display Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + showEditDialog = false + scope.launch { + updateProfileDisplayName( + newDisplayName = editingDisplayName, + account = account, + relayManager = relayManager, + latestMetadataEvent = latestMetadataEvent, + currentDisplayName = displayName, + currentAbout = about, + currentPicture = picture, + onStatusUpdate = { broadcastStatus = it }, + onSuccess = { displayName = editingDisplayName }, + ) + } + }, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showEditDialog = false }) { + Text("Cancel") + } + }, + ) + } } /** @@ -648,3 +748,62 @@ private suspend fun unfollowUser( throw IllegalStateException("Cannot unfollow: No contact list available") } } + +/** + * Updates the user's profile display name by creating and broadcasting a new MetadataEvent. + */ +private suspend fun updateProfileDisplayName( + newDisplayName: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + latestMetadataEvent: MetadataEvent?, + currentDisplayName: String?, + currentAbout: String?, + currentPicture: String?, + onStatusUpdate: (ProfileBroadcastStatus) -> Unit, + onSuccess: () -> Unit, +) = withContext(Dispatchers.IO) { + val connectedRelays = relayManager.connectedRelays.value + if (connectedRelays.isEmpty()) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays")) + return@withContext + } + + val totalRelays = connectedRelays.size + onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays)) + + try { + // Create the new MetadataEvent + val template = + if (latestMetadataEvent != null) { + MetadataEvent.updateFromPast( + latest = latestMetadataEvent, + displayName = newDisplayName, + ) + } else { + MetadataEvent.createNew( + name = currentDisplayName, + displayName = newDisplayName, + picture = currentPicture, + about = currentAbout, + ) + } + + // Sign the event + val signedEvent = account.signer.sign(template) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + + // Update progress (simplified - just show success after broadcast) + // In a full implementation, you'd track OK responses from each relay + onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays)) + onSuccess() + + // Auto-hide banner after delay + delay(3000) + onStatusUpdate(ProfileBroadcastStatus.Idle) + } catch (e: Exception) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt new file mode 100644 index 000000000..959e9e903 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.random.Random + +/** + * Generates human-readable chess game names. + * + * Produces memorable names like "Bold Knight", "Swift Rook", etc. + */ +object ChessGameNameGenerator { + // Chess-themed adjectives + private val adjectives = + listOf( + "Bold", + "Swift", + "Cunning", + "Royal", + "Noble", + "Shadow", + "Silent", + "Golden", + "Silver", + "Iron", + "Fierce", + "Wise", + "Dark", + "Bright", + "Ancient", + "Brave", + "Mystic", + "Grand", + "Crimson", + "Azure", + "Ivory", + "Obsidian", + "Emerald", + "Sapphire", + "Ruby", + "Phantom", + "Eternal", + "Secret", + "Hidden", + "Blazing", + ) + + // Chess pieces and related nouns + private val chessNouns = + listOf( + "King", + "Queen", + "Rook", + "Bishop", + "Knight", + "Pawn", + "Castle", + "Crown", + "Throne", + "Gambit", + "Defense", + "Attack", + "Checkmate", + "Fortress", + "Tower", + "Endgame", + "Opening", + "Sacrifice", + "Pin", + "Fork", + "Strategy", + "Victory", + "Battle", + "Duel", + "Match", + "Challenge", + ) + + // Famous chess-related animals/themes + private val animals = + listOf( + "Dragon", + "Phoenix", + "Griffin", + "Eagle", + "Lion", + "Wolf", + "Tiger", + "Falcon", + "Hawk", + "Raven", + "Bear", + "Stallion", + "Serpent", + "Panther", + "Cobra", + ) + + /** + * Generate a random chess game name. + * + * @param includeNumber Whether to append a random number (1-99) + * @return A name like "Bold Knight" or "Swift Dragon 42" + */ + fun generate(includeNumber: Boolean = false): String { + val adjective = adjectives.random() + val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random() + val base = "$adjective $noun" + + return if (includeNumber) { + val num = Random.nextInt(1, 100) + "$base $num" + } else { + base + } + } + + /** + * Generate a name based on two player display names. + * + * Creates a name that incorporates elements from both players + * for a more personalized game name. + * + * @param playerName First player's display name (can be npub/pubkey) + * @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge) + * @return A personalized game name + */ + fun generateFromPlayers( + playerName: String?, + opponentName: String?, + ): String { + // Extract first letter or use adjective if name is a pubkey/npub + val p1Initial = extractInitial(playerName) + val p2Initial = opponentName?.let { extractInitial(it) } + + // If we have good initials, use them in the name + val adjective = + if (p1Initial != null) { + // Try to find an adjective starting with that letter + adjectives.find { it.startsWith(p1Initial, ignoreCase = true) } + ?: adjectives.random() + } else { + adjectives.random() + } + + val noun = + if (p2Initial != null) { + // Try to find a noun starting with opponent's initial + (chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) } + ?: if (Random.nextBoolean()) chessNouns.random() else animals.random() + } else { + // Open challenge - use "Challenge" or similar + listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random() + } + + return "$adjective $noun" + } + + /** + * Extract a usable initial from a name. + * Returns null if the name looks like a pubkey/npub. + */ + private fun extractInitial(name: String?): Char? { + if (name.isNullOrBlank()) return null + + // Skip if it looks like a hex pubkey or npub + if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null + if (name.startsWith("npub") || name.startsWith("nprofile")) return null + + // Get first letter of the display name + val firstChar = name.firstOrNull { it.isLetter() } + return firstChar?.uppercaseChar() + } + + /** + * Generate a game ID that includes a readable component. + * + * @param timestamp Unix timestamp + * @return Game ID like "chess-1234567890-bold-knight" + */ + fun generateGameId(timestamp: Long): String { + val name = + generate(includeNumber = false) + .lowercase() + .replace(" ", "-") + return "chess-$timestamp-$name" + } + + /** + * Extract the human-readable name from a game ID. + * + * @param gameId Game ID like "chess-1234567890-bold-knight" + * @return Display name like "Bold Knight", or null if not a named game ID + */ + fun extractDisplayName(gameId: String): String? { + // Pattern: chess-timestamp-name-parts + if (!gameId.startsWith("chess-")) return null + + val parts = gameId.removePrefix("chess-").split("-") + if (parts.size < 2) return null + + // First part is timestamp, rest is the name + val nameParts = parts.drop(1) + if (nameParts.isEmpty()) return null + + // Check if it looks like a UUID (8 hex chars) - old format + if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) { + return null + } + + // Convert to title case + return nameParts.joinToString(" ") { part -> + part.replaceFirstChar { it.uppercaseChar() } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt new file mode 100644 index 000000000..b59e8e2a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -0,0 +1,297 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable + +/** + * Deterministic chess state reconstruction from Jester protocol events. + * + * This is the single source of truth for converting a collection of Jester events + * into a consistent game state. Both Android and Desktop platforms must use this + * algorithm to ensure identical state from the same events. + * + * Jester Protocol Reconstruction: + * 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players + * 2. Find move with longest history → this is the current state + * 3. Replay moves from history to build engine state + * 4. Check for result/termination in latest move → mark finished if present + * + * Key Jester differences: + * - No separate accept event (acceptance is implicit) + * - Full move history in every move event + * - Can reconstruct from any single move (has complete history) + * - startEventId is the game identifier (event ID of start event) + * + * Guarantees: + * - Same events always produce same state (deterministic) + * - Uses longest history for authoritative state + * - Invalid moves are skipped without error + */ +object ChessStateReconstructor { + /** + * Reconstruct game state from a collection of Jester events. + * + * @param events The collected Jester events for a single game + * @param viewerPubkey The pubkey of the user viewing the game (determines perspective) + * @return ReconstructionResult or error if reconstruction fails + */ + fun reconstruct( + events: JesterGameEvents, + viewerPubkey: String, + ): ReconstructionResult { + // Step 1: Get start event (required for player info) + val startEvent = + events.startEvent + ?: return ReconstructionResult.Error("No start event found") + + val startEventId = startEvent.id + val challengerPubkey = startEvent.pubKey + val challengerColor = startEvent.playerColor() ?: Color.WHITE + val challengedPubkey = startEvent.opponentPubkey() + + // In Jester, we determine opponent from the p-tag or from move events + // For open challenges, we need to find who made the first move + val opponentFromMoves = + events.moves + .firstOrNull { it.pubKey != challengerPubkey } + ?.pubKey + + val actualOpponent = challengedPubkey ?: opponentFromMoves + + // Determine players based on challenger's color choice + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to actualOpponent + } else { + actualOpponent to challengerPubkey + } + + // Determine viewer's role + val viewerRole = + when (viewerPubkey) { + whitePubkey -> ViewerRole.WHITE_PLAYER + blackPubkey -> ViewerRole.BLACK_PLAYER + else -> ViewerRole.SPECTATOR + } + + val playerColor = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> Color.WHITE + ViewerRole.BLACK_PLAYER -> Color.BLACK + ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective + } + + val opponentPubkey = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> blackPubkey ?: "" + ViewerRole.BLACK_PLAYER -> whitePubkey ?: "" + ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black + } + + // Check if game is pending (no moves yet AND viewer is the challenger) + // If viewer accepted someone else's challenge, the game is NOT pending + val isChallenger = viewerPubkey == startEvent.pubKey + val isPendingChallenge = events.moves.isEmpty() && isChallenger + + // Step 2: Create engine and apply moves from the longest history + val engine = ChessEngine() + val latestMove = events.latestMove() + val history = latestMove?.history() ?: emptyList() + + // Track applied moves + val appliedMoveNumbers = mutableSetOf() + var isDesynced = false + + // Apply moves from history + for ((index, san) in history.withIndex()) { + val moveNumber = index + 1 + val result = engine.makeMove(san) + if (result.success) { + appliedMoveNumbers.add(moveNumber) + } else { + // Move failed - game might be desynced + isDesynced = true + // Try to recover by loading the FEN from latest move if available + latestMove?.fen()?.let { fen -> + engine.loadFen(fen) + } + break + } + } + + // Verify final position matches if we have FEN + latestMove?.fen()?.let { expectedFen -> + val currentFen = engine.getFen() + if (!fenPositionsMatch(currentFen, expectedFen)) { + isDesynced = true + engine.loadFen(expectedFen) + } + } + + // Step 3: Check game end status + val gameResult = latestMove?.result() + val gameStatus = + when { + gameResult != null -> { + val result = parseGameResult(gameResult) + GameStatus.Finished(result) + } + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + GameStatus.Finished( + if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS, + ) + } + engine.isStalemate() -> GameStatus.Finished(GameResult.DRAW) + else -> GameStatus.InProgress + } + + // Get the head event ID (for linking next move) + val headEventId = latestMove?.id ?: startEventId + + // Build the reconstructed state + val state = + ReconstructedGameState( + startEventId = startEventId, + headEventId = headEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + viewerRole = viewerRole, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + isPendingChallenge = isPendingChallenge, + currentPosition = engine.getPosition(), + moveHistory = engine.getMoveHistory(), + gameStatus = gameStatus, + isDesynced = isDesynced, + pendingDrawOffer = null, // Jester doesn't have explicit draw offers + appliedMoveNumbers = appliedMoveNumbers, + challengeCreatedAt = startEvent.createdAt, + timeControl = null, // Not supported in Jester + ) + + return ReconstructionResult.Success(state, engine) + } + + /** + * Compare FEN positions, ignoring halfmove clock and fullmove number. + * This provides a more lenient comparison that focuses on the actual board state. + */ + private fun fenPositionsMatch( + fen1: String, + fen2: String, + ): Boolean { + // FEN format: position activeColor castling enPassant halfmove fullmove + // Compare only first 4 parts (position, activeColor, castling, enPassant) + val parts1 = fen1.split(" ") + val parts2 = fen2.split(" ") + + if (parts1.size < 4 || parts2.size < 4) { + return fen1 == fen2 // Fallback to exact match + } + + return parts1[0] == parts2[0] && // Board position + parts1[1] == parts2[1] && // Active color + parts1[2] == parts2[2] && // Castling rights + parts1[3] == parts2[3] // En passant + } + + private fun parseGameResult(result: String?): GameResult = + when (result) { + "1-0" -> GameResult.WHITE_WINS + "0-1" -> GameResult.BLACK_WINS + "1/2-1/2" -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } +} + +/** + * The viewer's role in the game. + */ +enum class ViewerRole { + WHITE_PLAYER, + BLACK_PLAYER, + SPECTATOR, +} + +/** + * Result of game state reconstruction. + */ +sealed class ReconstructionResult { + data class Success( + val state: ReconstructedGameState, + val engine: ChessEngine, + ) : ReconstructionResult() + + data class Error( + val message: String, + ) : ReconstructionResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state + + fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine +} + +/** + * Reconstructed game state from Jester events. + * This is an immutable snapshot of the game at the time of reconstruction. + */ +@Immutable +data class ReconstructedGameState( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, + /** The current head event ID - used for linking the next move */ + val headEventId: String, + val whitePubkey: String?, + val blackPubkey: String?, + val viewerRole: ViewerRole, + val playerColor: Color, + val opponentPubkey: String, + val isPendingChallenge: Boolean, + val currentPosition: ChessPosition, + val moveHistory: List, + val gameStatus: GameStatus, + val isDesynced: Boolean, + val pendingDrawOffer: String?, + val appliedMoveNumbers: Set, + val challengeCreatedAt: Long, + val timeControl: String?, +) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + fun isPlayerTurn(): Boolean = + when (viewerRole) { + ViewerRole.SPECTATOR -> false + ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE + ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK + } + + fun isFinished(): Boolean = gameStatus is GameStatus.Finished + + fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey + + fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt new file mode 100644 index 000000000..f4f510793 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt @@ -0,0 +1,382 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Jester Protocol Implementation + * + * Compatible with jesterui (https://github.com/jesterui/jesterui) + * + * Key differences from previous implementation: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history, nonce + * - Event linking via e-tags: [startId] or [startId, headId] + * - Full move history included in every move event + * + * Content kind values: + * - 0: Game start (challenge) + * - 1: Move + * - 2: Chat (not implemented) + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +object JesterProtocol { + /** Jester uses kind 30 for all chess events */ + const val KIND = 30 + + /** SHA256 of starting FEN position - used as reference for game discovery */ + const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" + + /** Standard starting FEN */ + const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + /** Content kind for game start */ + const val CONTENT_KIND_START = 0 + + /** Content kind for move */ + const val CONTENT_KIND_MOVE = 1 + + /** Content kind for chat */ + const val CONTENT_KIND_CHAT = 2 +} + +/** + * JSON content structure for Jester events + */ +@Serializable +data class JesterContent( + val version: String = "0", + val kind: Int, + val fen: String = JesterProtocol.FEN_START, + val move: String? = null, + val history: List = emptyList(), + val nonce: String? = null, + // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) + val playerColor: String? = null, // "white" or "black" - challenger's color choice + val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end + val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. +) + +private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + +/** + * Jester Chess Event (Kind 30) + * + * Unified event class for all Jester chess messages. + * The content.kind field determines the event type: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat + */ +@Immutable +class JesterEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) { + private val parsedContent: JesterContent? by lazy { + try { + json.decodeFromString(content) + } catch (e: Exception) { + null + } + } + + /** Get the content kind (0=start, 1=move, 2=chat) */ + fun contentKind(): Int? = parsedContent?.kind + + /** Check if this is a game start event */ + fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START + + /** Check if this is a move event */ + fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE + + /** Get the FEN position */ + fun fen(): String? = parsedContent?.fen + + /** Get the latest move (SAN notation) */ + fun move(): String? = parsedContent?.move + + /** Get the full move history */ + fun history(): List = parsedContent?.history ?: emptyList() + + /** Get the nonce (for start events) */ + fun nonce(): String? = parsedContent?.nonce + + /** Get the player color choice (for start events) */ + fun playerColor(): Color? = + parsedContent?.playerColor?.let { + if (it == "white") Color.WHITE else Color.BLACK + } + + /** Get the game result (for end events) */ + fun result(): String? = parsedContent?.result + + /** Get the termination reason (for end events) */ + fun termination(): String? = parsedContent?.termination + + /** Get the start event ID (first e-tag) */ + fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + /** Get the head/parent move ID (second e-tag, for moves) */ + fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1) + + /** Get opponent pubkey (p-tag, for private games) */ + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + /** Get all e-tags */ + fun eTags(): List = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] } + + companion object { + const val KIND = JesterProtocol.KIND + + /** + * Build a game start event (open challenge) + * + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildStart( + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position for game discovery + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + initializer() + } + } + + /** + * Build a private game start event (direct challenge) + * + * @param opponentPubkey Pubkey of the challenged player + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildPrivateStart( + opponentPubkey: String, + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + // Tag the opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a move event + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or start event for first move) + * @param move The move in SAN notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for notifications + * @param createdAt Event timestamp + */ + fun buildMove( + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // e-tags: [startId, headId] + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a game end move (includes result in content) + * + * This is a move event with additional result/termination fields. + * Used when the game ends (checkmate, resignation, draw). + */ + fun buildEndMove( + startEventId: String, + headEventId: String, + move: String?, + fen: String, + history: List, + opponentPubkey: String, + result: GameResult, + termination: GameTermination, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + result = result.notation, + termination = termination.name.lowercase(), + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } +} + +/** + * Helper to check if an event is a Jester chess event + */ +fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND + +/** + * Helper to check if an event is a Jester start event + */ +fun Event.isJesterStartEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty() + } catch (e: Exception) { + false + } +} + +/** + * Helper to check if an event is a Jester move event + */ +fun Event.isJesterMoveEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_MOVE && + content.history.isNotEmpty() && + tags.count { it.size >= 2 && it[0] == "e" } == 2 + } catch (e: Exception) { + false + } +} + +/** + * Convert a raw Event to JesterEvent if valid + */ +fun Event.toJesterEvent(): JesterEvent? { + if (kind != JesterProtocol.KIND) return null + return JesterEvent(id, pubKey, createdAt, tags, content, sig) +} + +/** + * Game events container for Jester protocol + */ +data class JesterGameEvents( + val startEvent: JesterEvent?, + val moves: List, +) { + companion object { + fun empty() = JesterGameEvents(null, emptyList()) + } + + /** Get the latest move (with longest history) */ + fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } + + /** Get the current FEN position */ + fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START + + /** Get the complete move history */ + fun fullHistory(): List = latestMove()?.history() ?: emptyList() + + /** Check if game has ended */ + fun isEnded(): Boolean = latestMove()?.result() != null + + /** Get the game result if ended */ + fun result(): String? = latestMove()?.result() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt index 5233fde9a..13ab21098 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt @@ -23,115 +23,163 @@ package com.vitorpamplona.quartz.nip64Chess /* * Live chess game state and move events * - * For real-time chess games, we use a different event structure than NIP-64. - * NIP-64 is for completed games in PGN format. Live games need individual - * move events that can be published and subscribed to in real-time. + * Uses Jester protocol (kind 30) for compatibility with jesterui. + * See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md * - * Event Structure: - * - Game Challenge (Kind 30064): Challenge another player or open challenge - * - Game Accept (Kind 30065): Accept a challenge - * - Chess Move (Kind 30066): Individual move in a live game - * - Game End (Kind 30067): Game result (checkmate, resignation, draw, etc.) + * Key features of Jester protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind (0=start, 1=move), fen, move, history + * - Full move history included in every move event + * - Event linking via e-tags: [startId] or [startId, headId] * - * All events use 'd' tag with game_id to group moves from the same game + * This enables: + * - Easy game reconstruction from any single move event + * - Tolerance for missing intermediate moves + * - Compatibility with jesterui and other Jester clients */ /** - * Game challenge event - start a new game - * Kind: 30064 + * Game start/challenge data for publishing * - * Tags: - * - d: game_id (unique identifier) - * - p: opponent pubkey (optional, if challenging specific player) - * - player_color: white|black (color challenger wants to play) - * - time_control: time control format (e.g., "10+0", "5+3") + * Jester protocol (kind 30) start event: + * - e-tag: reference to standard start position hash + * - p-tag: opponent pubkey (for private/direct challenges) + * - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor + * + * @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge) + * @param playerColor Color the challenger wants to play + * @param nonce Unique identifier for this game */ data class ChessGameChallenge( - val gameId: String, val opponentPubkey: String? = null, // null = open challenge val playerColor: Color, - val timeControl: String? = null, -) + val nonce: String = generateNonce(), +) { + companion object { + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } + + // Legacy compatibility - gameId is now derived from start event ID after signing + @Deprecated("gameId is determined after event creation", ReplaceWith("startEventId")) + val gameId: String get() = nonce +} /** - * Game acceptance event - accept a challenge - * Kind: 30065 + * Game acceptance data * - * Tags: - * - d: game_id (same as challenge) - * - e: challenge event ID - * - p: challenger pubkey + * In Jester protocol, accepting a game is implicit - the opponent + * simply makes the first move (if challenger chose white) or waits + * for challenger's first move (if challenger chose black). + * + * This data class is kept for internal state tracking. + * + * @param startEventId ID of the game start event to accept + * @param challengerPubkey Pubkey of the challenger */ data class ChessGameAccept( - val gameId: String, - val challengeEventId: String, + val startEventId: String, val challengerPubkey: String, -) +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val challengeEventId: String get() = startEventId +} /** - * Chess move event - individual move in a live game - * Kind: 30066 + * Chess move data for publishing * - * Tags: - * - d: game_id - * - move_number: move number (1-based) - * - san: move in Standard Algebraic Notation - * - fen: resulting position in FEN notation - * - p: opponent pubkey (for notifications) + * Jester protocol (kind 30) requires: + * - Full move history in every move event + * - e-tags linking: [startEventId, headEventId] + * - Content JSON with: version, kind=1, fen, move, history * - * Content: Optional move comment or time remaining + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or startEventId for first move) + * @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN notation + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for p-tag notification */ data class ChessMoveEvent( - val gameId: String, - val moveNumber: Int, + val startEventId: String, + val headEventId: String, val san: String, val fen: String, + val history: List, val opponentPubkey: String, - val comment: String? = null, -) +) { + /** Move number (1-based, derived from history length) */ + val moveNumber: Int get() = history.size + + // Legacy compatibility - some code still uses gameId + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} /** - * Game end event - final game result - * Kind: 30067 + * Game end data for publishing * - * Tags: - * - d: game_id - * - result: 1-0|0-1|1/2-1/2 (white wins, black wins, draw) - * - termination: checkmate|resignation|draw_agreement|stalemate|timeout|abandonment - * - winner: pubkey of winner (if applicable) - * - p: opponent pubkey + * In Jester protocol, game end is a move event with result/termination + * fields in the content JSON. * - * Content: Optional game summary or final PGN + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move + * @param lastMove The final move (null for resignation without move) + * @param fen Final board position + * @param history Complete move history + * @param result Game result (1-0, 0-1, 1/2-1/2) + * @param termination How the game ended + * @param opponentPubkey Opponent's pubkey for notification */ data class ChessGameEnd( - val gameId: String, + val startEventId: String, + val headEventId: String, + val lastMove: String?, + val fen: String, + val history: List, val result: GameResult, val termination: GameTermination, - val winnerPubkey: String? = null, val opponentPubkey: String, - val pgn: String? = null, -) +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + val winnerPubkey: String? get() = null // Determined from result +} /** - * Draw offer event - offer a draw to opponent - * Kind: 30068 + * Draw offer data * - * Tags: - * - d: game_id - * - p: opponent pubkey - * - * Content: Optional message + * In Jester protocol, draw offers can be communicated via: + * - Chat message (kind=2 in content) + * - Special field in move content * * Opponent can: - * - Accept by sending a GameEnd event with DRAW_AGREEMENT + * - Accept by sending a game end with DRAW_AGREEMENT * - Decline implicitly by making their next move - * - Decline explicitly (optional, no event needed) + * + * @param startEventId ID of the game start event + * @param headEventId ID of the current head move + * @param opponentPubkey Opponent's pubkey for notification + * @param message Optional message with the draw offer */ data class ChessDrawOffer( - val gameId: String, + val startEventId: String, + val headEventId: String, val opponentPubkey: String, val message: String? = null, -) +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} /** * Game termination reason @@ -146,12 +194,31 @@ enum class GameTermination { } /** - * Event kind constants for live chess + * Event kind constants for live chess (Jester protocol) + * + * Jester uses a single kind (30) for all chess events. + * The event type is determined by the content.kind field: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat */ object LiveChessEventKinds { - const val GAME_CHALLENGE = 30064 - const val GAME_ACCEPT = 30065 - const val CHESS_MOVE = 30066 - const val GAME_END = 30067 - const val DRAW_OFFER = 30068 + /** Jester protocol uses kind 30 for all chess events */ + const val JESTER = 30 + + // Legacy constants - deprecated, use JESTER instead + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_CHALLENGE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_ACCEPT = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val CHESS_MOVE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_END = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val DRAW_OFFER = 30 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt index 6d737b860..f7c44e620 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -32,9 +32,15 @@ import kotlinx.coroutines.flow.asStateFlow * - ChessEngine (move validation, board state) * - Nostr events (publishing moves, receiving opponent moves) * - UI (game state, turn management) + * + * Jester Protocol Notes: + * - startEventId: ID of the game start event (used as game identifier) + * - headEventId: ID of the current head move (updated after each move) + * - Full move history is included in every published move event */ class LiveChessGameState( - val gameId: String, + /** Start event ID - the game identifier in Jester protocol */ + val startEventId: String, val playerPubkey: String, val opponentPubkey: String, val playerColor: Color, @@ -42,7 +48,17 @@ class LiveChessGameState( val createdAt: Long = TimeUtils.now(), val isSpectator: Boolean = false, val isPendingChallenge: Boolean = false, + /** Initial head event ID - same as startEventId for new games */ + initialHeadEventId: String? = null, ) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + /** Current head event ID - tracks the most recent move for e-tag linking */ + private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId) + val headEventId: StateFlow = _headEventId.asStateFlow() + companion object { // Abandon timeout: 24 hours without a move const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L @@ -100,7 +116,9 @@ class LiveChessGameState( /** * Make a move (called when player makes a move on the board) - * Returns the move event to be published to Nostr + * Returns the move event data to be published to Nostr + * + * Jester Protocol: Move events include full history and link to previous event */ fun makeMove( from: String, @@ -131,15 +149,13 @@ class LiveChessGameState( // Check for game end conditions checkGameEnd() - // Return move event to publish - // Use ply count (half-move count) for moveNumber so each move gets a unique - // d-tag in the Nostr addressable event. The full-move counter from chesslib - // repeats for White/Black in the same move pair, causing d-tag collisions. + // Return move event data with full history for Jester protocol return ChessMoveEvent( - gameId = gameId, - moveNumber = _moveHistory.value.size, + startEventId = startEventId, + headEventId = _headEventId.value, san = result.san, fen = engine.getFen(), + history = _moveHistory.value, // Full history for Jester opponentPubkey = opponentPubkey, ) } else { @@ -148,6 +164,38 @@ class LiveChessGameState( } } + /** + * Update the head event ID after a move is successfully published. + * Call this after the move event is signed and broadcast. + * + * @param newHeadEventId The ID of the newly published move event + */ + fun updateHeadEventId(newHeadEventId: String) { + _headEventId.value = newHeadEventId + } + + /** + * Undo the last move made. + * Used to revert a move when publishing fails. + * + * @return true if the move was successfully undone, false if there was nothing to undo + */ + fun undoLastMove(): Boolean { + if (_moveHistory.value.isEmpty()) return false + + engine.undoMove() + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastError.value = null + + // Reset game status in case checkmate was detected + if (_gameStatus.value is GameStatus.Finished) { + _gameStatus.value = GameStatus.InProgress + } + + return true + } + /** * Apply opponent's move (called when receiving move event from Nostr) */ @@ -240,6 +288,48 @@ class LiveChessGameState( _lastError.value = null } + /** + * Apply moves from another game state while preserving this state's participant info. + * This is used when relay reconstruction incorrectly marks us as spectator but has newer moves. + * + * @param other The other game state to take moves from + */ + fun applyMovesFrom(other: LiveChessGameState) { + val currentMoves = _moveHistory.value + val otherMoves = other.moveHistory.value + + if (otherMoves.size <= currentMoves.size) return // Nothing new to apply + + // Apply only the new moves + for (i in currentMoves.size until otherMoves.size) { + val san = otherMoves[i] + val result = engine.makeMove(san) + if (!result.success) { + // Try to resync from the other engine's FEN + val otherFen = other.engine.getFen() + engine.loadFen(otherFen) + break + } + } + + // Update state flows + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = other.lastActivityAt.value + _isDesynced.value = false + _lastError.value = null + + // Update head event ID from other state + _headEventId.value = other.headEventId.value + + // Mark new moves as received + val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet() + receivedMoveNumbers.addAll(newMoveNumbers) + + // Check for game end conditions + checkGameEnd() + } + /** * Mark move numbers as already received. * Used when loading a game from cache to prevent duplicate move application during refresh. @@ -281,12 +371,14 @@ class LiveChessGameState( _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor)) return ChessGameEnd( - gameId = gameId, + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, result = GameResult.getResultForWinner(playerColor), termination = GameTermination.ABANDONMENT, - winnerPubkey = playerPubkey, opponentPubkey = opponentPubkey, - pgn = generatePGN(), ) } @@ -297,12 +389,14 @@ class LiveChessGameState( _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite())) return ChessGameEnd( - gameId = gameId, + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, result = GameResult.getResultForWinner(playerColor.opposite()), termination = GameTermination.RESIGNATION, - winnerPubkey = opponentPubkey, opponentPubkey = opponentPubkey, - pgn = generatePGN(), ) } @@ -313,7 +407,8 @@ class LiveChessGameState( fun offerDraw(): ChessDrawOffer { _pendingDrawOffer.value = playerPubkey return ChessDrawOffer( - gameId = gameId, + startEventId = startEventId, + headEventId = _headEventId.value, opponentPubkey = opponentPubkey, ) } @@ -341,12 +436,14 @@ class LiveChessGameState( _gameStatus.value = GameStatus.Finished(GameResult.DRAW) return ChessGameEnd( - gameId = gameId, + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, result = GameResult.DRAW, termination = GameTermination.DRAW_AGREEMENT, - winnerPubkey = null, opponentPubkey = opponentPubkey, - pgn = generatePGN(), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 1eea61060..da44ed74c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent @@ -190,6 +191,7 @@ class EventFactory { CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt new file mode 100644 index 000000000..a293e1e30 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt @@ -0,0 +1,573 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for JesterEvent (Jester Protocol kind 30 events) + * + * Verifies: + * - Event kind is 30 + * - JSON content parsing (version, kind, fen, move, history) + * - e-tag structure for event linking + * - p-tag for opponent tagging + * - Start vs Move event detection + * - Compatibility with jesterui protocol + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +class JesterEventTest { + private val testPubkey = "abc123def456" + private val opponentPubkey = "opponent789xyz" + private val startEventId = "start-event-id-001" + + // ========================================================================== + // PROTOCOL CONSTANTS + // ========================================================================== + + @Test + fun `verify event kind is 30`() { + assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30") + assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30") + } + + @Test + fun `verify start position hash constant`() { + assertEquals( + "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879", + JesterProtocol.START_POSITION_HASH, + "Should match jesterui's START_POSITION_HASH", + ) + } + + @Test + fun `verify starting FEN constant`() { + assertEquals( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + JesterProtocol.FEN_START, + "Standard chess starting position", + ) + } + + @Test + fun `verify content kind constants`() { + assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0") + assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1") + assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2") + } + + // ========================================================================== + // START EVENT TESTS + // ========================================================================== + + @Test + fun `parse start event - open challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(30, event.kind) + assertTrue(event.isStartEvent(), "Should be detected as start event") + assertFalse(event.isMoveEvent(), "Should not be a move event") + assertEquals(0, event.contentKind()) + assertEquals(JesterProtocol.FEN_START, event.fen()) + assertEquals(Color.WHITE, event.playerColor()) + assertEquals("abc12345", event.nonce()) + assertTrue(event.history().isEmpty(), "Start event has no history") + assertNull(event.opponentPubkey(), "Open challenge has no opponent") + } + + @Test + fun `parse start event - private challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isStartEvent()) + assertEquals(Color.BLACK, event.playerColor()) + assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent") + } + + @Test + fun `start event e-tag references START_POSITION_HASH`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + val eTags = event.eTags() + assertEquals(1, eTags.size) + assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH") + } + + // ========================================================================== + // MOVE EVENT TESTS + // ========================================================================== + + @Test + fun `parse move event - first move e4`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}""" + val event = + JesterEvent( + id = "move-001", + pubKey = testPubkey, + createdAt = 2000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", startEventId), // For first move, head is also start + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertFalse(event.isStartEvent(), "Should not be a start event") + assertTrue(event.isMoveEvent(), "Should be detected as move event") + assertEquals(1, event.contentKind()) + assertEquals("e4", event.move()) + assertEquals(listOf("e4"), event.history()) + assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen()) + assertEquals(opponentPubkey, event.opponentPubkey()) + } + + @Test + fun `parse move event - multiple moves in history`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), // Previous move + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent()) + assertEquals("Nf3", event.move()) + assertEquals(listOf("e4", "e5", "Nf3"), event.history()) + assertEquals(3, event.history().size) + } + + @Test + fun `move event e-tags structure - startEventId and headEventId`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}""" + val headEventId = "move-002" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId") + assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId") + + val eTags = event.eTags() + assertEquals(2, eTags.size) + assertEquals(startEventId, eTags[0]) + assertEquals(headEventId, eTags[1]) + } + + // ========================================================================== + // GAME END EVENT TESTS + // ========================================================================== + + @Test + fun `parse game end event - checkmate`() { + val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}""" + val event = + JesterEvent( + id = "move-007", + pubKey = testPubkey, + createdAt = 8000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-006"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent(), "End event is still a move event") + assertEquals("1-0", event.result(), "Should have result") + assertEquals("checkmate", event.termination(), "Should have termination reason") + assertEquals(7, event.history().size) + } + + @Test + fun `parse game end event - resignation`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}""" + val event = + JesterEvent( + id = "move-002", + pubKey = opponentPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-001"), + arrayOf("p", testPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("0-1", event.result(), "Black wins") + assertEquals("resignation", event.termination()) + } + + @Test + fun `parse game end event - draw`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("1/2-1/2", event.result(), "Draw") + assertEquals("draw_agreement", event.termination()) + } + + // ========================================================================== + // EDGE CASES AND ERROR HANDLING + // ========================================================================== + + @Test + fun `handle malformed JSON content gracefully`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "invalid json {{{}", + sig = "test_sig", + ) + + assertNull(event.contentKind(), "Should return null for invalid JSON") + assertFalse(event.isStartEvent(), "Should not crash on invalid content") + assertFalse(event.isMoveEvent(), "Should not crash on invalid content") + assertNull(event.fen()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + } + + @Test + fun `handle empty content`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "", + sig = "test_sig", + ) + + assertNull(event.contentKind()) + assertFalse(event.isStartEvent()) + assertFalse(event.isMoveEvent()) + } + + @Test + fun `handle missing optional fields`() { + // Minimal valid content with only required fields + val content = """{"kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertEquals(1, event.contentKind()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + assertNull(event.result()) + assertNull(event.termination()) + assertNull(event.playerColor()) + } + + @Test + fun `handle event with no e-tags`() { + val content = """{"version":"0","kind":0}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertNull(event.startEventId(), "No e-tags means no startEventId") + assertNull(event.headEventId(), "No e-tags means no headEventId") + assertTrue(event.eTags().isEmpty()) + } + + @Test + fun `handle event with only one e-tag`() { + val content = """{"version":"0","kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", startEventId), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId()) + assertNull(event.headEventId(), "Only one e-tag means no headEventId") + } + + // ========================================================================== + // JESTER GAME EVENTS CONTAINER TESTS + // ========================================================================== + + @Test + fun `JesterGameEvents - empty returns correct values`() { + val events = JesterGameEvents.empty() + + assertNull(events.startEvent) + assertTrue(events.moves.isEmpty()) + assertNull(events.latestMove()) + assertEquals(JesterProtocol.FEN_START, events.currentFen()) + assertTrue(events.fullHistory().isEmpty()) + assertFalse(events.isEnded()) + assertNull(events.result()) + } + + @Test + fun `JesterGameEvents - latestMove returns move with longest history`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3")) + + // Provide moves in random order + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move2, move3, move1), + ) + + val latest = events.latestMove() + assertNotNull(latest) + assertEquals("move-003", latest.id, "Should return move with longest history") + assertEquals(3, latest.history().size) + } + + @Test + fun `JesterGameEvents - currentFen returns FEN from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4") + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5") + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals("fen-after-e5", events.currentFen()) + } + + @Test + fun `JesterGameEvents - fullHistory returns history from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals(listOf("e4", "e5"), events.fullHistory()) + } + + @Test + fun `JesterGameEvents - isEnded detects result in latest move`() { + val normalMove = createTestMoveEvent("move-001", listOf("e4")) + val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0") + + val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove)) + val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove)) + + assertFalse(ongoingGame.isEnded()) + assertTrue(finishedGame.isEnded()) + assertEquals("1-0", finishedGame.result()) + } + + // ========================================================================== + // EXTENSION FUNCTION TESTS + // ========================================================================== + + @Test + fun `isJesterEvent extension detects kind 30`() { + val jesterEvent = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "{}", + sig = "sig", + ) + + assertTrue(jesterEvent.isJesterEvent()) + } + + @Test + fun `toJesterEvent converts valid Event`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = """{"kind":0}""", + sig = "sig", + ) + + val jesterEvent = event.toJesterEvent() + assertNotNull(jesterEvent) + assertEquals("test", jesterEvent.id) + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createTestMoveEvent( + id: String, + history: List, + fen: String = "test-fen", + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } + + private fun createTestMoveEventWithResult( + id: String, + history: List, + result: String, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt new file mode 100644 index 000000000..3cacf93c0 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt @@ -0,0 +1,808 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Integration tests for ChessStateReconstructor with Jester protocol. + * + * These tests verify that the reconstruction algorithm produces correct, + * deterministic game states from Jester events (kind 30). + * + * Jester Protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history + * - Start events have content.kind=0 and reference START_POSITION_HASH via e-tag + * - Move events have content.kind=1 and reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * - No separate accept event - acceptance is implicit when opponent makes first move + * + * Test Categories: + * 1. Basic game lifecycle (challenge, moves, end) + * 2. Move ordering and reconstruction from history + * 3. Game end conditions (checkmate, resignation) + * 4. Viewer perspective (white player, black player, spectator) + * 5. Determinism tests + */ +class ChessStateReconstructorTest { + // Test pubkeys + private val whitePubkey = "white_pubkey_abc123" + private val blackPubkey = "black_pubkey_def456" + private val spectatorPubkey = "spectator_pubkey_xyz789" + private val startEventId = "start-event-001" + + // ========================================================================== + // 1. BASIC GAME LIFECYCLE TESTS + // ========================================================================== + + @Test + fun `reconstruct pending challenge - no moves yet`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess(), "Should reconstruct pending challenge") + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge, "Game should be pending") + assertEquals(startEventId, state.startEventId) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertTrue(state.moveHistory.isEmpty(), "No moves yet") + assertEquals(GameStatus.InProgress, state.gameStatus) + } + + @Test + fun `reconstruct game with initial moves - e4 e5`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0]) + assertEquals("e5", state.moveHistory[1]) + assertTrue(state.isPlayerTurn(), "White's turn after e4 e5") + assertEquals(Color.WHITE, state.currentPosition.activeColor) + } + + @Test + fun `reconstruct game - acceptance is implicit via first opponent move`() { + // In Jester, there's no separate accept event + // Game is considered accepted when opponent makes their first move + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge, "Game is active with moves") + assertEquals(blackPubkey, state.blackPubkey) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after e4") + } + + // ========================================================================== + // 2. MOVE RECONSTRUCTION FROM HISTORY + // ========================================================================== + + @Test + fun `reconstruct from latest move with full history`() { + // In Jester, each move contains the full history + // Reconstruction uses the move with the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + createdAt = 1000, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + createdAt = 2000, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + createdAt = 3000, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2, move3), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(3, state.moveHistory.size) + assertEquals(listOf("e4", "e5", "Nf3"), state.moveHistory) + assertEquals("move-003", state.headEventId, "Head should be latest move") + } + + @Test + fun `reconstruct with out-of-order moves - uses longest history`() { + // Events might arrive out of order, but we use the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + // Events arrive in reverse order + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move2, move1), // Reverse order + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0], "First move should be e4") + assertEquals("e5", state.moveHistory[1], "Second move should be e5") + } + + @Test + fun `reconstruct with duplicate moves - uses longest history`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move1Duplicate = + createMoveEvent( + id = "move-001-dup", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move1Duplicate, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + // Move2 has the longest history (2 moves), so that's what we reconstruct + assertEquals(2, state.moveHistory.size, "Should have 2 moves from longest history") + } + + // ========================================================================== + // 3. GAME END CONDITIONS TESTS + // ========================================================================== + + @Test + fun `reconstruct scholar's mate - checkmate by engine detection`() { + // Scholar's mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "Qxf7#", + fen = "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", + history = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6", "Qxf7#"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), // Only need latest move with full history + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + + val engine = result.getEngineOrNull()!! + assertTrue(engine.isCheckmate(), "Engine should detect checkmate") + + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + (state.gameStatus as GameStatus.Finished).result, + "White wins by checkmate", + ) + } + + @Test + fun `reconstruct game with resignation - result in move content`() { + // In Jester, resignation is indicated by result field in move content + val resignationMove = + createMoveEventWithResult( + id = "move-002", + pubKey = blackPubkey, // Black resigns + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + result = "1-0", // White wins by resignation + termination = "resignation", + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(resignationMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + (state.gameStatus as GameStatus.Finished).result, + "White wins by resignation", + ) + } + + @Test + fun `reconstruct fool's mate - 4 move checkmate for black`() { + // 1. f3 e5 2. g4 Qh4# + val finalMove = + createMoveEvent( + id = "move-004", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-003", + move = "Qh4#", + fen = "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", + history = listOf("f3", "e5", "g4", "Qh4#"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.BLACK_WINS, + (state.gameStatus as GameStatus.Finished).result, + "Black wins by fool's mate", + ) + } + + // ========================================================================== + // 4. VIEWER PERSPECTIVE TESTS + // ========================================================================== + + @Test + fun `reconstruct as white player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertEquals(blackPubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as black player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, blackPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.BLACK_PLAYER, state.viewerRole) + assertEquals(Color.BLACK, state.playerColor) + assertEquals(whitePubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as spectator - white perspective, no turn`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.SPECTATOR, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor, "Spectators see from white's perspective") + assertFalse(state.isPlayerTurn(), "Spectators never have a turn") + } + + @Test + fun `challenger is black - roles reversed correctly`() { + // Challenger chooses black + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, // Opponent (white) makes first move + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, blackPubkey, Color.BLACK, whitePubkey), + moves = listOf(move1), + ) + + val whiteResult = ChessStateReconstructor.reconstruct(events, whitePubkey) + val blackResult = ChessStateReconstructor.reconstruct(events, blackPubkey) + + assertTrue(whiteResult.isSuccess()) + assertTrue(blackResult.isSuccess()) + + val whiteState = whiteResult.getOrNull()!! + val blackState = blackResult.getOrNull()!! + + assertEquals(whitePubkey, whiteState.whitePubkey) + assertEquals(blackPubkey, whiteState.blackPubkey) + assertEquals(ViewerRole.WHITE_PLAYER, whiteState.viewerRole) + assertEquals(ViewerRole.BLACK_PLAYER, blackState.viewerRole) + } + + // ========================================================================== + // 5. ERROR HANDLING TESTS + // ========================================================================== + + @Test + fun `reconstruct without start event - should fail`() { + val events = JesterGameEvents(startEvent = null, moves = emptyList()) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + assertEquals("No start event found", (result as ReconstructionResult.Error).message) + } + + @Test + fun `reconstruct with empty events - should fail`() { + val events = JesterGameEvents.empty() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + } + + // ========================================================================== + // 6. CASTLING AND SPECIAL MOVES TESTS + // ========================================================================== + + @Test + fun `reconstruct game with kingside castling`() { + // 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "O-O", + fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4", + history = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + assertEquals("O-O", state.moveHistory[6]) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after white castled") + } + + // ========================================================================== + // 7. DETERMINISM TESTS + // ========================================================================== + + @Test + fun `same events always produce same state`() { + val events = createBasicGameEvents() + + // Reconstruct multiple times + val results = + (1..5).map { + ChessStateReconstructor.reconstruct(events, whitePubkey) + } + + // All should succeed + assertTrue(results.all { it.isSuccess() }) + + // All states should be identical + val states = results.map { it.getOrNull()!! } + val first = states.first() + + states.forEach { state -> + assertEquals(first.startEventId, state.startEventId) + assertEquals(first.moveHistory, state.moveHistory) + assertEquals(first.gameStatus, state.gameStatus) + assertEquals(first.currentPosition.activeColor, state.currentPosition.activeColor) + assertEquals(first.currentPosition.moveNumber, state.currentPosition.moveNumber) + assertEquals(first.appliedMoveNumbers, state.appliedMoveNumbers) + } + } + + @Test + fun `different move list order produces same state`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + ) + + val startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey) + + // Order 1: moves in order + val events1 = JesterGameEvents(startEvent, listOf(move1, move2, move3)) + + // Order 2: moves reversed (but move3 has longest history, so result is same) + val events2 = JesterGameEvents(startEvent, listOf(move3, move1, move2)) + + val result1 = ChessStateReconstructor.reconstruct(events1, whitePubkey) + val result2 = ChessStateReconstructor.reconstruct(events2, whitePubkey) + + assertTrue(result1.isSuccess()) + assertTrue(result2.isSuccess()) + + val state1 = result1.getOrNull()!! + val state2 = result2.getOrNull()!! + + assertEquals(state1.moveHistory, state2.moveHistory, "Move history should be identical") + assertEquals(state1.appliedMoveNumbers, state2.appliedMoveNumbers) + } + + // ========================================================================== + // 8. OPEN CHALLENGE TESTS + // ========================================================================== + + @Test + fun `reconstruct open challenge - no specific opponent`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(null, state.blackPubkey, "No opponent assigned yet for open challenge") + } + + @Test + fun `reconstruct open challenge with first opponent move`() { + // When an unknown player makes the first move, they become the opponent + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, // Now we know the opponent + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + // Opponent is determined from move events when not specified in start + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createBasicGameEvents(): JesterGameEvents { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + return JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + } + + private fun createStartEvent( + id: String, + challengerPubkey: String, + challengerColor: Color, + opponentPubkey: String?, + createdAt: Long = 1000, + ): JesterEvent { + val tags = + mutableListOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ) + opponentPubkey?.let { tags.add(arrayOf("p", it)) } + + val colorString = if (challengerColor == Color.WHITE) "white" else "black" + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = tags.toTypedArray(), + content = content, + sig = "sig-start", + ) + } + + private fun createMoveEvent( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } + + private fun createMoveEventWithResult( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + result: String, + termination: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson],"result":"$result","termination":"$termination"}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } +}